きっかけ
暇つぶしとインフラの勉強を兼ねて、何か作りたいと思っていた。最初に思いついたのは「iptablesのルールを抽出して、何が許可されてて何が拒否されてるのかをUI的に見せるアプリ」だった。
思いつきはよかったが、実際に手を動かしてみると想像より何段階も難しかった(動いたのはほぼClaude)。この記事はその過程の記録。
言語化しづらい分野だったが、ふんわりとした説明でも個人的にはかなり納得のいくものを生成してくれたので満足ではある。
作ったもの
GitHub - wasuken/iptables-rel · GitHub
既存ツールは無かったのか
作り始める前に軽く調べた。同じ発想のツールは既にいくつかある。
gressgraph は iptables -L -vx の出力をグラフ化するツールで、開発者自身が小規模なルールセットでしか検証していないと明言している。iptable_vis はAWKスクリプトでiptablesの出力を読み、blockdiagでフローチャートを生成する。星が700超えていて、それなりに使われているようだった。
ただ、これらは基本的に「チェーンのジャンプ構造」をそのままグラフにする方向性で、IPアドレスやインターフェースの意味を解釈して「ホストAからホストBへの通信」という粒度に変換するような作り方はしていなかった。個人の環境に合わせて意味づけをするなら、これは自分で作った方が早いと判断した。
最初の設計: グラフ、失敗
D3.jsのforce-directedグラフで、ノードをホスト、エッジをルールとして表示するプロトタイプを最初に作った。動くには動いたが、実際に見てみると「関連はわかるけど、間のルールがわからない」という致命的な問題があった。エッジの上に小さくラベルを乗せるだけでは、プロトコルやポート、チェーンの情報を表現しきれない。
次に表形式(ホストごとにルールを一覧表示)を試したが、今度は逆に「どこと繋がってるか」の直感性が失われた。
最終的に落ち着いたのは、グラフとテーブルのハイブリッドだった。ノード間の関係は力学モデルのグラフで表示し、エッジをクリックすると、その2ノード間の全ルールが表形式でサイドバーに展開される。これで「全体像」と「詳細」を両立できた。
インターフェースでつなげる
実データを流し込んでみて気づいたのが、FORWARDチェーンのルールをホスト同士のエッジとしてマッピングすると意味が壊れるケースがあることだった。-i eth0 -o wg0 のようなルールは、2つのホスト間の通信を表しているのではなく、1台のマシンの中で「あるインターフェースから別のインターフェースへ中継してよいか」を表している。
ここでモデルを作り直した。ホストをそのまま1つのノードにするのではなく、ホスト:eth0 ホスト:wg0 のようにインターフェース単位でノードを分割し、ルールでない「そもそも物理的/論理的に繋がっている」という関係は、クリックできない点線のtopologyエッジとして別扱いにした。実線はルール、点線は構造。この区別を入れてから、ようやく「何が起きているか」が読み取れるグラフになった。
パーサ: 再帰的チェーン解決
iptables-save の出力は、INPUT や FORWARD のようなビルトインチェーンから、ユーザー定義のチェーンへ -j でジャンプしていく構造になっている。UFWの設定などはこのジャンプが何段にもネストしていて、素直に1行ずつ読むだけでは意味のあるグラフにならない。
実装したパーサは、ビルトインチェーンを起点に、ACCEPT / DROP / REJECT / DNAT / MASQUERADE のような終端アクションに到達するまで再帰的にチェーンを辿る。テストケースとして、意図的に未定義のチェーンへジャンプするルールや、循環参照(AがBを呼び、BがAを呼ぶ)を仕込んで、無限ループせずに unresolved として記録されることを確認した。完全なNetfilterのセマンティクスを再現しているわけではなく、あくまでヒューリスティックな近似であることは、公開ページのフッターにも明記している。
エリア表示: 凸包から矩形へ
同じサブネットに属するノードを視覚的にグルーピングしたくなった。最初は d3.polygonHull() を使って、ノード群を囲む凸包を薄い背景として描画する方式を試した。技術的には動いたが、見た目が思っていたより有機的すぎて、期待していた「かっちりした枠」の印象にならなかった。
矩形(bounding box)に切り替えた。同じグループのノード座標からmin/maxを取り、パディングを足して角丸の<rect>を描画するだけなので、実装としては凸包より単純になった。CIDRが入れ子になっているケース(/24の中に/28があるなど)は、prefix長が短いほど背面・大きいパディング・薄い色、長いほど前面・小さいパディング・濃い色にすることで、二重の枠として表現した。
インターフェースのノードについては、エリアの境界をノードの座標だけで決めると、繋がっているエッジが箱の外からいきなり生えているように見えて不自然だった。接続エッジの中間点までを座標の計算対象に含めることで、エッジが箱の内側から伸びているように見せている。
公開、しかし・・・
Cloudflare Pagesにデプロイした。ビルドコマンドとアウトプットディレクトリを指定するだけで、静的サイトとして問題なく配信できた。バックエンドは無く、ルールのパースは全部ブラウザ内で完結する構成にした。
一個ドメイン持ってたので、サブドメイン割り当てて公開し、最初デプロイしたとき問題なく表示された。
ところが微修正を加えてPushし、自動デプロイが走った直後、突然サイトにアクセスできなくなった。Cloudflare Pagesが自動で割り当てる *.pages.dev のドメインからは問題なくアクセスできる一方、独自サブドメインだけが ERR_NAME_NOT_RESOLVED で弾かれる状態だった。
切り分けは以下の順で進めた。
nslookup サブドメイン 8.8.8.8で直接GoogleのDNSに問い合わせると、正しくAレコード/AAAAレコードが返ってきた。DNSレコード自体は正しく設定されていた- オプション無しの
nslookupも、自宅NWの上流DNS(dnsmasq)経由で正しく解決できていた - しかし
curlはCould not resolve hostで失敗した
nslookup はOSのリゾルバを経由せず直接UDPでクエリを投げるツールで、curl は /etc/nsswitch.conf の設定に従ってOSのネームサービス解決の仕組み(この場合はsystemd-resolved)を経由する。この2つの経路が違うために、片方だけ通って片方だけ失敗するという状況が起きていた。
resolvectl query で直接 systemd-resolved に問い合わせると、同じく not found のエラーが返ってきて、ここが本丸だと確定した。最終的にはキャッシュのクリアないし systemd-resolved の再起動で解消したが、DNSレコードが正しく設定されていても、末端のリゾルバ実装(ここではsystemd-resolved)がキャッシュや検証設定の都合で古い/誤った状態を返し続けることがある、という教訓が残った。
ブラウザのスーパーリロードでも解決しない上にローカルのnslookupではちゃんと返ってきてたのでかなり困惑した。 生成AIなかったら多分泣きながら1時間くらいググってたと思う。
まとめ
思いつきから公開まで、設計のやり直しが3回(グラフ→表→ハイブリッド)、可視化手法の転換が1回(凸包→矩形)、原因不明の障害切り分けが1回(DNS)あった。既存ツールを調べた限り、同じ路線(インターフェース単位のモデリング、日本語での説明生成、インタラクティブなドリルダウン)のものは見当たらなかったので、車輪の再発明ではあるが、自分の環境に最適化する意味はあったと思っている。
パーサは完全なNetfilterセマンティクスの再現を目指しておらず、あくまで近似であることは強調しておきたい。実際の設定判断をする際は、必ず生ルールを確認してほしい。
まあお遊びツールとして扱ってほしい。