インフラ・DevOps
SSHとNginxを使ったセルフホスト型HTTPトンネル
Self-hosted HTTP tunnels with SSH and Nginx (vincent.bernat.ch)
要約
この記事では、商用サービスや専用クライアントを必要としない、OpenSSHとNginxのみを使用したセルフホスト型のHTTPトンネル構築方法を解説しています。SSHのポートフォワーディングとNginxの設定を組み合わせることで、ローカルのWebサービスを外部に公開する安全な方法を提供します。さらに、ngx_http_secure_link_moduleを利用してアクセス制御を強化し、ヘルパースクリプトで生成プロセスを簡略化する方法も紹介しています。
全文翻訳
友人が作業中のブログ記事の校正を希望していますが、そのプレビューはlocalhost:8080でしか実行されません。いくつかのツールが役立ちます。ngrokやCloudflare Quick Tunnelsのような商用サービスもあります。frpやlocaltunnelのようにセルフホスト可能ですが、専用クライアントが必要です。sishのようにプレーンなSSHクライアントのみを必要としますが、特定のSSHサーバーに依存するものもあります。OpenSSHとnginxだけでセルフホストソリューションを実装しましょう!
$ ssh -R 0:localhost:8080 http-over-ssh
Allocated port 41535 for remote forward to localhost:8080
https://6J3jK1WmB15c6WmjW_X-Wg--1789928654@p41535.ssh.luffy.cx/
基本設定
まず、リモートサーバー上のポートからローカルサービスへの接続を転送します。
$ 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;
}
}
また、*.ssh.luffy.cxのDNSレコードを追加し、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チャレンジに使用しており、ワイルドカード証明書と複数のWebサーバーで提供されるドメインの両方に対して行っています。私の場合は、NixOSが証明書を自動的に取得します。
アクセス制御
ポートは、コンテンツを機密に保つ唯一の「秘密」です。他の転送ソリューションは、侵入者が可能な値を列挙するのを防ぐためにドメイン名にランダムな文字列を追加します。ngx_http_secure_link_moduleのおかげで、このセットアップを少し安全にすることができます。このモジュールは、秘密鍵を含む値のセットに対してハッシュを計算し、リクエストからのハッシュと比較します。ハッシュはbase64エンコードされているため、ドメイン名(大文字小文字を区別しない)に入れることはできません。代わりに、URLにユーザー名として、有効期限のタイムスタンプとともに配置します。
https://6J3jK1WmB15c6WmjW_X-Wg--1789928654@p41535.ssh.luffy.cx/en/blog
╰─────────┬──────────╯ ╰───┬────╯ ╰─┬─╯ ╰──┬───╯
hash expires port path
クライアントはHTTP基本認証でユーザー名をサーバーに送信します。これは、curlを含むほとんどのHTTPクライアントで機能します。Nginxは、$remote_user変数にユーザー名を公開します。モジュールは、ハッシュと有効期限のタイムスタンプをカンマで区切ることを期待しています。mapディレクティブを使用して、$remote_userから2つの部分を抽出し、カンマで結合します。
また、ハッシュする文字列をモジュールに渡します。これには、有効期限のタイムスタンプ、ポート、および秘密鍵が含まれます。
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」です。ハッシュが間違っているか欠落している場合は、認証情報を要求するWWW-Authenticateヘッダーとともに401エラーを返します。リンクが期限切れの場合は、410エラーを返します。プロキシする前にAuthorizationヘッダーを削除し、WebSocket接続をプロキシするためのいくつかのディレクティブを追加します。以下は完全な設定です。
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
さて、あなたは「Vincent、これはあまり便利ではない!もしよければngrokを使い続けます」と言っているのでしょう。わかりました。ヘルパースクリプトを書きましょう。
ヘルパースクリプト
主な難しさは、OpenSSHが割り当てる一時的なポートを見つけることです。これは環境変数には表示されないためです。この障害を回避するために、上位のsshd-sessionプロセスを探します。
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 }'
このプロセスに関連付けられたリスニングポートを取得します。
ports=$(sudo -n ss --listening --numeric --tcp --processes --no-header \
| grep -F "$pids" \
| awk '{ print $4 }' | awk -F: '{ print $NF }' \
| sort -un)
最後に、URLを表示し、セッションを開いたままにします。
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
このソリューションでは、すでにサーバーで実行されている2つのソフトウェア、OpenSSHとnginxのみに依存しています。短いコマンドで、セルフホスト型のトンネルと共有するためのURLが得られます。試すには、いくつかのマイナーな改善が含まれている完全なヘルパースクリプトを入手してください。もしあなたがセンスのある人ならNixOSを実行しているなら、私のhttp-over-ssh.nixを見てください。❄️