22 分で読了 #homelab
このページの目次

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とは、いつ使うべきか

内網脱出には多くの選択肢がありますが、シーンに合わせて選定します。

方案適しているケース適していないケース
ratholeVPSがあり、TCPポート転送のみ必要、軽量で依存関係なしUDPやP2Pピアツーピア接続が必要
frp同上 + Web管理パネルや他のプロトコルが必要設定が重くなる
TailscaleVPSの管理をしたくない、自分用でリレー遅延を受け入れられる家族やパブリックユーザーがクライアントをインストールする必要がある
WireGuardVPSがあり、ポートマッピングではなくネットワーク構成を組みたいカーネルモジュールが必要、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 アーキテクチャ: 自宅のサービスがTCPトンネル経由でVPSへ → Caddyがパブリックに公開 自宅(内網) Grafana :3000 ドキュメントサイト :8090 SSH :22 · その他… rathole-client network_mode: host TCPトンネル 暗号化・ロングコネクション VPS(パブリック) rathole-server :2333 トンネル口 127.0.0.1:13000+ Caddy TLS終端・リバースプロキシ HTTPS パブリックユーザー https://doc.liz6.com rathole はTCPポート転送のみを行い、TLSは解析しません。TLS証明書はVPS側のCaddyが自動管理します。

役割分担は明確です。

  • 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: ["--server", "/config/server.toml"]
    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: ["--client", "/config/client.toml"]
    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)に到達できない場合:

  1. 自宅で acme.sh を実行し、阿里云のDNS-01を使用してワイルドカード *.liz6.com の証明書を発行します(ACMEリクエストはプロキシ経由で出国、DNS APIは国内の直接接続を使用)。
  2. --install-cert --reloadcmd で証明書をVPSに自動scpし、Caddyをリロードします。
  3. 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_downalt-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つの手段、それぞれ独立して機能します):

  1. TCP keepalive(保活): client.toml で keepalive_secs=10 / keepalive_interval=5 を設定 —— rathole の接続はすべてclientから能動的に開始されるため、client側のみを変更すれば制御チャネルとデータチャネルの両方に適用されます。
  2. 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 を実行してもパッケージは検出されず、自宅側では接続成功と表示されます。

真実への道筋:

  1. ip route get <VPS_IP>via 198.18.0.2 dev Meta —— VPSへのトラフィックはすべてTUN経由。
  2. VPS上で確実に閉じているポート⁠(例: 9999)に curl -v で接続し、「成功」= TUNの偽応答、「拒否」= 本当にVPSに到達したことを確認。
  3. 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からローカルプロキシに変わっただけです。

TUNハイジャック: ratholeからVPSへの接続がローカルのmihomo TUNによってインターセプトされ、その後切断される 修正前(誤ったパス) rathole mihomo TUN 198.18.0.2 VPS "DIRECT" ≠ TUNのバイパス、接続は依然としてmihomoが管理 修正後(正しいパス) rathole VPS カーネル直結(wlp9s0) ルーティング除外: TUNはこのIPに触れない

修正⁠: 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

curl -s --connect-timeout 5 --max-time 5 "$ENDPOINT" > /dev/null
if [ $? -ne 0 ]; then
    fails=$(($(cat $FAIL_FILE 2>/dev/null || echo 0) + 1))
    echo $fails > $FAIL_FILE
    if [ $fails -ge $MAX_FAILS ]; then
        docker restart rathole-client
        rm -f $FAIL_FILE
    fi
else
    rm -f $FAIL_FILE
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-authenticator PAM)を追加。
  • 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: ["--server", "/config/server.toml"]
    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: ["--client", "/config/client.toml"]
    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

関連ドキュメント