以下の内容はhttps://turgenev.hatenablog.com/entry/2024/02/27/015827より取得しました。


nftables/iptables/ufwの使い分け・ベストプラクティス(+Docker連携)

(2024/05/22) Docker関連とiptablesのリセットの話を追加し、順番を入れ替えるなど大幅な変更を行いました。また、iptablesでもチェインを使えばnftablesと似たような設定ができそうと書きましたが誤っていたので修正しました。

概要

Linuxファイアウォール(パケットフィルタ)は、内部的にはLinuxカーネルのnetfilterという機能が担っています。iptablesやnftablesはnetfilterを操作するフロントエンドで、ufwiptablesやnftableを操作するさらに上位のフロントエンドです。また、筆者は使ったことがありませんがfirewalldというのもufwと同じくiptablesやnftablesを操作する上位のフロントエンドのようです。

Linuxファイアウォールを運用するにあたってこのどれを使うべきかが問題になると思うのですが、うまくいいとこどりをして良い感じに運用できそうな方法を見つけたので紹介します。なお、主に個人でのサーバー運営程度を想定しており、業務で使うような大規模システムだったらもっとちゃんとしたやり方が必要かと思います。

iptablesとnftablesの基本

この2つの違いとしては、簡単にいうとnftablesはiptablesの後継で、nftablesが本格的に導入されているUbuntu 22.04などではiptablesコマンドもnftablesを操作するフロントエンドとして動作するようになっています。

iptablesiptablesコマンド、nftablesはnftコマンドを使用します。(処理の共通化など細かい使用感を除けば)パケット処理に関してできることは大体同じですが、構文の見た目は結構違います。nftables自体はそこまで最近導入されたものではないと思うのですが、依然としてiptablesコマンドがほぼ変わらない動作で使用可能なこともあってか、使い方に関する情報が比較的少ない印象です。

例えば以下が参考になります。

nftablesのテーブル

nftablesとiptablesの(特にこの記事において)重要な違いは、ユーザーが独自のテーブルを作成できるということです。

iptablesではnatやmangleのように役割ごとに決まった名前のテーブルを使う必要がありました。一方でnftablesではテーブルは好きな名前で作成し、その中のチェインにおいてnatやmangleなどのタイプを指定するやり方に変わりました。

nftables環境でiptablesコマンドを使用した際には、natやmangleといった従来の固定的なテーブル名に由来するテーブルだけが読み書きされます。つまり、iptablesコマンドを使って独自のテーブルの内容を管理することはできず、nftコマンドを使う必要があります。ufwは(nftables環境でも)依然としてiptablesで動いていますが、firewalldはnftablesを使って独自テーブルを管理しているので、nftを使わないと見られないということに注意が必要です。

nftablesのルールの管理

nftablesでは、nftコマンドを用いてルールをいちいち追加・削除してもいいですが、設定ファイルを用いてルールを管理することもできます。

nftablesというサービスがあって、これを有効化(sudo systemctl enable nftables)すると、/etc/nftables.confというファイル(ディストリビューションによって異なる可能性あり)を起動時に読み込んでくれます(デーモンではなくOneShotタイプ)。

設定ファイルの読み込みの際にはnft -f xxx.confとfオプションを使用します。nftablesが動くのは起動時だけなので、起動後であればルールを反映させたいタイミングでこのコマンドを実行します。

設定ファイルでは、以下のように最初でflush rulesetとして既存のルールを全て削除してから新たにルールを追加していく方法がよく使われます。

#!/usr/sbin/nft -f

flush ruleset

table inet filter {
   ......(省略)
}

そのまま追加するとnft -fでの読み込みのたびにルールが複製されてしまいますが、flushを使用するとこれを防げます。

flush rulesetの問題点

しかし、このflush rulesetには問題があり、文字通り全ての設定を消去してしまうので、ufwやfirewalldやその他VPNなど(例: tailscale)が追加したルールまで全て消えてしまうのです。実際、ufw稼働中にnft -f xxx.confと(ufw関連のルールがない)confファイルを読み込むと、ufwは勝手にdisabledの状態になってしまいます。tailscaleも、うまく通信が通らない状態になってしまいます。

全てをnftablesのファイルで管理するようにすればこの問題はなくなりますが、ufwの簡潔なコマンドの恩恵を受けられなくなり、tailscaleは二重管理のような状態にならざるを得ません。

table単位でのflush

そこで、この記事で解決策として提案したいのが、tableの単位でルールを管理してflushを行うことです。

ntfablesでは、flush rulesetのように全てを消去するのではなく、flush table inet my_inet_tableのようにテーブルを指定してその中のルールを全て消去するということが可能です。これならmy_inet_tableの外側にあるルールとは一切干渉しません。

具体的には、以下のような記述を基本単位としてルールを管理するのが良さそうです。

table inet my_inet_table {
}
flush table inet my_inet_table
table inet my_inet_table {
......(独自のチェイン・ルール)
}

このようにすれば、ファイルを読み込み時にmy_inet_tableの内容だけをファイルに書かれた内容に変更できます。flushの前に最初で一旦空のmy_inet_tableを作成しているのは、my_inet_tableが存在しない状態だとflushコマンドが失敗してしまうからです。(既存のテーブルを改めて作成するのはエラーにはなりません)

  • 追記: 後から知ったのですが、flushに似たコマンドとしてdeletedestroyがあり、deleteはテーブルの削除、destroyも削除ですが対象が存在していなくてもエラーにならないとのことです。この記事の範囲であればdestroyを使うほうが簡明かもしれません。

なお、前述のようにnatやmangleなどの役割に応じてテーブルを分ける必要はありませんが、arpとinetのテーブルなどは別々に書く必要があるので、その個数の分だけ上記の基本単位を書くことになります。

この内容をどこに書けばいいかというと、もちろん既存の/etc/nftables.confに書いてもいいですが、/etc/nftablesにはflush ruleset文を始めとしてちょっとした内容が書いてあるのでそれを変えたくないという場合は、

include "/etc/nftables.custom.conf"

のように独自のファイルをインクルードしておいて、そのcustomファイルのほうに上記の基本単位を記述していく(ルールの更新の際はcustomのほうだけをnft -fで読み込む)という方法も考えられます。

nftables.custom.confに誤りがあると、起動時にnftablesサービス自体が失敗し、ufwなども連鎖的にうまく起動できなくなってしまうので注意してください。(セキュリティ的に必須でない内容は事後的に読み込むのもアリかも?)

複数のtableに分かれるとなると処理の順番が気になるところですが、nftablesではpriorityを指定することで処理順を設定できます。priorityが等しい場合、別のテーブルに属するチェインとの処理順は未定義のようです(Packet processing order in nftables - Unix & Linux Stack Exchange)。そもそもテーブル間での処理順を明確に付けたくなることは実際問題としてあまり多くないかなとは思います。

chain単位でのflush

多くの場合は上記のtableごとのflushで十分かと思いますが、場合によってはtableではなくそれより細かいchain単位でのflushを使いたいケースもあります。

例えば、起動からしばらく経って有効になるもの(cgroupのpathなど)を対象としたルールを設定したい場合を考えます。このとき、起動直後にこのルールを設定しようとするとエラーになるので、上記のnftables.conf(あるいはそこからincludeされるもの)に含めることはできません。となると、別のtableを用意する必要がありそうですが、実際にはnftables.conf内部の他のchainにjumpして使いたいといったケースが考えられます。

この場合には、以下のように同様の発想でchain単位でのflushを行うファイルを作ることで、後からでも既存table内のチェインのルールを書き換えることができます。(ルール内容は適当です)

  • 追記: さっきと同様にあとから気づいたのですが、destroyを使ったほうが短く書けると思います。
table inet my_inet_table {
    chain output_nat2 {
        type nat hook output priority -100; policy accept;
    }
}
flush chain inet my_inet_table output_nat2
table inet my_inet_table {
  chain output_nat2 {
        type nat hook output priority -100; policy accept;
        socket cgroupv2 level 1 "nopriv" jump nopriv
    }
}

ただし、先ほどのtable丸ごとflushするほうのファイルを再読み込みしてしまうとこの内容が消えてしまうため、以下のように両方をincludeしたファイルを作っておいてこれを再読み込みする運用にすると良さそうです。

include "/path/to/my_inet_table.conf"
include "/path/to/my_inet_table_output_nat2.conf"

(追記)mapとsetについての注意

それほど使われる頻度は多くありませんが、nftablesにはmapとsetという機能があり、それぞれ通信のマッピングを保持した上での処理や特定のIP範囲をグループ化した上での処理などに使われます。

これらはtableに付属する形で書くのですが、tableをflushしてもそこに所属していたmapやsetは消えないようです。deleteやdestroyなら消えます。

delete set inet my_inet_table my_set

のようにしてsetやmapを対象にflush・delete・destroyを行うことができます。

ただしmapをflushしてもあまり効果がないようなので注意しましょう。

アトミックなルールの置き換え

この記事ではルールを一旦削除して追加するということを行っていますが、このときに、削除されている一瞬の間にパケットが侵入してこないか?ということが不安になるかもしれません。

実際には、nft -fの読み込みでは、ルールの変更がアトミック(原子的)に実行されます。簡単にいえば、全ての変更が一気に適用されるという意味で、設定ファイルなどを読み込んだ際に「既存のルールがある状態」か「既存のルールがなくなって新規ルールが導入された状態」のどちらか一方でのみパケットを処理するため、隙が生じないということです。

iptables(nftables未導入)環境

nftablesは2014年リリースのLinux 3.13以降なら使えるので多くの環境では使えると思いますが、より古い環境だとiptablesしか使えません。

iptablesでは、iptables-restoreを使えばnft -fと同じようにアトミックにルールを置き換えてくれますが、これの使い勝手はあまり良くありません。具体的には、テーブルに手を加えようとするとテーブル全体がリセットされてしまうので、ufwやfirewalldのルールまで消えてしまいます。また文法もちょっとわかりづらいです(空行がないとエラーになったりとか: firewall - Error applying iptables rules using iptables-restore - Server Fault)。Includeもありません。

アトミックにこだわらなければチェイン単位でのフラッシュ自体は可能なので、iptables-restoreを使わず自前のスクリプトで管理するという方法もありだと思います。ルールの置き換えが1秒程度で終わるのであれば、その間に外部からの攻撃が成立する可能性は現実的にはほぼないでしょう。

nftables/iptables/ufwの実用的な使い分け

これで、ufwVPNの動作を阻害することなく任意のタイミングで独自ルールをアトミックに適用できるようになったので、実際の使い分け方を考えていきます。

ufwは、ポートやインターフェイスなど条件を指定して許可/不許可の設定をする分には見通しがよく便利で、gufwというGUIツールまであるのですが、NATなどの(必ずしも「ファイアウォール」とあまり関係のない)機能を扱おうとすると設定ファイルの編集が必要になります。

そこで、ファイアウォール的な部分だけはufwで管理して、NATなどの細かい設定はnftablesで独自のユーザーテーブルを用いて管理するという使い分け方がいいのではないかと思います。ufwとnftablesのどちらか一方だけを選ぶ必要はありません。nftablesがない環境であればかわりにiptablesが使えます。

では、(nftables環境の)iptablesはどこで使うのか?ということなんですが、ちゃんと使い道はあります。

nftコマンドの大きな欠点として、追加したルールの削除が非常に面倒というのがあります。追加のしかたは大体iptablesと同じなのですが、削除はルールの内容を指定して検索することはできず、ルールの番号(handle)を取得してそれを指定して削除しなければいけません。

iptablesであれば、追加の際の-A(または-I)を-Dに変えるだけで対応するルールを見つけ出して削除してくれます。構文の見やすさ、ネット上の情報の多さなどからいっても、動作テスト時にルール単位で追加・削除をする分にはiptablesコマンドのほうが圧倒的に便利です。

従って、試験的なルールの追加・削除はiptablesで行い、確定したらそのルールをnft list rulesetで表示してnftablesでの書き方を学び、それを先ほどのnftables.custom.confに転記し、iptablesで設定したものは削除する、といった使い方をすると結構やりやすいです。

ただ、iptablesで色々と試したルールをいちいち削除するのは結構な手間です。そこで、手元では以下のようなスクリプトを使って、FORWARD0やFORWARD1といった名前の空のチェインを用意(既に存在するならフラッシュ)し、既存のFORWARDチェインの最初と最後に挿入(既に存在するなら移動)することで環境を手軽にリセットできるようにしています。(もちろん、アトミックではないです)

#!/bin/sh
flush_and_add_aux() {
    table=$1
    chain=$2
    for num in 0 1
    do
        # remove rules if exist
        iptables -t $table -D $chain -j $chain$num
        # create if not exist
        iptables -t $table -N $chain$num
        iptables -t $table -F $chain$num
    done
    iptables -t $table -I $chain -j ${chain}0
    iptables -t $table -A $chain -j ${chain}1
}
flush_and_add(){
    flush_and_add_aux $1 $2 >/dev/null 2>&1
}
for chain in INPUT OUTPUT FORWARD
do
    flush_and_add filter $chain
done
for chain in INPUT OUTPUT PREROUTING POSTROUTING
do
    flush_and_add nat $chain
done
for chain in INPUT OUTPUT FORWARD PREROUTING POSTROUTING
do
    flush_and_add mangle $chain
done
for chain in OUTPUT PREROUTING
do
    flush_and_add raw $chain
done
for chain in INPUT OUTPUT FORWARD
do
    flush_and_add security $chain
done

Dockerとufwの相性の話

ちょっと余談というか応用的な話題です。Dockerではdocker run -p 8080:80 nginxみたいなコマンドを普通に実行してポートを公開すると全IP(0.0.0.0と[::])の8080番をリッスンする上に、8080番を許可するルールがufwのルールよりも上位にinsertされるためにufwで8080番を許可していなくても外から8080番にアクセスが通ってしまうというそこそこ有名な罠が存在します。認識されてから既に5年以上は経っていますが修正される気配がないらしいです。

これに関する対策をちょっと考えてみました。

Dockerによるiptables/nftables設定が具体的にどうなっているかというと、(filterテーブルの)FORWARDチェインの上のほうにDOCKERという別のチェインを呼び出す命令が追加されていて、こいつが勝手にdockerに向かっていくパケットを許可してしまいます。ufw関連のチェイン(ufw-before-forwardとか)はその下で呼び出されています。

解決策としては、要するにDOCKERチェインより前にufwを呼び出すことで不要なパケットをdropさせてやればいいです。こうしておけば、ufwで明示的に許可したパケットだけがdockerに行くようになります。

nftablesの設定内容をよく見ると、DOCKERの上にDOCKER-USERというチェインが追加されており、ここにユーザーが最優先したいルールを追加できます(Docker と iptables — Docker-docs-ja 24.0 ドキュメント)。ここでufwを呼び出せばいいということです。

具体的にnftの設定ファイルに書いてみると、以下のようになります。これは起動時に一回呼び出せば済むので/etc/nftablesに書いてしまって構いません。

#!/usr/sbin/nft -f
flush ruleset
table ip filter {
    chain ts-forward {}
    chain ufw-before-logging-forward {}
    chain ufw-before-forward {}
    chain ufw-after-forward {}
    chain ufw-after-logging-forward {}
    chain ufw-reject-forward {}
    chain ufw-track-forward {}
    chain ufw-skip-to-policy-forward  {}
    chain DOCKER-USER {
        jump ts-forward
        jump ufw-before-logging-forward
        jump ufw-before-forward
        jump ufw-after-forward
        jump ufw-after-logging-forward
        jump ufw-reject-forward
        jump ufw-track-forward
        jump ufw-skip-to-policy-forward 
    }
}

まず、存在しないチェインをjumpで呼び出すことはできないので、あらかじめufw関連のチェインを該当する名前で作成してしまいます(この状態からでもufwは問題なく動作します)。次に、もともとFORWARDチェインから呼び出されていたufw関連のルール(とufw-skip-to-policy-forward)を全てDOCKER-USERチェインで呼び出します。こうすれば、forwardされるパケットはまずここを通り、ufwにより明示的に許可・拒否されなければufwのデフォルトのポリシー(ufw default **** routedで設定するやつ、普通はdrop)に従って処理されます。

ts-forwardというのが入っていますがこれは筆者が使っているTailscaleのためのものです。Tailscale関連の通信は必要に応じてufwをバイパスしてほしいので、(元のFORWARDチェインの内容に沿って)この位置に書いておく必要があります。

この例ではnftablesを使っていますがpriorityや別のtable名などnftables特有の機能は使っていないため同様の設定はiptablesでも可能だと思います。

類似の方法としてGitHub - chaifeng/ufw-dockerというのがそこそこ知られているようですが、プライベートIPを全許可していたりUDPの53番だけ許可していたり32767がハードコードされていたりと、お世辞にも筋の良いやり方とは言えません。




以上の内容はhttps://turgenev.hatenablog.com/entry/2024/02/27/015827より取得しました。
このページはhttp://font.textar.tv/のウェブフォントを使用してます

不具合報告/要望等はこちらへお願いします。
モバイルやる夫Viewer Ver0.14