VPN接続後のアクションをデーモンではなくユーザーとして実行する
USER=alice という環境変数は、hook がどのアカウントで動くかを変えない。イベント、トリガー、アクションを分離し、UID を証明する。
VPN デーモンの接続フックはデーモンとして実行され、通常それは root として実行されることを意味する。そうしたフックの一つが誰かのホームディレクトリに書き込んだり、デスクトップのヘルパーを再起動した瞬間、あなたはネットワークイベントに任意のローカルユーザーとして振る舞う能力を与えてしまっている。よくある近道、つまり環境変数からユーザー名を読み取って渡すだけでは、これは直らない。それはアクションのラベルを付け替えるだけで、どのアカウントが実行するかは変えない。うまくいくのは三部構成の受け渡しで、特権を持つステップが何が起きたかを告げ、別のユニットが正しい身元でアクションを行う。
環境変数のユーザー名は身元の切り替えスイッチではない
Unix はプロセスが何に触れられるかを実効ユーザー ID で決めるのであって、環境の文字列では決めない。USER=alice を設定したり LOGNAME をエクスポートしても変わるのは不注意なソフトウェアが出力に印字する内容だけで、ファイル所有権のチェックは何も変わらない。その変数を設定したまま root で動くフックは、今も root として Alice のファイルを開く。
名前もまた同じ名前ではない。リモートアクセスのログイン、ローカルの Unix アカウント、証明書のコモンネームは三つの識別子で、しばしば一致し、時には一致しない。a.smith として認証するユーザーにローカルアカウントが全くないこともあれば、ディレクトリサービス経由で asmith にマッピングされることもある。一つの文字列が三つの役割すべてを担うと仮定する設計は、最初の外部委託者で壊れる。
したがって最初の問いは「どのユーザーが接続しているか」ではなく「どのローカルアカウントにこのアクションを行う許可があるか」であり、それはクライアントが送ってきたものではなく、自分のアカウントデータベースから答えよ。
id -u alice
getent passwd alice | cut -d: -f1,3,6
stat -c '%U:%G %a %n' /home/alice/.local/state/vpn-status.json
三番目のコマンドこそ人々が飛ばすものだ。対象ファイルが以前のフックによって作られたために root 所有になっていると、正しく設定されたユーザーアクションは権限エラーで失敗し、それは systemd の問題のように見えて実際にはそうではない。
デーモンはその場にとどまる
VPN サービス自身の User= を Alice に変えるのは間違ったレバーだ。デーモンがインターフェースをバインドし、鍵を読み、状態ファイルを書き、ルートを管理する方法を変えてしまい、そのリスクすべてを動作していたユニットに移すことになる。デーモンには特権が必要だが、後続のアクションには要らない。
触る前にユニットが実際に何をしているかを見よ、というのも二つのコマンドで実行身元と有効な正確なコマンド行(オーバーライドが追加したものも含む)が分かるからだ。
systemctl show -p User -p Group -p ExecStart openvpn-server@server
systemctl cat openvpn-server@server
既定のパッケージインストールではデーモンは root で、--up スクリプトはそれを継承する。仮定せずに確認せよ。強化されたディストリビューションでは専用アカウントに移されているかもしれず、手書きの drop-in が別の規則を持つラッパー経由でフックを動かしているかもしれない。
三つの名付けられた部品、一つの狭いトリガー
保守に耐える配置にはイベント、トリガー、アクションがある。デーモンが名前付きセッションが上がったことを報告する。トリガーはその小さな事実を受け取り、一つの固定されたアクションを要求する。アクションは触れるファイルを所有するローカルアカウントの下で動く。連鎖のどこも任意のコマンドパスを受け取らない。
アクション自体はごく普通のサービスユニットであり、身元はその中の一つのディレクティブだ:
[Service]
Type=oneshot
User=alice
Group=alice
ExecStart=/usr/local/bin/refresh-vpn-status --slot %i
User=alice がユニット全体に適用されることに注意せよ、systemd は知っておく価値のある例外を一つ文書化しており、+ を前置したコマンドはそれに関係なく完全な特権で動く。その前置は単一の特権ステップには正当な道具であり、いま作った分離を打ち消す便利な方法でもあるので、そのユニット内の + はレビューで正当化すべきものとして扱え。
トリガーの入力を最小限かつ非秘密に保て。セッション識別子、マッピングされたアカウント、タイムスタンプが通常必要なすべてだ。シェルの断片、実行ファイルのパス、クライアント提供の環境ブロックを境界の向こうへ決して渡してはならない。受け取る側には正当な値と注入された値を区別する手段がないからだ。
実際に動いた身元を確認する
設定のレビューは証拠ではないので、承認されたテスト接続の後に、ファイルに触れた UID と、デーモンが今も使っている UID を確認せよ。
stat -c '%U %G %y %n' /home/alice/.local/state/vpn-status.json
ps -o user=,pid=,cmd= -p "$(pgrep -o -f 'openvpn --daemon')"
systemd-run --uid=alice --pipe /usr/local/bin/refresh-vpn-status --dry-run
一行目は Alice とテスト時間内のタイムスタンプを示すはずだ。二行目は今も root かパッケージのサービスアカウントを示すはずで、これがデーモンが動いていないことを証明する。三行目は実際の再接続を待たずにアクションの権限を試す最も安い方法だ。
ファイルの所有者が Alice なのに、実行ごとにグループやモードが変わっているなら、論理エラーではなく UMask= やディレクトリ権限の違いを探せ。タイムスタンプが全く動いていなければトリガーは一度も発火しておらず、有用な次の一歩はクライアントの再接続ではなくアクションユニットに対する journalctl -u だ。
接続イベントがなお証明しないもの
確立したトンネルは一つの事実にすぎない。デスクトップセッションが解錠されていること、セッションバスに到達できること、アクションが求める私有リソースがすでに利用可能であることは意味しない。Alice の UID の下の oneshot ユニットは彼女の身元を持ち、セッションは何も持たない。だからグラフィカルバスを必要とするものはすべて、対応するユーザーセッション機構を通す必要があり、彼女がログアウトしているときの挙動を定義しなければならない。
再接続も繰り返される。列車の中で眠るノート PC は一時間に十数回の接続イベントを生みかねず、そのたびに行を追加するフックは状態ファイルを誰も読めないログに変える。アクションを冪等にするか、セッションをキーとする一つのレコードを置き換えさせよ。
最後に、被害半径をユニット自身で限定せよ。状態ディレクトリ以外は読み取り専用のファイルシステムビュー、新しい特権はなし、明示的なタイムアウトを付ける。デーモンとユーザーアクションの分離はそれ自体に価値があるが、アクションが承認した一つのこと以外を何もできないとき、はるかに価値がある。