homelab notes ポート開放できない家の自宅サーバー

ポート開放なしで自宅サービスを HTTPS 公開する

ドメインも固定IPもポート開放もなしに、https://grafana.<tailnet>.ts.net のような 名前でサービスにアクセスできるようにする。使うのは Tailscale と tsdproxy だけ。

そもそもの制約

うちのルータはポート開放に対応していない。つまり外から入ってくる経路が作れない。 Let's Encrypt の HTTP-01 チャレンジも通らないし、リバースプロキシを立てても インターネットからは届かない。

Tailscale は WireGuard の上に P2P の仮想ネットワークを作るので、この制約を丸ごと回避できる。 さらに tailnet 内のホスト名に対して Let's Encrypt の証明書を発行してくれるのが効く。 DNS-01 チャレンジを Tailscale 側が代行するので、こちらは何も公開しなくていい。

1サービス1デバイスにする

素直にやると https://<host>.ts.net:3000 のようにポート番号で区別することになるが、 それだと証明書は1枚で済む代わりにポートを覚える羽目になる。

tsdproxyプロキシ対象ごとに Tailscale のデバイスを作る。 つまり grafanajellyfin がそれぞれ独立したノードとして tailnet に生え、 それぞれが自分の証明書を持つ。結果として、

https://grafana.<tailnet>.ts.net
https://jellyfin.<tailnet>.ts.net
https://prometheus.<tailnet>.ts.net

のように全部 443 でアクセスできる。ポート番号を覚える必要がなくなる。

設定

コンテナのラベルから自動検出させる方法もあるが、host ネットワークで動いているサービス (ラベルを持たない)も混ざっていたので、ファイルで列挙する lists プロバイダを使った。

# config/services.yaml
grafana:
  ports:
    443/https:
      targets: [ "http://127.0.0.1:3000" ]
  dashboard: { label: "Grafana" }

portainer:
  ports:
    443/https:
      targets: [ "https://127.0.0.1:9443" ]
      tlsValidate: false      # 自己署名証明書なので検証を切る

このファイルは保存すると自動で読み直される。再起動は要らない。

踏んだ罠

1. ドキュメントが未リリースの機能を書いている

公式ドキュメントを見ながら設定を書いたら、起動ループに入った。

field dashboard not found in type config.config
field proxyAccessLog not found in type config.LogConfig

原因は、ドキュメントが main ブランチの内容で、リリース済みのバージョンより 先を行っていたこと。タグを指定してソースの設定構造体を読みに行くのが早い。

internal/config/config.go   ← リリースタグのものを見る

それ以来、イメージは :latest ではなく明示的なバージョンで固定している (別の理由でも固定すべきだった)。

2. 認証キーが Reusable でないと1台しか作れない

9個のサービスを設定したのに、1個だけ成功して8個が API key ... not valid で失敗した。

Tailscale の認証キーには使い捨て(one-shot)と再利用可能(reusable)があり、 既定は使い捨て。最初のデバイスがキーを消費した時点で残りが全部弾かれる。 1サービス1デバイスの構成では必ず Reusable にする。

ついでに、生成されたデバイスは既定で90日後にキーが失効する。 サーバー用途なら管理画面で「Disable key expiry」しておかないと、 3ヶ月後に全部落ちる。

3. 127.0.0.1 と書くと 0.0.0.0 に書き換えられる

管理用の待受アドレスを 127.0.0.1 にしたのに、 コンテナ内で 0.0.0.0 になっていた。

ソースを読んだら、Docker 内で動いている場合に "127.0.0.1" という文字列と完全一致したら "0.0.0.0" に置換する という処理が入っていた。コンテナ内のループバックに縛ると外から到達できないので、 親切のつもりの実装だと思う。

回避策は localhost と書くこと。文字列一致を外れるので置換されない。

http:
  hostname: localhost   # "127.0.0.1" と書くと 0.0.0.0 に書き換えられる
  port: 8081

結果

11サービスに HTTPS のホスト名が付いた。費用はゼロ。ポート開放もゼロ。 証明書の更新も Tailscale が勝手にやる。

注意点として、この構成はアクセス制御を Tailscale の tailnet 参加そのものに 委ねている。tailnet に他人を招く場合は、サービス側の認証か Tailscale の ACL が別途要る。 特に認証のない API(Ollama など)をそのまま生やすと、tailnet 内の全員が叩ける。