Security Lab

SSH-туннели на практике: локальный, обратный и SOCKS-проброс

1241
SSH-туннели на практике: локальный, обратный и SOCKS-проброс

SSH-туннель не создает отдельную зашифрованную трубу для каждого приложения. Клиент устанавливает одно SSH-соединение с сервером, а затем открывает внутри логические каналы для терминала, команд, SFTP и проброшенных TCP-соединений. Приложение видит обычный локальный порт, хотя данные проходят через шифрованный SSH-транспорт и выходят в сеть уже с другой машины.

Главное правило простое. В режиме -L конечный адрес открывает SSH-сервер. В режиме -R конечный адрес открывает SSH-клиент. В режиме -D приложение сообщает SOCKS-прокси, куда подключаться. Я всегда разбираю команду по трем точкам: где слушается порт, через какой сервер проходит поток и с какой стороны создается последнее TCP-соединение.

В июле 2026 года текущим выпуском остается OpenSSH 10.4. Современный OpenSSH пробрасывает TCP-порты и Unix-сокеты, создает локальный и удаленный SOCKS-прокси, а также поднимает TUN-интерфейсы. В обычной администраторской речи SSH-туннелем чаще называют проброс портов, а не полноценный IP-туннель.

Что происходит внутри SSH-соединения

На входе SSH принимает обычное TCP-соединение, упаковывает данные в канал и передает их внутри общего шифрованного транспорта. Противоположная сторона создает новое TCP-соединение до цели.

приложение
     |
     | обычный TCP
     v
 SSH-клиент
     |
     | один зашифрованный транспорт
     | несколько логических каналов
     v
 SSH-сервер
     |
     | новое TCP-соединение
     v
 целевой сервис

Шифрование заканчивается на SSH-сервере. Если сервер подключается к базе по незашифрованному протоколу, участок между SSH-сервером и базой останется открытым для соответствующего сетевого сегмента. Туннель защищает путь между клиентом и сервером SSH, но не заменяет TLS на конечном сервисе.

Целевой сервис обычно видит адрес стороны, которая открыла последнее соединение. При локальном пробросе база видит SSH-сервер, при обратном пробросе локальное приложение видит SSH-клиент. Журналы поэтому могут показывать бастион вместо реального пользователя.

Несколько туннелей способны работать внутри одного SSH-соединения. Экономия удобна, но общий транспорт становится общей точкой отказа. Разрыв SSH закрывает все вложенные каналы, а плохой канал связи повышает задержку сразу у нескольких потоков. Для высоконагруженного прокси я не считаю SSH универсальной заменой VPN или специализированному шлюзу.

Локальный, обратный и динамический проброс

Локальный проброс -L нужен, когда сервис доступен с SSH-сервера, но закрыт от рабочего компьютера. Например, PostgreSQL принимает соединения только из внутренней сети.

ssh -NT 
   -L 127.0.0.1:15432:db.internal:5432 
   admin@bastion.example
psql -> 127.0.0.1:15432
           |
           v
      SSH-клиент
           |
        шифрование
           |
           v
   bastion.example
           |
           v
  db.internal:5432

Адрес db.internal разрешает и открывает бастион. Запись localhost в правой части укажет на сам бастион, а не на мой компьютер. Левый адрес 127.0.0.1 ограничивает доступ локальной машиной. Пустой адрес или * может открыть порт другим узлам.

Обратный проброс -R открывает порт на SSH-сервере и ведет к сервису со стороны клиента. Такой режим помогает дать мониторингу доступ к агенту за NAT или временно показать тестовый веб-сервис.

ssh -NT 
   -R 127.0.0.1:9000:127.0.0.1:3000 
   deploy@vps.example
vps.example:9000
         |
         v
       sshd
         |
      шифрование
         |
         v
    SSH-клиент
         |
         v
 127.0.0.1:3000

Первый адрес относится к серверу vps.example, второй к компьютеру с SSH-клиентом. По умолчанию удаленный порт обычно слушает только loopback. Для публикации на внешнем интерфейсе сервер должен разрешить GatewayPorts. Я предпочитаю clientspecified. Значение yes заставляет удаленные пробросы слушать wildcard-адрес и повышает риск случайной публикации.

Динамический проброс -D запускает локальный SOCKS4/5-прокси. Конечный адрес передает приложение для каждого соединения.

ssh -NT -D 127.0.0.1:1080 admin@bastion.example
 
 curl --socks5-hostname 127.0.0.1:1080 https://example.com/

Форма --socks5-hostname передает доменное имя прокси. Обычный режим SOCKS5 может сначала разрешить имя локально, из-за чего DNS-запрос пойдет мимо SSH. Поведение браузеров зависит от настроек.

У OpenSSH есть менее известный зеркальный вариант. Команда -R без конечного адреса создает SOCKS-прокси на сервере, а подключения к целям выходят со стороны клиента.

ssh -NT -R 127.0.0.1:1080 admin@vps.example

Для обратного проброса порт 0 поручает серверу выбрать свободный номер автоматически. OpenSSH также поддерживает Unix-сокеты вместо TCP-портов. Пробрасывать сокет Docker опасно, поскольку удаленная сторона фактически получает управление хостом.

Режим Где открыт порт Кто подключается к цели Сценарий
-L На клиенте SSH-сервер Внутренняя база или панель
-R На сервере SSH-клиент Сервис за NAT
-D На клиенте SSH-сервер SOCKS для TCP-сервисов
-R без цели На сервере SSH-клиент Удаленный SOCKS
-w На TUN-интерфейсах Маршрутизация ОС IP-трафик между сетями

Как настроить, проверить и не сломать TLS

Повторяемые туннели я сохраняю в ~/.ssh/config. Конфигурация снижает риск перепутать стороны и позволяет задать контроль соединения.

Host db-tunnel
     HostName bastion.example
     User admin
     IdentityFile ~/.ssh/id_ed25519
     LocalForward 127.0.0.1:15432 db.internal:5432
     ExitOnForwardFailure yes
     ServerAliveInterval 30
     ServerAliveCountMax 3
     SessionType none
ssh db-tunnel

SessionType none запускает соединение без удаленной оболочки. На старом клиенте можно использовать -N -T. Параметр ExitOnForwardFailure yes проверяет, смог ли SSH создать слушающий порт, но не проверяет конечную базу. Туннель запустится даже при отказе db.internal:5432.

ServerAliveInterval и ServerAliveCountMax обнаруживают оборванное SSH-соединение, но не подключают его заново. Для постоянного туннеля нужен супервизор с перезапуском, например systemd, либо autossh.

Проверку я провожу по слоям. Сначала смотрю итоговую конфигурацию, затем слушающий сокет, TCP-доступ и протокол приложения.

ssh -G db-tunnel | grep -E 'hostname|user|localforward'
 
 ss -lntp | grep 15432
 nc -vz 127.0.0.1 15432
 psql -h 127.0.0.1 -p 15432

В Windows встроенный клиент OpenSSH использует тот же синтаксис. Порт можно проверить через PowerShell.

Get-NetTCPConnection -LocalPort 15432
 Test-NetConnection 127.0.0.1 -Port 15432

При ошибке запускаю ssh -vvv. Журнал помогает разделить четыре сбоя. Сервер недоступен, аутентификация не прошла, порт занят или противоположная сторона не смогла подключиться к цели. Сообщение administratively prohibited часто указывает на AllowTcpForwarding, PermitOpen или ограничения ключа.

HTTPS через локальный порт создает отдельную ловушку. При запросе к https://127.0.0.1:8443 клиент проверит сертификат для IP-адреса и может отправить неверное имя SNI. Отключать проверку через -k не нужно. Я сохраняю настоящее имя сервиса и направляю только соединение на loopback.

ssh -NT 
   -L 127.0.0.1:8443:app.internal:443 
   admin@bastion.example
 
 curl --resolve app.internal:8443:127.0.0.1 
   https://app.internal:8443/

curl подключится к локальному порту, но оставит app.internal для SNI, HTTP Host и проверки сертификата.

Границы применимости и безопасная конфигурация

Режимы -L, -R и -D переносят TCP-потоки. UDP, широковещательные пакеты и маршруты ОС автоматически в них не попадают. Для IP-трафика OpenSSH предлагает -w с TUN-интерфейсами, но сервер должен разрешить PermitTunnel, а администратор настроить адреса, маршруты и фильтрацию. По сложности такой режим уже ближе к ручной сборке VPN.

Проброс не добавляет аутентификацию конечному приложению. Если панель доверяет любому клиенту из localhost, туннель откроет доступ владельцу SSH-учетной записи. Если обратный порт опубликован на 0.0.0.0, внутренний сервис рискует оказаться доступным всей сети или интернету.

На сервере я ограничиваю направление и допустимую цель. Полный перечень параметров содержит справочник sshd_config.

Match User tunnel-db
     PasswordAuthentication no
     PermitTTY no
     X11Forwarding no
     AllowAgentForwarding no
     AllowStreamLocalForwarding no
     AllowTcpForwarding local
     PermitOpen db.internal:5432
     MaxSessions 0

MaxSessions 0 запрещает оболочку, команды и SFTP, но сохраняет проброс. PermitOpen ограничивает цели локального режима. Для обратного режима применяют AllowTcpForwarding remote и PermitListen. Имена в PermitOpen сравниваются буквально, поэтому клиент должен запросить именно разрешенное имя или адрес.

Один запрет AllowTcpForwarding no не делает shell-учетную запись безопасной. Пользователь с оболочкой может запустить собственный nc, socat или прокси. Служебной учетной записи нужно одновременно убрать shell-сессии, PTY, агент, X11, Unix-сокеты и лишние команды. Директива DisableForwarding yes отключает все виды перенаправления сразу.

SSH-туннель хорошо решает узкую задачу. Для одного внутреннего TCP-сервиса подходит -L, для сервиса за NAT подходит -R, для нескольких TCP-подключений через SOCKS подходит -D. После запуска нужно проверить открытый порт, конечный протокол, DNS, TLS-имя и серверные ограничения. Если требуется UDP, высокая пропускная способность, отказоустойчивость или полноценная маршрутизация подсетей, лучше выбрать VPN, сервисную сетку или специализированный bastion access gateway.

Alt text
Обращаем внимание, что все материалы в этом блоге представляют личное мнение их авторов. Редакция SecurityLab.ru не несет ответственности за точность, полноту и достоверность опубликованных данных. Вся информация предоставлена «как есть» и может не соответствовать официальной позиции компании.

антипов жжёт

В санатории вас лечат.
Или просто доят?

// пиявки, озон и клизмы в вашем счёте →

Юрий Кочетов

Здесь я делюсь своими не самыми полезными, но крайне забавными мыслями о том, как устроен этот мир. Если вы устали от скучных советов и правильных решений, то вам точно сюда.