フックは root で手動実行すると動作し、Operation not permittedサービスが起動した瞬間に返す。ファイルはこの二回の実行の間で変わっていない。変わったのは環境だ。systemd はプロセスに小さな世界を渡す。ケーパビリティは減り、/devも別物になり、読み取り専用のファイルシステムパスやタスクの上限も付いてくる。失敗は、壊れたスクリプトではなく、ある特定の操作を拒否した境界として読む。

拒否された操作から始める

unit が失敗したというログ行は、ほとんど何も教えてくれない。役に立つ行は、拒否された呼び出しを名指しする。開けなかったデバイスノード、書き込めなかったディレクトリ、作成できなかった名前空間だ。どの一つのディレクティブに触れる前でも、その呼び出しを探す。それぞれが別の制約に対応しているからだ。そのうえで、パッケージが同梱したファイルではなく、実際に動いた unit を drop-in も含めて読む。

以下のクエリは、フラグメントのパス、drop-in ディレクトリ、そしてデバイスやファイルシステムの拒否で重要なプロパティを出力する。どの境界が関係するかを決める前に、すべて読む。

systemctl show "[email protected]" -p FragmentPath -p DropInPaths -p PrivateDevices -p DevicePolicy -p DeviceAllow -p ProtectHome -p ProtectSystem -p TasksMax
journalctl -u "[email protected]" -b --no-pager | grep -iE 'operation not permitted|permission denied|cannot open|failed'

このブロックは何も書き込まない。拒否された呼び出しがジャーナルにないなら、ディレクティブを推測するのではなく、一回だけクライアントの詳細度を上げる。プロセスが動いていることは unit が起動した証拠であって、フックが必要な権限で動いた証拠ではない。

名前空間には、TUN デバイスが決して使わないマウント権限が要る

同じフックの中で似て見える二つの操作は、権限プロファイルが違う。/dev/net/tunを開き、インターフェースを作るには、ネットワーク管理とそのノードへのアクセスが要る。名前付きネットワーク名前空間の作成にもマウント作業が要る。名前空間ツールが新しい/sysビューをマウントし、状態ディレクトリを所定の位置にバインドするからだ。 上流のクライアント unit は、マウント管理を意図的にバウンディングセットの外に置いている。だからトンネル自体は動いたはずなのに、名前空間の手順だけが拒否される。

だからデバイスノードを与えてもスクリプトは救われない。デバイスの許可が答えるのは一つの問い、このプロセスがこのノードを開けるかどうかであり、名前空間の問いは別だ。何かを広げる前に、失敗した呼び出しをそのケーパビリティに対応づける。

シェルでのテストは、制限のない root が持つすべてのケーパビリティを継承する。unit が継承するのは、バウンディングセットが残したものだけだ。 二つの実行は別の実験だ バイナリが同一でも。

デバイス、保護パス、タスク上限

拒否された操作が分かれば、三つのディレクティブ群でほとんどの失敗を説明できる。デバイスへのアクセスを制御するのはPrivateDevicesで、これは/devを最小限の疑似デバイス集合に置き換える。もう一つはDevicePolicyとDeviceAllowで、cgroup が何を許すかを決める。/dev/net/tunはその最小集合に含まれないので、PrivateDevices=yesを持つ unit はデバイスポリシーが何と言おうと開けない。

ファイルシステムの保護は二つ目の問いに答える。ProtectHome=yesは/home, /rootと/run/userを到達不能にし、ProtectSystem=fullは/usr, /bootと/etcを読み取り専用にする。だから自分の設定ファイルの隣に書き込むフックは、シェルでは動き unit の下では失敗する。ケーパビリティが欠けたときと同じ形の権限エラーだ。書き込み可能な入力をポリシーが許すパスへ移すか、狭いReadWritePaths=エントリを追加する。

リソース制限が三つ目の群で、最も分かりにくい。TasksMax=は unit が作れるタスク数を制限し、ファイルディスクリプタの上限もソケットやハンドルに対して同じように働く。上限を超えて成長した配備は、権限エラーではなくリソースエラーで失敗する。正確なメッセージが今も重要なのはそのためだ。

名前空間の手順をフックの外へ出す

クライアントが持つべきでない権限を要する準備は、専用の unit に入れる。クライアントより先に走り、結果を保持し、接続より前に順序づけられた oneshot が、その権限をトンネルプロセスに渡さずに名前空間の手順へ必要なものを与える。クライアントは up フックからスクリプトを呼ぶ代わりに、その unit を参照する。

下のテンプレートは出発点の形であり、読まずに適用するものではない。準備を権限のある場所に置き、トンネルの unit は触らない。

# /etc/systemd/system/[email protected]
[Unit]
Description=Namespace preparation for %i
Before=openvpn-client@%i.service
[Service]
Type=oneshot
RemainAfterExit=yes
ExecStart=/usr/local/sbin/netns-up.sh %i
CapabilityBoundingSet=CAP_NET_ADMIN CAP_SYS_ADMIN
[Install]
WantedBy=multi-user.target

順序はパッケージの unit を編集するのではなく drop-in で足す。そうすればアップグレードが変更を黙って落とすことはない。ディレクトリはインスタンス名に紐づくので、二つ目のプロファイルには専用のファイルが要る。

sudo mkdir -p /etc/systemd/system/[email protected]
sudo tee /etc/systemd/system/[email protected]/10-namespace.conf >/dev/null <<'CONF'
[Unit]
[email protected]
[email protected]
CONF
sudo systemctl daemon-reload

変更後に同じ呼び出しを繰り返す

修理が確認できるのは、同じ呼び出しが違う結果を出したときだけだ。両方の unit を再起動し、失敗した操作を実行し、その結果を変更前に保存したジャーナル行と比べる。フックが二度と走らないまま unit が起動するのは修理ではない。

下の手順は比較を正直に保つ。unit の色ではなく、拒否された呼び出しを確認するからだ。その出力を、変更前に取ったジャーナル行の隣に保存する。

sudo systemctl restart "[email protected]" "[email protected]"
systemctl status "[email protected]" --no-pager
journalctl -u "[email protected]" -b --no-pager | grep -c 'Operation not permitted'
ip -brief addr show tun0

最後の行を読むのではなく拒否の回数を数える。フックは失敗し、再試行し、成功することがあり、境界が動いたかどうかは回数でしか分からない。drop-in には緩めたディレクティブと理由をコメントで残す。次のアップグレードがあなたの設定をパッケージの既定値と比べるからだ。すべての保護を切ってからでないと起動しない unit は直っていない。

関連記事