nftables の設定を書いた時のメモ

前回に引き続き、新しいマシンの環境構築をしている。

以前のマシンではファイアウォールとして、以前は iptables を使用していたが、今回 nftables に移行してみる。元々 iptables 自体、かなり昔に書いた設定を使い回していて、設定方法はすっかり忘れてしまっていた。

あらかた設定できたので、その際のメモ。

ネットワーク環境

マシンが置かれているネットワークの環境は以下のような感じ。

  • ルータの内側にいるサーバマシン
    • なのでほとんどの通信はルータの時点でシャットアウトしてもらえる
    • とは言え、可能な限りインターネットに晒されている前提の設定を目指す
  • しかし IPv6 は今回は設定しない
    • 将来的には挑戦したい…
    • プロバイダから IPv6 アドレスを当てられていないので、IPv6 の通信は実際には来ないはず
  • 将来的には Wi-Fi の AP にもしたい
    • が、それはまたの機会に
    • こちらを開くとルータ内部の前提が崩れるので IPv6 も含めてしっかりやらないといけなさそう

設定方針

  • 公開するポートは http(80), https(443), ssh(22 は使わず、別のポートを使う) のみ
  • ssh ポートは、DOS アタックに対して一定時間のブロックを行う

nftables についての資料

あちこちのブログ記事等も参照したが、最終的には公式 Wiki が一番だった。

https://wiki.nftables.org/wiki-nftables/index.php/Main_Page

説明足らずな部分もあったけど、見付けられた資料の中では一番詳しかった。

コマンドライン

nft コマンドを使う。

このコマンドで 1 つずつルールを足していくこともできるが、今回はルールファイルを用意してそのファイルを読み込ませる方法のみを使った。

設定中に使っていたのは以下のコマンドのみ。

コマンド 説明
nft -f {ファイル名} 設定ファイルを読み込んで適用する。
nft list ruleset 現在適用されている設定を表示する。

作った設定ファイルは最終的には /etc/nftables.conf に配置し、systemctl start nftables.service でロードできる。systemctl enable nftables.service で起動時に設定を適用できる。

設定の書き方

特にここからは、個人的に理解したことをざっくりベースで書いていく。理解が間違っている可能性も大いにある。

nftables の ruleset は複数の table を持っており、table は複数の chain を持っており、chain は複数の rule を持っている。table と chain は名前を持っている。

設定の初期化

設定は nft -f で読み込まれる度に追記される。それは困るので、ファイルの先頭に全ての設定を削除するコマンドを入れておく。

flush ruleset

設定ファイルを読み直す度に最初にルールが全て削除されるので、常にファイルに書かれた状態にできる。

なお、nftables はルールをアトミックに変更してくれるので、「flush した一瞬だけルールがない状態でパケットが処理される」ということはない。この辺りも iptables に対する強みの1つ。

table

table はプロトコルファミリーごとに作成する。

# IPv4 用
table ip filter {
}

filter という名前の IPv4 プロトコル向けの table を作った。ファミリーは他に、ip6(IPv6 向け) inet(IPv4/IPv6 両方向け) 等々。詳細はココ

この table のブロック { ... } の中に chain を書いていく。

chain

chain は大きく分けて base chain と non-base chain がある。

base chain

base chain は処理の入口になる chain で、先頭に type <type> hook <hook> priority <value>; のようなものがある。これはこの chain が処理するパケットの条件で、これにマッチするパケットがその chain で処理される。

table ip filter {
  chain input {
    type filter hook input priority filter; policy drop;

    # ... rules ...
  }
}

input と言う名前の chain を作った。なお table もそうだが、名前はあくまで識別のための名前であり、名前自体に意味はない。好きな名前で大丈夫。

type に指定できるのは filter route nat、filter に指定できるのは prerouting input forward output postrouting ingress みたいな感じ。詳細は公式 Wiki の該当ページを参照。 入ってきたパケットは条件に合う chain を priority の小さい順に処理されていく。priority は数字で、一部の数字には別名がある。priority filterpriority 0 と同じ。負数もある。同値の場合は処理順は不定

non-base chain

non-base chain は base chain 以外のもの。先頭に条件がなければ non-base chain になる。jump や goto などのルールで他の chain から飛んでくる。飛んでこない限り chain は参照されない。

rule

chain の中には rule を順番に書いていく。上から順に処理される。

rule は matches による条件の絞り込みと statements によるパケットに対するアクションを好きな順序で並べていく。

# NEWだがSYNが立っていないパケットを破棄
tcp flags & (fin | syn | rst | ack) != syn ct state new counter log prefix "NEW not SYN:" drop
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ ~~~~~~~~~~~~ ======= ========================= ====
  + matches (TCP のフラグをチェック)        |            |       |                         |
                      matches (conntrack の状態が new)   |       |                         |
                      statements (通過したパケット数をカウント)  |                         |
                                                               statements (log に残す)     |
                                                                      statements (パケットを drop する)

個々の matches と statements の境界が文法上は特になく、わかってないとどこで切れているのかがわからないのでわかりづらい。

matches と statements はいくつでも書けるし、順番が混ざってもよい。左から順に処理される。例えば、

# 仮の文法
<stmt1> <match1> <stmt2> <match2> <stmt3>

みたいに書けば、まず stmt1 は常に実行され、次に match1 にマッチしたパケットには stmt2 が実行され、さらに match2 がマッチしたパケット(つまり match1match2 両方にマッチしたパケット)は stmt3 が実行される。

dropaccept などの一部の statements は Verdict statements と呼ばれていて、パケットの扱いが決まる。 この場合はものによって、chain での処理を中断して chain を抜けたりする。別の chain に飛ぶ、jump (終わったら戻ってくる)や goto (終わっても戻ってこない) も Verdict statements の 1 つ。

chain の最後まで行った場合、chain のポリシーによって扱いが決まる。base chain の先頭に書いてた policy drop; みたいなやつ。これは acceptdrop のどちらかで、書いてない場合はデフォルトで accept になる。

個人的な設定

最後に現時点での個人的な設定を貼っておく。コメントが英語だったり日本語だったり、割と雑。どなたか、もしなにかまずそうな部分を見付けたら、こっそり教えてください…。

#!/usr/bin/nft -f

flush ruleset

define localnet = 192.168.1.0/24

table ip filter {
  set dos_counter {
    type ipv4_addr . inet_service
    flags dynamic
  }
  set block_targets {
    type ipv4_addr . inet_service
    flags timeout
  }

  chain input {
    type filter hook input priority filter; policy drop;

    # セッション確立後のパケットは許可
    ct state established,related counter accept

    # early drop of invalid connections
    ct state invalid counter drop

    # allow from loopback
    iifname "lo" counter accept

    # SYN/ACKでNEWなパケットに対してRSTを送る
    tcp flags & (syn | ack) == syn | ack ct state new counter log prefix "SYN/ACK and NEW:" reject with tcp reset

    # NEWだがSYNが立っていないパケットを破棄
    tcp flags & (fin | syn | rst | ack) != syn ct state new counter log prefix "NEW not SYN:" drop

    # allow from local network
    ip saddr $localnet ct state new tcp dport . ip protocol {
      53 . tcp, 53 . udp,  # DNS
      123 . udp, # NTP
      137 . udp, 138 . udp, 139 . tcp,  # Windows
      445 . tcp,  # Active Dierctory
      22 . tcp, # ssh
      17500 . tcp, 17500 . udp, # Dropbox (LAN sync)
    } counter accept

    tcp dport 22 tcp flags & (fin | syn | rst | ack) == syn ct state new goto dos_detection

    ct state new tcp dport . ip protocol {
      80 . tcp, 443 . tcp,  # HTTP/HTTPS
    } counter accept

    # Ident(113)を拒否(DROPするとレスポンスが遅くなるのでReject)
    tcp dport 113 counter reject with tcp reset

    # ping
    icmp type { echo-reply, echo-request } counter accept
  }

  chain output {
    type filter hook output priority filter; policy accept;
    oifname "lo" counter accept
  }

  chain dos_detection {
    counter
    add @dos_counter { ip saddr . tcp dport limit rate over 10/minute burst 5 packets } counter jump add_block
    ip saddr . tcp dport @block_targets counter drop
    counter accept
  }

  chain add_block {
    # Logging only first time
    ip saddr . tcp dport != @block_targets log prefix "DOS Attacked:"
    update @block_targets { ip saddr . tcp dport timeout 30m }
  }
}

ssh はここでは 22 と書いているが、冒頭でも書いた通り実際には別のポートに変えている。

同じく冒頭で書いた DOS アタックのブロックは dos_detection chain と add_block 辺り。ただの rate limit ではなく、検出したら block_targets に放り込んで 30 分ずっと拒否する。

この検出の部分に limit rate を使ってみたのだけど、なんとなくそれっぽく動いているのだけど、実際にどういう処理になっているのかがいまいちよくわかっていない…。

余談: バグ

ところで、ポート番号とかを { ... } で列挙している部分は Set というもので、これを使うことで1つずつルールを書くよりもテーブルジャンプができることで処理が効率化できるみたい。 が、この文法を複数行で書く場合は注意が必要で、文法上のバグがある。

tcp dport {
  80,  # ここにはコメントが書ける
  # ここにはコメントが書けない!
  443,
} counter accept

コメントと書いたが、実際に書けないのは空行である(コメントだけの行も空行扱い)。 すでにバグトラッカーには登録されているみたいだけど、地味に不便なので直ってほしい…。 (opt_newline が任意の個数の改行を処理してくれればよさそうではあるけど、あまり深追いしてない)