GrooveSeek

Semantic search over a Markdown knowledge base, served over MCP.

View the Project on GitHub alphabet-h/grooveseek

21. 渡された socket だけを受け、自分では作らない

背景と課題

GrooveSeek は誰も認証しない。--i-know 無しで非 loopback な bind を求められたときに 出す拒否文自体がそう書いている (grooveseek/src/transport/mod.rs の non_loopback_bind_refusal) — そのポートに到達できる者は、知識ベース全体を読める。したがって到達性がアクセス制御の すべてであり、v1.10.0 まで到達性とは TCP ポートのことだった。loopback のポートは そのホスト上のすべてのアカウントに開いているので、1 台に複数の daemon を (文書の束ごとに 1 つ、それぞれ 1 人の呼び出し元のために) 立てても、 どのポートが誰のものかという取り決め以上に強いものでは分かれていなかった。

ファイルシステム上の socket は、この問いをカーネルが既に答えている場所へ移す。 所有者と mode は connect(2) の時点で、最初の 1 バイトが届く前に、 他のあらゆるファイルを守っているのと同じコードによって検査される。 必要なのは、daemon が起動する前に、正しい所有者と mode でそれを作る誰かである。

この記録が答えるのは「AF_UNIX で serve するかどうか」ではない。 誰がその socket を作るのか、そして約束された socket を受け取れなかったとき serve は何をするのかである。

判断の軸

検討した選択肢

  1. .socket unit が既に bind した descriptor を、明示 opt-in で受け取る。 受け取れなければ起動を拒否する。 — 採った案。

  2. socket activation を環境変数から自動検出する (LISTEN_FDS が居れば使う)。 却下。1 つのコマンドラインが 2 つの異なる意味を持つことになり、しかも どちらになるかを決める変数は継承される。activation された service から起動した shell、 自分の descriptor を渡し込む supervisor、他者のために変数を設定した親 — どれも同じ形で serve に届く。sd_listen_fds(3) が最初に置いている検査は、 まさに環境変数が descriptor より遠くまで伝わるから存在する (grooveseek/src/transport/systemd_fd.rs の check_listen_env、 LISTEN_PID を見る分岐)。自動検出にすると、その検査を通り抜けることが 「opt-in された経路」ではなく「通常の経路」になる。

  3. groove 自身が path に bind する (--unix-socket <path>)。却下。 動きはするが、socket を作る主体が 2 つになる。所有者と mode は、groove と unit ファイルと周囲の umask のうち先に到達したものが決めることになり、 しかも配置ごとに違う主体が決める。本設計では socket を作るのは service manager の仕事であり、 そこに置いたままにしておくことが「誰がここへの到達可否を決めたか」に答えを 1 つだけ残す。

  4. unit と groove の間に systemd-socket-proxyd を挟む (systemd の man の namespace の例が採っている形)。proxy の 3 つの性質を根拠に却下。いずれも一次情報:

    • 接続ごとの timeout も SO_KEEPALIVE も無い (https://github.com/systemd/systemd/issues/23320)。MCP の Streamable HTTP は 接続を張り続けるので、半死の接続が --connections-max= の枠を食う
    • 資格情報を通さない。systemd-socket-proxyd(8) の原文が “will not forward SCM_RIGHTS, SCM_CREDENTIALS, SCM_SECURITY, SO_PEERCRED, SO_PEERPIDFD, SO_PEERSEC, SO_PEERGROUPS and similar” と書いている
    • 1 つの proxy は 1 つの socket にしか対応しない (https://github.com/systemd/systemd/issues/15599)。配置を 1 つ増やすたびに unit が増える

    4 つ目の懸念には裏付ける文書が無いので、推定としてここに残す: proxy は上流への接続を持つ前に接続を受け付けるので、呼び出し元の死活確認は 「接続できた」と答えられてから待たされ、失敗にはならないと考えられる。 これは実測していない。する必要も無かった — 上の 3 つで足りている。

    いずれにせよ daemon ごとにプロセスが 1 つ前に挟まる。descriptor を直接 受け取る形なら 0 である。

  5. SO_PEERCRED で接続元の uid を照合する。却下 — そしてこの道が開いているのは 選択肢 4 のおかげである。proxy を通さないことが、資格情報が届く状態を残している。 axum の Connected trait は sealed ではなく (axum-0.8.9/src/extract/connect_info.rs:80-83)、axum 自身が UnixListener 向けの 実装をコンパイル試験として持っている (axum-0.8.9/src/serve/mod.rs:503-513)。 IncomingStream::io() (axum-0.8.9/src/serve/mod.rs:436-439) から UnixStream が取れ、その peer_cred() が答えを返す。

    それでも採らない。 socket の mode が、同じ問いをより早い段階でカーネルに 立てさせている。アプリケーション層でもう一度問えば、1 つの条件が socket の mode とアプリケーションのコードという、食い違いうる別々の場所へ 分かれる。加えて Connected::connect_info は io::Result ではなく Self を返す (axum-0.8.9/src/extract/connect_info.rs:82) ので、 peer_cred() が失敗したときにそれを申告する先が無い。既定値は fail-open か、理由の言えない拒否のどちらかにしかならない。

    覆る条件: 1 つの socket を複数の principal に開ける必要が出たとき (想定する呼び出し元とは別に監視エージェントを足す、外部の read-only 利用者を作る等)。 そのとき mode はグループへ広げられ、uid の区別をここで持つ必要が出る。

決定

groove serve は渡された socket で accept し、自分では socket を作らない。

結果と代償

参考