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.