SSH-туннели и проброс портов на практике

(последнее обновление от )

SSH умеет не только давать шелл на удалённой машине. Через одно соединение можно пробрасывать порты в обе стороны и поднимать SOCKS-прокси. Это закрывает кучу задач без публичных IP для всех серверов. В домашней сети SSH-туннели стали для меня основным инструментом, когда нужно достучаться до сервиса через localhost.

Разберём три режима проброса портов и пару приёмов, которые делают туннели удобными в повседневной работе.

Локальный проброс: -L

Локальный проброс открывает порт на вашей машине и заворачивает трафик через сервер к нужному адресу. Классический случай: сервис слушает только 127.0.0.1 на удалённом хосте, и снаружи к нему не подключиться.

$ ssh -L 8080:127.0.0.1:80 user@server

Теперь запрос на localhost:8080 уходит по SSH-соединению и на сервере превращается в запрос к 127.0.0.1:80. Так я открываю веб-морду сервиса, которая принципиально не выставлена в сеть.

Адрес назначения разрешается со стороны сервера. Поэтому можно пробросить порт не самого сервера, а машины в его сети:

$ ssh -L 5432:db.internal:5432 user@gateway

Подключаетесь к localhost:5432, а попадаете на базу db.internal, видимую только из внутренней сети шлюза. Удобно для разовых задач, когда поднимать WireGuard ради одного запроса избыточно.

Вот как это выглядит на стенде из раздела «Проверка на стенде» ниже. Хост db.internal доступен только со шлюза:

$ curl db.internal:5432            # напрямую с вашей машины
curl: (6) Could not resolve host: db.internal

$ ssh -fN -L 5432:db.internal:5432 user@gateway
$ curl -s localhost:5432           # тот же запрос через туннель
db-node

Обратный проброс: -R

Обратный проброс работает в другую сторону. Он открывает порт на удалённом сервере и заворачивает трафик к вам. Это способ выставить наружу сервис, который крутится на локальной машине за NAT.

$ ssh -R 9000:127.0.0.1:3000 user@server

Теперь обращение к localhost:9000 на сервере придёт на ваш 127.0.0.1:3000. Так можно показать коллеге локально запущенное приложение или принять вебхук на машину без белого IP.

На стенде приложение слушает 127.0.0.1:3000 на стороне клиента. После проброса оно доступно прямо со шлюза:

$ ssh -fN -R 9000:127.0.0.1:3000 user@gateway
$ curl -s localhost:9000           # эта команда выполняется на gateway
local-app

По умолчанию проброшенный порт слушает только 127.0.0.1 сервера. Чтобы он стал доступен другим машинам, нужен GatewayPorts yes в /etc/ssh/sshd_config на сервере. Без явной необходимости я это не включаю. Каждый лишний открытый порт расширяет поверхность атаки.

Динамический проброс: -D

Флаг -D поднимает на вашей машине SOCKS-прокси. Весь трафик, который вы направите в этот прокси, выйдет в сеть со стороны сервера.

$ ssh -D 1080 user@server

Дальше настраиваете браузер или приложение на SOCKS5-прокси 127.0.0.1:1080. Один туннель заменяет десяток -L: не нужно заранее знать порты и адреса. Я так захожу в админки домашних сервисов, когда нахожусь вне домашней сети, через Proxmox-хост как точку входа.

Запрос через прокси выходит в сеть со стороны сервера, поэтому достаёт тот же внутренний хост:

$ ssh -fN -D 1080 user@gateway
$ curl -s --socks5-hostname localhost:1080 http://db.internal:5432/
db-node

Прыжок через бастион: ProxyJump

Часто до нужной машины нет прямого доступа. Есть только бастион, а за ним внутренняя сеть. Флаг -J пробрасывает соединение через промежуточный сервер:

$ ssh -J user@bastion user@internal-host

SSH сам поднимет промежуточный туннель и подключит вас к целевой машине. Это чище, чем вложенные ssh внутри ssh, и работает для копирования файлов через scp -J тоже.

На стенде db.internal напрямую недоступен, но через шлюз ходит без проблем:

$ ssh user@db.internal             # напрямую с вашей машины
ssh: Could not resolve hostname db.internal: Name does not resolve

$ ssh -J user@gateway user@db.internal hostname
2e71c81621fc                       # это контейнер db, доступный только через gateway

Туннели без шелла и в фоне

Если нужен только проброс, шелл можно не открывать:

$ ssh -fN -L 8080:127.0.0.1:80 user@server

Туннель будет жить фоновым процессом. Завершить его можно через pkill -f по строке команды или найдя PID.

Чтобы туннель не отваливался

Долгие туннели рвутся: меняется сеть, NAT забывает про простаивающее соединение. Два средства решают почти всё.

Первое: keepalive в ~/.ssh/config. Клиент будет периодически слать пакеты, чтобы соединение не считалось мёртвым:

Host server
    HostName server.example.com
    User user
    ServerAliveInterval 30
    ServerAliveCountMax 3

Второе: autossh. Утилита следит за туннелем и перезапускает его при обрыве:

$ autossh -M 0 -fN -L 8080:127.0.0.1:80 server

-M 0 отключает отдельный мониторинговый порт и полагается на ServerAliveInterval, поэтому задайте его флагом или в конфиге. Для постоянных туннелей я заворачиваю autossh в systemd-юнит, чтобы он стартовал с системой и переживал перезагрузки.

Проверить восстановление просто. Убейте дочерний ssh, который держит туннель, и autossh поднимет новый:

$ autossh -M 0 -f -N -o ServerAliveInterval=3 -L 8090:127.0.0.1:80 user@gateway
$ curl -s localhost:8090
internal-admin
$ pkill -x ssh                     # имитируем обрыв (autossh при этом жив)
$ curl -s localhost:8090           # пара секунд, и туннель снова поднят
internal-admin

Конфиг вместо длинных команд

Повторяющиеся туннели удобно описать прямо в ~/.ssh/config:

Host db-tunnel
    HostName gateway.example.com
    User user
    LocalForward 5432 db.internal:5432
    ServerAliveInterval 30

После этого ssh -fN db-tunnel поднимет именно нужный проброс. Никаких длинных строк с флагами, и параметры лежат в одном месте.

Проверка на стенде

Все примеры выше я прогнал на воспроизводимом стенде из трёх контейнеров. Топология повторяет реальный кейс:

Образ собираем заранее. Пакет openssh в Alpine по умолчанию запрещает проброс портов (AllowTcpForwarding no), поэтому включаем его явно:

FROM alpine:3.20
RUN apk add --no-cache openssh python3 curl autossh \
 && ssh-keygen -A \
 && sed -i 's/^AllowTcpForwarding no/AllowTcpForwarding yes/' /etc/ssh/sshd_config \
 && adduser -D user && echo 'user:user' | chpasswd \
 && install -d -m700 -o user -g user /home/user/.ssh

Сам стенд:

# docker-compose.yml, запуск: docker compose up -d --build
x-srv: &srv
  build: .
  volumes: [./keys:/keys:ro]

services:
  gateway:                       # бастион + сервис только на 127.0.0.1:80
    <<: *srv
    command:
      - sh
      - -c
      - |
        install -m600 -o user /keys/id.pub /home/user/.ssh/authorized_keys
        echo internal-admin > /index.html
        ( cd / && python3 -m http.server 80 --bind 127.0.0.1 & )
        exec /usr/sbin/sshd -D -e
    networks: [front, back]

  db:                            # внутренний сервис :5432, без выхода в интернет
    <<: *srv
    command:
      - sh
      - -c
      - |
        install -m600 -o user /keys/id.pub /home/user/.ssh/authorized_keys
        cd / && echo db-node > index.html
        ( python3 -m http.server 5432 --bind 0.0.0.0 & )
        exec /usr/sbin/sshd -D -e
    networks:
      back:
        aliases: [db.internal]

  client:                        # ваша машина
    <<: *srv
    command:
      - sh
      - -c
      - |
        install -d -m700 /root/.ssh
        install -m600 /keys/id /root/.ssh/id_ed25519
        cat > /root/.ssh/config <<EOF
        Host *
          User user
          StrictHostKeyChecking no
          UserKnownHostsFile /dev/null
          LogLevel ERROR
        EOF
        exec sleep infinity
    networks: [front]

networks:
  front:
  back:
    internal: true

Генерируем ключ для входа в контейнеры и поднимаем стенд:

$ ssh-keygen -t ed25519 -N '' -f keys/id
$ docker compose up -d --build

Дальше заходим в client и повторяем любой пример из статьи:

$ docker compose exec client sh
/ # ssh -fN -L 5432:db.internal:5432 user@gateway
/ # curl -s localhost:5432
db-node

db.internal доступен только со шлюза, но туннель пробрасывает его на вашу машину. Так же проверяются -R, -D и -J.

Итог

SSH-туннели закрывают три типовые задачи. -L тянет удалённый порт к себе, -R выставляет ваш порт наружу, -D даёт SOCKS-прокси на всю сеть сервера. ProxyJump проводит через бастион, autossh и ServerAliveInterval держат соединение живым.

Для разовых задач это быстрее выделения белых IP-адресов: одна команда, и доступ есть. Когда туннелей становится много или нужен постоянный доступ ко всей подсети, я перехожу на WireGuard. Но начинается всё почти всегда с обычного ssh -L.


Теги: