Vincent Bernat показал, как сделать свой HTTP-туннель на SSH и nginx

Исходная задача простая: другу нужно прочитать черновик вашего поста, но предпросмотр работает только на localhost:8080. Vincent Bernat перечисляет готовые варианты: ngrok и Cloudflare Quick Tunnels, коммерческие сервисы, frp и localtunnel можно развернуть у себя, но нужен их собственный клиент, sish обходится обычным SSH-клиентом, но требует особого SSH-сервера. Он предлагает решение только на OpenSSH и nginx.
Базовая схема. Командой ssh -N -R 0:localhost:8080 web02.luffy.cx локальный порт пробрасывается на сервер; значение 0 в качестве удалённого порта означает, что сервер сам выделит свободный (в примере, 41535). Дальше nginx по регулярному выражению в server_name берёт пятизначный номер порта из поддомена вида p41535.ssh.luffy.cx и проксирует запрос на http://127.0.0.1:41535. Для этого нужны DNS-записи на *.ssh.luffy.cx, запись CAA и сертификат на все поддомены от Let's Encrypt, полученный через ACME DNS-01 (зона acme.luffy.cx размещена в Route 53; на NixOS автора сертификаты выпускаются автоматически).
Доступ. В базовой схеме единственный «секрет», номер порта, тогда как другие решения добавляют в доменное имя случайную строку, чтобы нельзя было перебрать значения. Автор усиливает схему «немного» модулем ngx_http_secure_link_module: модуль считает хэш от набора значений с секретом и сверяет его с хэшем из запроса. Хэш закодирован в base64, поэтому в домен (он нечувствителен к регистру) его не положить, и его передают как имя пользователя в URL вместе с меткой времени окончания: https://хэш--срок@p41535.ssh.luffy.cx/путь. Клиент отправляет это имя через HTTP basic authentication, что работает с большинством клиентов, включая curl. nginx отдаёт имя в переменной $remote_user; директива map с регулярным выражением (22 символа хэша, два дефиса, число) разбивает его на хэш и срок, а secure_link_md5 хэширует строку из срока, порта и секрета. Переменная $secure_link пуста, если хэши не совпали, равна «0», если совпали, но ссылка истекла, и «1» в остальных случаях. При неверном или отсутствующем хэше nginx отвечает 401 с заголовком WWW-Authenticate, при истёкшей ссылке, 410. Перед проксированием заголовок Authorization удаляется, добавлены директивы для WebSocket, отключена буферизация, proxy_read_timeout равен 30 минутам.
Скрипт. Считать хэш вручную через printf, openssl md5, openssl base64 и tr неудобно, поэтому автор пишет вспомогательный скрипт. Главная трудность, узнать порт, который выделил OpenSSH: он не попадает ни в одну переменную окружения. Скрипт поднимается по цепочке родительских процессов к sshd-session, затем через sudo -n ss находит порты, которые слушают эти процессы, и для каждого печатает ссылку; срок жизни в скрипте, 86400 секунд от текущего момента. После этого скрипт остаётся работать (sleep infinity), чтобы сессия не закрылась. Скрипт ставится на сервер как http-over-ssh, а в ~/.ssh/config добавляется запись Host http-over-ssh с RemoteCommand и ControlPath none. Тогда одна команда ssh -R 0:localhost:8080 http-over-ssh поднимает туннель и выдаёт ссылку. Автор отмечает, что опирается лишь на OpenSSH и nginx, уже работающие на его сервере, и ссылается на полный скрипт с мелкими улучшениями и на модуль http-over-ssh.nix для NixOS.
Ключевые факты
- Туннель собран только из OpenSSH (ssh -R 0:localhost:8080, сервер сам выбирает свободный порт) и nginx, без отдельного клиента или специального SSH-сервера.
- nginx берёт пятизначный порт из поддомена p<порт>.ssh.luffy.cx и проксирует на 127.0.0.1; нужны DNS на *.ssh.luffy.cx и сертификат Let's Encrypt на все поддомены.
- Защита через ngx_http_secure_link_module: хэш MD5 от срока, порта и секрета идёт в URL как имя пользователя; неверный или отсутствующий хэш даёт 401, истёкшая ссылка, 410.
- Скрипт-помощник находит выделенный порт через родительские процессы sshd-session и sudo -n ss и печатает готовую ссылку со сроком жизни 86400 секунд.
- Автор сам называет защиту лишь «немного» усиленной схемой; в примерах секрет и порт иллюстративные.
Почему это важно
Для ИИ-новостей тема побочная: это инженерный рецепт, а не новость про модели. Зато он показывает, как поделиться локальным сервисом (например, предпросмотром или локальным интерфейсом) без сторонних сервисов вроде ngrok или Cloudflare Quick Tunnels. Решение держится на двух программах, которые у автора уже работали на сервере.
Кому это важно
Тем, у кого есть свой сервер с OpenSSH и nginx и кто хочет временно показать коллеге или другу то, что запущено на localhost. Подойдёт и тем, кто не хочет ставить специальный клиент (как у frp и localtunnel) или специальный SSH-сервер (как у sish). Автор отдельно упоминает NixOS, где есть готовый модуль http-over-ssh.nix.
Как это применить
Нужны: сервер с OpenSSH и nginx; DNS-записи на *.ssh.<ваш домен> (CNAME на сервер, CAA с issuewild для letsencrypt.org, CNAME для _acme-challenge); сертификат на все поддомены через ACME DNS-01; конфигурация nginx из поста; скрипт http-over-ssh на сервере и запись Host http-over-ssh с RemoteCommand и ControlPath none в ~/.ssh/config. После этого команда ssh -R 0:localhost:8080 http-over-ssh печатает ссылку, которую можно отправлять. Полный скрипт с мелкими улучшениями автор выложил по ссылке в конце поста. Цены в тексте не обсуждаются.
Можно ли доверять
Это личный блог автора, который описывает собственную рабочую конфигурацию на своём сервере web02.luffy.cx, и код приведён целиком. Сам автор говорит, что схема защищает лишь «немного». В тексте нет ни аудита безопасности, ни замеров скорости, ни сравнения по цене и возможностям с ngrok, frp, localtunnel или sish: есть только короткая классификация альтернатив. Секрет ZuPerS3cr3! и порт 41535, примеры, а не реальные данные.
Риски и подводные камни
Без хэша порт остаётся единственным «секретом», о чём предупреждает сам автор. Модуль secure_link использует MD5, и более сильных вариантов в посте не обсуждается. Секрет нужно задать и в конфигурации nginx, и в скрипте. Скрипт вызывает sudo -n ss, то есть пользователю на сервере нужно разрешить эту команду без пароля. Выделенный порт OpenSSH нигде не публикуется, поэтому скрипт ищет его обходным путём через процессы sshd-session; он работает, только когда запущен внутри SSH-сессии. Ссылка содержит хэш в части с именем пользователя и работает через HTTP basic authentication.