外部からのアクセス経路を、素のポート開放ではなく、暗号化された専用トンネルに限定する。ここではその実現方法と、複数サービスを一元的に管理するリバースプロキシの構成を記す(具体的なポート番号・IPアドレスは、このページの公開範囲を踏まえ意図的に記載しない)。

CaddyCaddy
TailscaleTailscale

画像: Caddy / Tailscale ロゴ、Wikimedia Commons

専用トンネル: tailnet限定公開と一般公開の使い分け

外部アクセスの基盤には、暗号化されたメッシュネットワークを構築するツールを使っている。このツールには2つの公開モードがある。

一般公開する場合でも、TLS終端の方式には2種類あり、選択を誤るとリアルタイム通信(WebSocket)が機能しなくなる罠がある。一方はHTTPSプロキシとして振る舞い、ALPNでHTTP/2を提示する。もう一方はTLS終端のみを行い生のTCPとして転送する。前者だと、HTTP/2上でのWebSocketアップグレードに対応していないアプリケーションでリアルタイム接続が確立できない。後者を使うことでこの問題を回避している。

リバースプロキシ: 公開範囲の一元管理

複数のサービスをそれぞれ個別にトンネル経由で公開すると、認証やヘッダー付与などの共通処理をサービスごとに重複して設定することになる。これを避けるため、各サービスの手前にリバースプロキシを1つ配置し、外部公開の経路を必ずこのプロキシ経由に統一している。導入の動機は「新しいサービスを追加しやすい構成にしたい」という単純なもので、これ以前は各サービスのトンネル設定を個別に手作業で作っていた。

具体的には、各サービスに専用の内部ポートを1つ割り当て、リバースプロキシがそのポートへ転送するブロックを1つのファイルに集約する。新しいサービスを追加する際は、このファイルに転送ブロックを1つ追記し、動作確認した上でトンネル側の転送先をそのポートに向け直す、という手順がテンプレート化されている。ルーティング定義が1箇所に集約されているため、セキュリティヘッダーの付与や認証の追加といった横断的な変更を、個々のサービスの設定を触らずに行える。

サブパス集約を試みて分かった限界

リバースプロキシ導入時、当初は「複数サービスを1つの公開ポートにサブパス(/service-a/のようなパス階層)で集約し、公開ポート数自体を減らす」という、さらに一歩進んだ統合を狙った。しかし実際に各サービスを調べると、ほとんどの自己ホスト型アプリケーションはサブパス配信を公式にはサポートしていないことが分かった。

唯一うまくいったのは、公式にBase URL設定を持つメディアサーバーのケースで、これとダッシュボードアプリの2つだけを1ポートに集約するに留めた。この方式は汎用的には使えないという結論に落ち着き、残りのサービスは「外部ポート番号は個々に維持したまま、内部の転送経路だけをリバースプロキシに集約する」という、より保守的な方式にした。汎用性を狙った設計が個別事情の壁にぶつかり、結局は地道な個別対応に落ち着くというのは、この手の統合作業ではよくあるパターンである。

最小権限の原則: 待受アドレスの限定

各サービス自体の待受設定も、外部からの直接到達を防ぐ最後の防衛線として重要である。基本方針は、新しいサービスを追加する際は最初からループバックアドレス限定でバインドし、必要な公開範囲だけをリバースプロキシ経由で明示的に開けるというものである。全インターフェースへのバインドがデフォルト設定になっているソフトウェアは多く、これを見落とすと、リバースプロキシとは別の経路(同一ネットワーク内からの直接アクセス)でサービスに到達できてしまう。

この点検は一度で終わらない。個々のサービスの主設定ファイルだけでなく、別ファイルに分離されたサービス定義や、他のプロセスが内部で自動起動する付随プロセスも含めて、実機の待受状態を定期的に列挙し、想定される公開範囲と突き合わせる作業を継続している。

経路自体を疑う: 仮想化のネットワークモードという盲点

上記の点検作業のきっかけは、「外部アクセスは常にトンネル経由(tailnet限定)」という運用方針が実態とズレていたと分かったことにある。原因は、コンテナ群の実行基盤(WSL2)のネットワーキング設定を、同じマシン上で並行稼働させている別プロジェクト(ロボット開発用のワークスペース)向けに「ミラーリングモード」にしていたことだった。このモードでは、仮想化レイヤー側の全インターフェースへのバインドが、tailnet内だけでなく同一LAN上の全端末からも直接到達可能になる。専用トンネル経由のアクセスしか想定していなかった設計者の意識と、実際の到達可能性との間にギャップが生まれていた典型例である。

この設定は自分の判断だけでは変更できない(ロボット開発側の要件と共有されているため)。そこで、経路そのものを塞ぐのではなく、各サービス側の待受アドレスをループバック限定にすることで、経路が開いていても到達できない状態を作る方針に切り替えた。これが上記の「最小権限の原則」の具体的な出発点である。

なお、同じネットワークモードの副作用として、ファイル共有で使っているプロトコルの補助的なNetBIOS名前解決デーモンが、モード変更後から恒常的にクラッシュし続けていたことも判明した。原因は、仮想化基盤ではなくホストOS自身が、同名前解決に使う特定のUDPポートを、ミラーリング下の全インターフェース(LAN・トンネル双方を含む)ですでに専有していたことで、コンテナ側の同名プロセスが同じポートのバインドに毎回失敗していた。ファイル共有そのものの機能には影響がなく(失われるのはネットワーク一覧上でのホスト名表示程度)、ネットワーキングモード自体を変更しない限り根本解決はできない構造的な問題と判断し、当該デーモンを無効化して対処した。

点検の死角: 別ファイル・別プロセスという抜け穴

最小権限の点検を一度実施した後も、抜け穴が残っていた。主要なサービス定義は1つのcomposeファイルに集約しているが、写真管理サービスのように定義ファイルが独立している別サービスは、最初の点検対象から漏れていた。改めて別ファイルまで含めて洗い出したところ、この写真管理サービスが全インターフェースに公開されたままになっていたことが判明した。19,000件を超える写真・動画を扱うサービスだけに、見落としとしては重大なものだった。

同様に、分析用のノートブック環境は、単に待受設定が漏れていただけでなく、管理者権限での起動・パスワードなしのsudo許可・ブラウザの実プロファイル(Cookieや保存済みパスワードを含む)の直接マウントという、複数の過剰な権限が積み重なった状態で全インターフェースに公開されていた。「未公開・ローカル限定」というドキュメント上の想定と、実際の設定との乖離に気づけなかった典型例である。画像生成サービスも、認証機構を持たないまま同様に公開されていた。いずれも待受アドレスをループバック限定にし、ノートブック環境については権限とマウント対象自体も最小化して解消した。

この経験から、点検を「メインの定義ファイルを見る」だけで終わらせず、別ファイルに分離された定義や、コンテナ内部で自動起動する付随プロセスまで含めて、実機の待受状態を直接列挙して突き合わせる習慣に切り替えた。ドキュメント上の想定ではなく、実機の状態そのものを一次情報として扱うという方針である。

ダッシュボードアプリ自体も同じ原則で守る

複数の自己ホスト型サービスへのリンクを一覧できるダッシュボードアプリも導入しているが、これ自体も他のサービスと同じ最小権限の原則で構成している。待受アドレスはループバックに限定し、外部からアクセス可能なホスト名を許可リストとして明示的に指定することで、想定していないHostヘッダーでのアクセスを拒否する設定にしている。このダッシュボードは各コンテナの稼働状況を表示する機能も持つが、そのためにコンテナ管理ソケットを読み取り専用でマウントしている。書き込み権限を与えないことで、ダッシュボード自体が乗っ取られた場合でも、他のコンテナを操作される経路にはならないようにしている。

参考リンク

#Caddy #Tailscale