Базовая настройка#

Сначала нужно пробросить соединения с порта удалённого сервера на локальный сервис:

$ 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. ❄️