このページの目次
rathole + Caddy による内網脱出: CGNAT からパブリック HTTPS への完全ガイド
適用シーン: CGNAT またはパブリックIPなし、VPSあり、TCPポート転送のみ必要、軽量でカーネル依存なしを追求、WireGuardのインストールやCloudflare Tunnelの設定を避けたい場合。
ローカルマシンでは rathole —— Rustで書かれた軽量なTCPトンネル —— をDockerで構築し、CaddyでTLSリバースプロキシを行うことで、Grafana、ドキュメントサイト、SSHなどをすべて公開します。本稿では、選定、構築、証明書ポリシー、そして遭遇した3つの大きな問題点(HTTP/3、conntrackによる接続切断、ローカルプロキシによるトンネルのハイジャック)から、再現可能な完全な設定までを解説します。
1. ratholeとは、いつ使うべきか
内網脱出には多くの選択肢がありますが、シーンに合わせて選定します。
| 方案 | 適しているケース | 適していないケース |
|---|---|---|
| rathole | VPSがあり、TCPポート転送のみ必要、軽量で依存関係なし | UDPやP2Pピアツーピア接続が必要 |
| frp | 同上 + Web管理パネルや他のプロトコルが必要 | 設定が重くなる |
| Tailscale | VPSの管理をしたくない、自分用でリレー遅延を受け入れられる | 家族やパブリックユーザーがクライアントをインストールする必要がある |
| WireGuard | VPSがあり、ポートマッピングではなくネットワーク構成を組みたい | カーネルモジュールが必要、CGNAT下では追加の穴あけが必要 |
| Cloudflare Tunnel | ドメインがCF(Cloudflare)にあり、VPSの管理を完全に避けたい | トラフィックがCF経由になる(プライバシー/コンプライアンス)、中国国内で遅い |
今回は rathole を選択しました。理由は、腾讯云(Tencent Cloud)のVPSを1台持っており、自宅のいくつかのHTTPサービスとSSHを公開したいだけで、UDP/P2P/管理パネルは不要だからです。rathole は単なるTCPトンネルであり、TLSの解析や証明書に触れません。TLSはVPS上のCaddyに任せます。
もしあなたのシーンと本稿のケースが一致しない場合: CGNATかつVPSなしなら Cloudflare Tunnel を選択、ネットワーク構成を組むなら Tailscale、UDPが必要なら frp を選択してください。これらは本稿で深く扱っていませんので、以下では rathole の構成のみを展開します。
2. アーキテクチャ: 1本のトンネル、2つの役割
役割分担は明確です。
- rathole-client (自宅, Docker + hostネットワーク): VPSのserverに接続し、自宅の
127.0.0.1:3000をVPS上の127.0.0.1:13000にマッピングします。 - rathole-server (VPS, Docker + hostネットワーク):
:2333をリッスンし、clientからの接続を受信し、設定に従って各サービスをローカルポートにバインドします。 - Caddy (VPS, ホストOS): 443番ポートで外部をリッスンし、ドメインに応じて rathole-server の対応するポートへリバースプロキシを行います。TLS証明書は自動取得されます。
自宅のサービスはすべて 127.0.0.1 にのみバインドされており、ratholeトンネルを経由しない限り外部からアクセスできません。これは「最小限の攻撃面」というディフェンスインデプスの原則に準拠しています。
3. 構築: 0からパブリックアクセス可能まで
3.1 VPS側: rathole-server + Caddy
両方のマシンで rathole はDockerで実行し、hostネットワークを使用します(コンテナ内の 127.0.0.1 = ホストOS)。まずは server.toml を書きます。
# VPS: /opt/rathole/server.toml
[server]
bind_addr = "0.0.0.0:2333" # clientが接続するポート
[server.transport]
type = "tcp"
[server.services.grafana]
token = "<ランダムなトークンを生成>" # clientでも同じトークンを設定
bind_addr = "127.0.0.1:13000" # Caddyのリバースプロキシのみ向け、パブリックには公開しない
[server.services.doc]
token = "<ランダムなトークン>"
bind_addr = "127.0.0.1:13002"
# SSHはCaddyを経由せず、直接 bind 0.0.0.0:
[server.services.homessh]
token = "<ランダムなトークン>"
bind_addr = "0.0.0.0:30022"
# その他のサービス: openwebui→13001, paste→13003, caldav→13004, OTLP→9443 (同じ形式)
# VPS: /opt/rathole/docker-compose.yml
services:
rathole:
image: rapiz1/rathole:latest
container_name: rathole-server
restart: unless-stopped
network_mode: host
command:
volumes:
- ./server.toml:/config/server.toml:ro
次に Caddy のリバースプロキシ設定(VPS上でホストOSとして実行):
# /etc/caddy/Caddyfile
grafana.liz6.com {
reverse_proxy 127.0.0.1:13000
}
doc.liz6.com {
reverse_proxy 127.0.0.1:13002
}
Caddy は自動的に Let's Encrypt からTLS証明書を発行します(VPSに独立したパブリックIPがあり、80/443ポートが到達可能な場合)。不可能な場合は、3.3節の証明書ポリシーを参照してください。
3.2 自宅側: rathole-client
# 自宅: /opt/rathole-home/client.toml
[client]
remote_addr = "<VPSのパブリックIP>:2333"
[client.transport]
type = "tcp"
[client.transport.tcp]
keepalive_secs = 10 # 保活ハートビート、NAT/conntrackによる接続切断に対抗
keepalive_interval = 5
nodelay = true
[client.services.grafana]
token = "<server.tomlと同じトークン>"
local_addr = "127.0.0.1:3000"
[client.services.doc]
token = "<server.tomlと同じトークン>"
local_addr = "127.0.0.1:8090"
# 自宅: /opt/rathole-home/docker-compose.yml
services:
rathole:
image: rapiz1/rathole:latest
container_name: rathole-client
restart: unless-stopped
network_mode: host
command:
volumes:
- ./client.toml:/config/client.toml:ro
network_mode: host を使用することで、コンテナは直接ホストOSの 127.0.0.1:3000 などのポートにアクセスできます。すべての自宅サービスはlocalhostにバインドするだけでよく、Dockerブリッジに追加で公開する必要はありません。
3.3 証明書ポリシー: 2つのシーン
シーンA: VPSに独立したパブリックIPがある(推奨)。 Caddyの tls ブロックを空にしておけば、Let's Encryptが自動で発行されます。これが現在のメインの設定です。
シーンB: NAT VPSで、パブリックの80/443が利用不可。 共有IP NATかつ海外(Let's Encrypt)に到達できない場合:
- 自宅で
acme.shを実行し、阿里云のDNS-01を使用してワイルドカード*.liz6.comの証明書を発行します(ACMEリクエストはプロキシ経由で出国、DNS APIは国内の直接接続を使用)。 --install-cert --reloadcmdで証明書をVPSに自動scpし、Caddyをリロードします。- VPS上のCaddyは静的証明書を使用し(自動LEを使用せず)、URLにはNATの高位ポートサフィックスを含めます。
シーンBからシーンAに切り替えられる場合、URLからポートサフィックスを削除し、acme.shのワイルドカード証明書はDERPなどLEが利用できないシーンにのみ残します。標準の443 + Caddy自動LEが使用できる場合はそれを優先し、手動の手順を減らして障害点を減らします。
4. トラブルシューティング: 遭遇した3つの大きな問題点
4.1 HTTP/3 の alt-svc ポート不一致
Caddy はデフォルトで HTTP/3 (QUIC) を有効にしています。HTTP/3 は alt-svc 応答ヘッダーを使用してブラウザに「次はQUICを使用し、ポートはXです」と伝えます。しかし、Caddy は自分がどのポートでリッスンしているかしか知らず、外部にNAT転送があるかどうかは知りません。
パブリックポート ≠ Caddyのリッスンポート(シーンBのNAT高位ポート)の場合、alt-svc に記載されるポートが間違っており、ブラウザは誤ったQUICポート番号を受け取って接続に失敗し、ページが開きません。
現象: Open WebUI がシーンBで突然開かなくなる。curl は正常(HTTP/1.1を使用)、ブラウザは失敗(QUICを試みる)。調査: ブラウザのDevTools → Network で、h3 リクエストで止まっているか確認。
修正: Caddy の h3 を無効にする —— servers { protocols h1 h2 }。標準の443(シーンA)に戻した後、Caddyの外部ポートがリッスンポート(どちらも443)と一致することを確認し、h3を再度有効にできます。Caddyの前にリバースプロキシやNATポートマッピングがある場合、header_up または header_down で alt-svc を手動で修正する必要があります。
残存する問題: ブラウザは alt-svc を最大30日間キャッシュします(HSTSも影響します)。修正後はシークレットウィンドウを使用するか、DevTools → Application → Clear site data で確認してください。
4.2 Early EOF: conntrack によるアイドル接続の切断
CGNAT ↔ 共有IP NAT のクロスネットワークリンクにおいて、rathole のログには約10分ごとに Failed to read cmd: early eof が表示されます。制御チャネル(ロングコネクションで、通常はほとんどトラフィックがありません)が、中間の conntrack によってアイドルとみなされ、クリーンアップされました。
これは rathole の問題ではありません。 裸のTCPトンネル(frpも同様)では、このようなリンクで同様の問題が発生します。接続が長期間アイドル状態になると、中間のNATゲートウェイのconntrackエントリが期限切れでクリアされ、次の通信時にTCPがリセットされます。
修正(2つの手段、それぞれ独立して機能します):
- TCP keepalive(保活): client.toml で
keepalive_secs=10 / keepalive_interval=5を設定 —— rathole の接続はすべてclientから能動的に開始されるため、client側のみを変更すれば制御チャネルとデータチャネルの両方に適用されます。 - Watchdogによるフォールバック(5節参照): keepalive は確率を下げるだけですが、極端なリンクでは依然として切断されます。watchdog は切断後に自動的に再起動して回復します。
4.3 大きな問題: ローカルのTUNプロキシがトンネル転送をハイジャック
この3つの中で最も隠れていた問題です。
現象: VPSは独立したパブリックIP(ICMP 0% パケットロス、遅延安定)、keepaliveも有効化されているが、rathole は依然として定常的なタイムアウト —— 24時間で約470回のタイムアウト、制御チャネルは約7分ごとに再構築。さらに奇妙なことに、使用頻度の低いサービスほど多くのタイムアウト(数百回)が発生し、高頻度の grafana だけが数回程度。
調査プロセス —— TUNプロキシ環境では通常の手段があなたを騙します:
まず curl / nc でVPSへの到達性をテスト → すべて接続成功。後になって分かったことですが、TUNプロキシ(mihomo)が有効なマシンでは、curl のTCPハンドシェイクがTUNによってローカルでインターセプトされ、偽の応答が返されます。VPSでリッスンしていないポートに接続しても「成功」となります。VPS側で tcpdump を実行してもパッケージは検出されず、自宅側では接続成功と表示されます。
真実への道筋:
ip route get <VPS_IP>→via 198.18.0.2 dev Meta—— VPSへのトラフィックはすべてTUN経由。- VPS上で確実に閉じているポート(例: 9999)に
curl -vで接続し、「成功」= TUNの偽応答、「拒否」= 本当にVPSに到達したことを確認。 - VPS上で
tcpdump -i eth0 tcp port 2333でパケットキャプチャ ——-i anyを使用しないでください。cooked擬似リンクヘッダーを使用すると、tcp[tcpflags]のバイトオフセットフィルタが静かにゼロマッチになります。
根本原因: 自宅マシンで mihomo TUN を実行しており、auto-route + strict-route によって全体の外向きトラフィックを乗っ取っています。rathole から VPS への接続は mihomo の TUN データプレーンに挿入されます —— ルーティングルールが DIRECT(プロキシノードを使用しない)と判断しても、DIRECT は「プロキシ出口を通らない」ことを意味するだけで、接続自体は依然として TUN ユーザー空間スタックを通ります。mihomo のアイドル接続回収/reload は、rathole が事前に構築したデータチャネルのプールを切断し、タイムアウトを引き起こします。
本質的には4.2節のEarly EOFと同じカテゴリ(アイドル接続の回収)ですが、犯人がプロバイダのNATからローカルプロキシに変わっただけです。
修正: VPSのIPをmihomoのTUNルーティング除外リストに追加し、カーネル直結経由でプロキシを完全にバイパスします。
# mihomo config.yaml
tun:
inet4-route-exclude-address:
- 1.117.75.80/32 # rathole VPS, ローカルのmihomo TUNをバイパス
systemctl restart mihomo 後、ip route get <VPS_IP> が via 192.168.31.1 dev wlp9s0(ネイティブリューティング)に変わることを確認し、docker restart rathole-client で接続を再構築して直結経由にします。変更後、5分間タイムアウトゼロ、8/8のサービスがオンライン —— 以前は7分ごとに必ず切断されていました。
一般的な原則: トンネル/VPNの転送接続(transport)は、常に別のローカルTUNプロキシを通さないでください —— 故障点が増えるだけでなく、プロキシの接続ライフサイクル管理によって誤って影響を受けます。転送接続はネイティブリューティングを通過する必要があります。この原則は、rathole、frp、WireGuard、Tailscale、ZeroTierなど、ロングコネクションを必要とするすべてのトンネル方案に適用されます。
5. モニタリング: トンネルがダウンしたときにどう知るか
トンネルはインフラであり、ダウンするとすべてのサービスに影響します。2つのレイヤーがあります。
① 健康チェック + 自動回復(watchdog)。 シンプルなbashスクリプトで、systemd timer で5分ごとに実行します。
#!/bin/bash
# /usr/local/bin/rathole-watchdog
ENDPOINT="http://127.0.0.1:13002/health" # ratholeトンネル経由でVPS上のdocサービスのヘルスエンドポイントを叩く
MAX_FAILS=3 # 連続3回失敗してからダウンと判定(デバウンス用)
FAIL_FILE=/tmp/rathole-watchdog-fails
if [; then
fails=
if [; then
fi
else
fi
② モニタリングの盲点: 単一のビジネスチャネルのフリーズ。 watchdog は1つのポート(docの13002)のみをプローブします。もし他のトンネルチャネルがフリーズした場合(例: grafanaの13000)、watchdog は検知できません —— ratholeの制御チャネルは生きており、ヘルスエンドポイントも応答しますが、あるデータチャネルがネットワークの揺らぎ後に自己回復しません。
実際の事例: doc.liz6.com が数時間ダウン —— TLS/証明書は正常だが、ratholeのdocデータチャネルがネットワークの揺らぎ後にフリーズし、他の5本のトンネルはすべて正常。docker restart rathole-client で強制全チャネル再接続して回復。
教訓: watchdog は少なくとも2つのビジネスポート(doc + grafanaなど)をプローブすべきで、1つだけプローブしてはいけません。理想的には、ratholeやその後の代替案が per-service リコネクトをサポートしている場合、フリーズしたチャネルのみを対象に再起動し、全体を再起動しないようにすべきです(全体再起動するとSSHセッションがすべて切断されます)。
6. セキュリティ強化
- VPS sshd:
PasswordAuthentication no、キー認証のみ。 - rathole のすべての bind はデフォルトで
127.0.0.1。SSHトンネルのみCaddyを経由しないため0.0.0.0:30022にバインドし、TOTP 2FA(google-authenticatorPAM)を追加。 - Caddy は rathole トンネル口のみリバースプロキシし、自宅のポートを直接公開しない。
- 機密リンク(例: OTLP metrics)には mTLS を追加 —— Caddyがクライアント証明書を検証し、失効チェックを行い、トンネルは暗号化されたデータのみを転送。
- VPS側で
ufwまたはiptablesを使用し、rathole ポート(:2333)を自宅IPからの接続のみ許可。IPが変動する場合は、少なくともSSHポート(:30022)のブルートフォース試行を制限(fail2ban)。 - rathole トークンは
openssl rand -hex 24で強力なランダム値を生成し、複数のサービスで同じトークンを共有しない。 - Docker
network_mode: hostは、コンテナがホストOSのすべてのポートに直接アクセスできることを意味します —— 信頼できるネットワークでのみ使用し、rathole-server と rathole-client は制御されたマシンで実行してください。
7. 完全な設定
VPS: server.toml + docker-compose.yml
# /opt/rathole/server.toml
[server]
bind_addr = "0.0.0.0:2333"
[server.transport]
type = "tcp"
[server.services.grafana]
token = "<ランダムなトークン>"
bind_addr = "127.0.0.1:13000"
[server.services.doc]
token = "<ランダムなトークン>"
bind_addr = "127.0.0.1:13002"
# SSHはCaddyを経由せず、0.0.0.0に直接公開
[server.services.homessh]
token = "<ランダムなトークン>"
bind_addr = "0.0.0.0:30022"
# /opt/rathole/docker-compose.yml
services:
rathole:
image: rapiz1/rathole:latest
container_name: rathole-server
restart: unless-stopped
network_mode: host
command:
volumes:
- ./server.toml:/config/server.toml:ro
VPS: Caddyfile(例、必要に応じてルートを追加)
grafana.liz6.com {
reverse_proxy 127.0.0.1:13000
}
doc.liz6.com {
reverse_proxy 127.0.0.1:13002
}
自宅: client.toml + docker-compose.yml + watchdog
# /opt/rathole-home/client.toml
[client]
remote_addr = "<VPS IP>:2333"
[client.transport]
type = "tcp"
[client.transport.tcp]
keepalive_secs = 10
keepalive_interval = 5
nodelay = true
[client.services.grafana]
token = "<server.tomlと一致>"
local_addr = "127.0.0.1:3000"
[client.services.doc]
token = "<server.tomlと一致>"
local_addr = "127.0.0.1:8090"
# /opt/rathole-home/docker-compose.yml
services:
rathole:
image: rapiz1/rathole:latest
container_name: rathole-client
restart: unless-stopped
network_mode: host
command:
volumes:
- ./client.toml:/config/client.toml:ro
# /usr/local/bin/rathole-watchdog(完全版は5節参照)
systemd timer を設定して5分ごとにトリガー:
# ~/.config/systemd/user/rathole-watchdog.service
[Service]
Type=oneshot
ExecStart=/usr/local/bin/rathole-watchdog
# ~/.config/systemd/user/rathole-watchdog.timer
[Timer]
OnBootSec=2min
OnUnitActiveSec=5min
[Install]
WantedBy=timers.target
関連ドキュメント
- network-architecture.md — トポロジー概要
- network-recovery.md — 障害復旧
- monitoring.md — モニタリングアーキテクチャ