一つの cgroup からの通信にマークを付けるのは簡単な部分だ。一つのアプリケーションが物理インターフェースから出て、それ以外はすべてトンネル内に残るには、三つの別々の判断が揃っていなければならない。どのソケットが例外に属するか、マークがどの経路検索を引き起こすか、そしてパケットが出るときにどの送信元アドレスを運ぶか、である。失敗する試みのほとんどは最初を正しく行い、二つ目を中途半端に済ませ、三つ目には決して目を向けない。

三つの判断であって、一つではない

分類は誰を除外するかに答える。経路選択はマークされたパケットが代わりにどこへ行くかに答える。送信元の選択は、遠端が何を見て、応答がどこへ戻るべきかに答える。

これらは独立に失敗する。パケットは分類され、物理インターフェースへ再ルーティングされても、上流ルーターが戻せないプライベートアドレスを運び続けることがあり、その結果はファイアウォールの遮断とまったく同じに見えるタイムアウトになる。分類の工程はそれについて何も警告しない。経路の工程も同様だ。

順序も重要だ。分類はパケットが生成されるときに起こり、経路選択はその後に起こり、変換は出口の地点で起こる。

三つの段階のどれが壊れているか言えないなら、それはデバッグではなく、ルールを並べ替えて願っているだけだ。

明日も存在する cgroup に一致させる

nftables はソケットの cgroup v2 メンバーシップを検査できる。これは宛先ではなくプロセスに従うので本当に便利だ。落とし穴は識別子にある。メニューから起動したデスクトップアプリは、次の起動で名前が変わる一時的なスコープに住んでいることが多い。

nft list ruleset
systemctl status session.slice 2>/dev/null
cat /proc/self/cgroup

あるセッションからスコープ名をコピーして固定すると、今日は動き明日は黙って一致しなくなるルールができあがる。代わりに、自分で制御する専用のスコープやユニットなど、意図的で安定した実行コンテキストにアプリを置き、それに一致させる。そうすればルールは再起動やパッケージ更新を生き延びる。同僚もあなたのセッション履歴なしで理解できるものになる。

どの仕組みを使っているかも正確に。cgroup v1 の net_cls 分類と cgroup v2 のソケット照合は別物であり、文書で似た名前のフィールドがあるからといって互換にはならない。

二度目の検索を引き起こすのはルートチェーンだ

ローカルで生成される通信では、チェーン種別がマーク設定後に何か起こるかを決める。filter 種別の output チェーンはパケットにマークを付けられるが、カーネルは既に選んだルートを使い続ける。route 種別のチェーンが新しい検索を要求する仕組みだ。

table inet bypass {
  chain mark {
    type route hook output priority mangle; policy accept;
    socket cgroupv2 level 2 "app.slice" meta mark set 0x1 comment "app bypass"
  }
}
ip rule add fwmark 0x1 lookup 200
ip route add default via 203.0.113.1 dev wlan0 table 200

次にマークには、それをテーブルへ送るポリシールールが必要で、そのテーブルは完全でなければならない。デフォルトルート、アプリがまだ必要とするローカル宛先、そして実際に応答する上流ゲートウェイだ。

マークの値は意図して選ぶこと。WireGuard は外部トランスポート通信に独自の fwmark を使うので、偶然同じ番号に着地すると、トンネル自身の経路例外を診断しづらい形で妨害しうる。

送信元アドレスはルートに従わない

トンネルの経路のために送信元アドレスを既に選んだソケットは、再ルーティング後もそのアドレスを保つ。経路の変更はそれを書き換えない。だからパケットは、上流ネットワークが戻せないプライベートな送信元で物理インターフェースを出る。

目に見える症状は、データを送っているように見えて応答をまったく受け取らないアプリと、パケットが戻ってこないので何も表示されないファイアウォールログだ。

狭い修正は、そのアプリのマークと実際の出力インターフェースに限定した送信元変換ルールであり、既存の NAT を置き換えるのではなく統合するものだ。

table inet bypass {
  chain postrouting {
    type nat hook postrouting priority srcnat; policy accept;
    meta mark 0x1 oifname "wlan0" masquerade
  }
}

例外だけを変換し、それ以外は何もしない。大雑把な masquerade ルールは症状を直すが、どの通信がどの経路を通ったかを見分ける能力を壊す。マーク、実際の出力インターフェース、可能ならアプリ自身のアドレスに一致させる。広いルールは書きやすく、後から監査するのはほぼ不可能だ。

それでも例外から漏れるもの

名前解決は別の cgroup の別のリゾルバプロセスから来ることがあるので、ブラウザのソケットを分類してもその検索は分類されない。リゾルバのクエリがトンネル内に残り、接続がその外へ出ると、スプリットホライズンの応答が経路と食い違うことがある。その不一致は DNS のバグに見える障害を生むが、バグではない。

多くの構成では子プロセスは cgroup を継承するが、自分自身を専用スコープへ移すヘルパーは継承しない。思い込まずに確認すること。とくに自己再実行するものについては。

最後に、トンネルが落ちているときに何が起きるべきかを決めよ。黙って完全なバイパスになる例外は、フェイルクローズする例外とは別のセキュリティ姿勢であり、意図したのはどちらか一方だけだ。

関連記事