Базовая настройка#
Сначала нужно пробросить соединения с порта удалённого сервера на локальный сервис:
$ ssh -N -R 0:localhost:8080 web02.luffy.cx
Allocated port 41535 for remote forward to localhost:8080
При указании 0 в качестве удалённого порта сервер выделяет свободный порт. Затем настраиваем nginx, чтобы он проксировал запросы с адреса https://p41535.ssh.luffy.cx на http://127.0.0.1:41535:
server {
listen 0.0.0.0:443 ssl ;
listen [::0]:443 ssl ;
server_name ~^p(?<port>\d\d\d\d\d)\.ssh\.luffy\.cx$;
location / {
proxy_pass http://127.0.0.1:$port;
}
}
Также нужно добавить DNS-записи для *.ssh.luffy.cx и получить wildcard-сертификат через Let's Encrypt:
*.ssh.luffy.cx. CNAME web02.luffy.cx.
ssh.luffy.cx. CAA 0 issuewild "letsencrypt.org"
_acme-challenge.ssh.luffy.cx CNAME ssh.luffy.cx.acme.luffy.cx.
acme.luffy.cx — это зона на Route 53, используется для ACME DNS-01 challenges, как для wildcard-сертификатов, так и для доменов, обслуживаемых несколькими веб-серверами. На NixOS сертификаты загружаются автоматически.
Контроль доступа#
Порт — единственный «секрет»1, защищающий содержимое от несанкционированного доступа. Другие решения для туннелирования добавляют в имя домена случайную строку, чтобы затруднить подбор возможных значений.
С помощью ngx_http_secure_link_module можно немного укрепить эту настройку. Модуль вычисляет хеш2 над набором значений, включая секрет, и сравнивает его с хешем из запроса. Хеш кодируется в base64, поэтому его нельзя поместить в имя домена (они регистронечувствительны). Вместо этого помещаем его в URL в качестве username вместе с временем истечения:3
https://6J3jK1WmB15c6WmjW_X-Wg--1789928654@p41535.ssh.luffy.cx/en/blog
╰─────────┬──────────╯ ╰───┬────╯ ╰─┬─╯ ╰──┬───╯
hash expires port path
Клиент отправляет username на сервер с помощью HTTP basic authentication. Это работает с большинством HTTP-клиентов, включая curl. Nginx предоставляет username в переменной $remote_user. Модуль ожидает хеш и метку времени истечения, разделённые запятой. Используем директиву map, чтобы извлечь обе части из $remote_user и соединить их запятой.4 Также передаём модулю строку для хеширования. Она содержит метку времени истечения, порт и секрет:
map $remote_user $httpssh_link {
"~^([-_A-Za-z0-9]{22})--([0-9]+)$" "$1,$2";
}
server {
# […]
location / {
secure_link $httpssh_link;
secure_link_md5 "$secure_link_expires $port ZuPerS3cr3!";
}
}
Модуль возвращает статус проверки в переменной $secure_link:
- пусто, если хеши не совпадают,
"0", если они совпадают, но ссылка истекла, или"1"в остальных случаях.
Если хеш неверен или отсутствует, возвращаем ошибку 401 с заголовком WWW-Authenticate, чтобы запросить учётные данные. Если ссылка истекла, возвращаем ошибку 410. Удаляем заголовок Authorization перед пересылкой запроса и добавляем несколько директив для проксирования WebSocket-соединений. Вот полная конфигурация:5
map $remote_user $httpssh_link {
"~^([-_A-Za-z0-9]{22})--([0-9]+)$" "$1,$2";
}
server {
listen 0.0.0.0:443 ssl ;
listen [::0]:443 ssl ;
server_name ~^p(?<port>\d\d\d\d\d)\.ssh\.luffy\.cx$;
location / {
secure_link $httpssh_link;
secure_link_md5 "$secure_link_expires $port ZuPerS3cr3!";
if ($secure_link = "") {
add_header WWW-Authenticate 'Basic realm="tunnel"' always;
return 401;
}
if ($secure_link = "0") {
return 410;
}
proxy_pass http://127.0.0.1:$port;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header Authorization "";
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_buffering off;
proxy_read_timeout 30m;
}
}
Теперь естественный вопрос: как сгенерировать хеш? Проще простого!
$ expires=$(( $(date +%s) + 86400 ))
$ port=41535
$ secret='ZuPerS3cr3!'
$ printf '%s %s %s' "$expires" "$port" "$secret" \
> | openssl md5 -binary \
> | openssl base64 \
> | tr +/ -_ | tr -d =
6J3jK1WmB15c6WmjW_X-Wg
Вероятно, сейчас скажете: «Это неудобно! Буду использовать ngrok». Согласен. Напишем вспомогательный скрипт.
Вспомогательный скрипт#
Главная сложность — найти эфемерный порт, который выделил OpenSSH, так как он не попадает ни в одну переменную окружения.6 Чтобы обойти это препятствие, ищем предков процессов sshd-session:7
pids=$(
pid=$$
while [ "$pid" -gt 1 ]; do
line=$(ps -o comm=,pid=,ppid= -p "$pid")
echo "$line"
pid=${line##* }
done | awk '$1 == "sshd-session" { printf "pid=%s,\n", $2 }'
)
if [ -z "$pids" ]; then
echo "not an ssh session" >&2
exit 1
fi
Затем получаем порты прослушивания, связанные с этими процессами sshd-session:8
ports=$(sudo -n ss --listening --numeric --tcp --processes --no-header \
| grep -F "$pids" \
| awk '{ print $4 }' | awk -F: '{ print $NF }' \
| sort -un)
if [ -z "$ports" ]; then
echo "no forwarded port, use ssh -R 0:localhost:PORT" >&2
exit 1
fi
Наконец, отображаем URLs и держим сессию открытой:
lifetime=86400
secret='ZuPerS3cr3!'
expires=$(( $(date +%s) + lifetime ))
for port in $ports; do
token=$(printf '%s %s %s' "$expires" "$port" "$secret" \
| openssl md5 -binary \
| openssl base64 \
| tr +/ -_ | tr -d =)
echo "https://$token--$expires@p$port.ssh.luffy.cx/"
done
sleep infinity
Устанавливаем этот скрипт как http-over-ssh на сервере и добавляем такую запись в ~/.ssh/config:
Host http-over-ssh
Hostname web02.luffy.cx
RemoteCommand http-over-ssh
ControlPath none
С этим решением нужны только OpenSSH и nginx — два компонента, уже запущенные на этом сервере. Одна короткая команда даёт самохостируемый туннель и URL для шаринга. Чтобы попробовать, возьмите полный скрипт-помощник, в котором есть несколько небольших улучшений. Если используете NixOS, посмотрите http-over-ssh.nix. ❄️