えー,大学の期末試験が終わったので記事の投稿をしようと思います。元々は昨日投稿する予定の記事だったのですが,体調を崩したので回復した今日に投稿を延期しました。今回はnftablesでの国別IPフィルタリングの話です。
0. nftablesとは
nftablesはLinuxカーネルバージョン3.13以降に搭載されたフレームワークの1つです。現在はiptablesやufw,firewalldではなくnftablesがよく使われるようになったらしいですね。(セキュリティエンジニアの人に聞くまで知らんかった...)
ある程度バージョンの新しいLinuxディストリビューションにはnftablesが標準で搭載されるようになっているみたいですね。RHEL系のOSはバージョン8からiptablesではなくnftablesに切り替えたようです。(RedHatの公式ドキュメントを見る限りRHEL8の部分に書いてあるので恐らく合ってるはず...)
結局バックエンドで動作するプログラムがiptablesからnftablesに変わったってことでしょう。実際iptablesやufw,firewalldのバックエンドで動いているのはnftablesですからね。
さて,話は戻りますがnftablesはiptablesの新たな移行先のファイアウォールです。それぞれの違いについてみてみましょう。
| 特徴 | iptables | nftables | 
|---|---|---|
| 管理ツール | iptables,ip6tables,arptablesなどプロトコルごとにツールが分かれている | nftコマンド一つでIPv4, IPv6, ARP, ブリッジなどを一元管理できる | 
| 構文 | コマンドライン引数ベースで、複雑なルールは記述しにくい | より直感的で一貫性のある構文を採用し、スクリプトのようにルールセットを記述できる | 
| パフォーマンス | ルールの追加・削除時に全ルールを再読み込みするため、大規模なルールセットでは遅延が発生する | ルールセットの更新がアトミック(不可分)であり、差分のみをカーネルに適用するため高速 | 
| ルール評価 | カーネル内でルールが線形に評価されるため、ルール数が増えると性能が低下しやすい | 仮想マシンがルールを評価。 setやmapといったデータ構造を使い、特定IP群に対するマッチングなどを高速に処理できる | 
| 重複 | 類似したルールでも個別に記述する必要があり、コードの重複が起きやすい | setやmapなどのデータ構造を利用して、複数のIPアドレスやポートを一つのルールで効率的に扱える | 
nftablesの基本構造
nftablesのルールセットは、主に「テーブル」「チェーン」「ルール」という3つの階層的な要素で構成されます。
1. テーブル (Table)
ルールセット全体を格納する最上位のコンテナです。テーブルを作成する際には、どのプロトコルファミリーを扱うかを指定します。
- 
主なファミリー:
- 
ip: IPv4パケット用
- 
ip6: IPv6パケット用
- 
inet: IPv4とIPv6の両方を扱う(推奨)
- 
arp: ARPパケット用
- 
bridge: ブリッジされたパケット用
- 
netdev: ネットワークデバイスの入力段階で処理する用
 
- 
例: inetファミリー用のfilterという名前のテーブルを作成する場合
nft add table inet filter
2. チェーン (Chain)
ルールを格納するリストです。パケットが特定の経路を通過するタイミングで処理を実行します。チェーンには2つの種類があります。
- 
ベースチェーン (Base Chain):
 カーネルのネットワークスタックにフックされ、自動的にパケットが通過します。フックの種類にはprerouting,input,forward,output,postroutingなどがあります。
- 
レギュラーチェーン (Regular Chain):
 ベースチェーンや他のルールからjump(ジャンプ)やgoto(ゴートゥ)アクションで呼び出されるカスタムチェーン。ルールを整理・再利用するために使用します。
例: filterテーブル内に、inputフックを持つinputチェーンを作成
nft add chain inet filter input { type filter hook input priority 0 \; }
3. ルール (Rule)
チェーンに追加される個別の処理単位です。ルールは「マッチング条件」と「アクション」で構成されます。
- 
マッチング条件 (Expressions):
 パケットの送信元/宛先IPアドレス、プロトコル(TCP/UDP)、ポート番号など、ルールを適用する条件を指定します。
- 
アクション (Statements):
 条件に一致したパケットに対して実行する処理です。- 
accept: パケットを許可する
- 
drop: パケットを破棄する(送信元に応答を返さない)
- 
reject: パケットを拒否する(送信元に拒否応答を返す)
- 
log: パケットの情報をログに記録する
- 
jump <chain>: 別のチェーンに処理を移す
 
- 
例: inputチェーンに、SSH(TCPポート22)へのアクセスを許可するルールを追加
nft add rule inet filter input tcp dport 22 accept
nftablesは、iptablesの後継として,よりシンプルで一貫性のある構文,高いパフォーマンス,そして柔軟なルール管理機能を提供します。IPv4とIPv6を統合的に扱えるinetファミリーや,set/mapといった高度なデータ構造により,現代の複雑なネットワーク要件にも効率的に対応できる強力なパケットフィルタリングフレームワークと言えるでしょう。多くのLinuxディストリビューションで標準のファイアウォールバックエンドとして採用が進んでおり,Linuxディストリビューションにおける非常に重要なネットワークツールとなるでしょう。
1. 国別制限をするには
ここで重要なのはIPアドレスのデータベース参照になってきます。当然国別でのアクセス制限をしたいのですから指定した国のIPや特定のIPのみからのパケットを許可するように設定する必要があるわけです。ではその国のIPアドレスのデータベースをどこから参照するか?今回はAPNIC(Asia Pacific Network Information Centre)の公開しているIPアドレスデータベースを参照するのではなくGeoIPのデータベースを用いて行います。GeoLite2という無料で使えるデータベースを使ってフィルタリングを行なっていきます。Maxmind社が公開しているデータベースですね。GeoLite2のライセンスを持っていない方は取得しておいて損は無いと思います。有償版を購入することもできますが個人で払えるような金額だったかなぁ?(調べてないので知らない)
では,まずはGeoLite2を使うためにGeoIPツールをインストールします。geoipupdateコマンドが使えるならどのLinuxでも大丈夫だと思われます。今回の検証環境はnftables v1.0.6,v1.0.9,v1.1.3です。OSで言うならUbuntu 24.04 LTS, Debian 12,Debian 13でamd64です。
1-1. geoipupdateパッケージのインストール
Debianだとデフォルトの状態ではインストールできなかったので,sources.listを編集してcontribコンポーネントを付け足します。UbuntuやRHEL系のOSの場合はそのままデフォルトのリポジトリからインストールできるかと思います。
$ sudo apt edit-sources
#nanoかviで開かれると思うので,contribを付け足します。(例:Debian 13の場合)
deb http://deb.debian.org/debian/ trixie main non-free-firmware contrib
deb-src http://deb.debian.org/debian/ trixie main non-free-firmware contrib
#編集し保存したらaptやapt-getなどでリポジトリデータベースを更新します。
$ sudo apt update
$ sudo apt install geoipupdate
$ sudo dnf install geoipupdate   #yumでもいいが今後のOSでは廃止予定らしい。
1-2. GeoIP.confの設定
このパッケージをインストールしただけではGeoLite2のデータベースを利用できません。/etc/GeoIP.confにライセンスIDとライセンスキーを記述する必要があります。
# Please see https://dev.maxmind.com/geoip/updating-databases?lang=en for
# instructions on setting up geoipupdate, including information on how to
# download a pre-filled GeoIP.conf file.
# Replace YOUR_ACCOUNT_ID_HERE and YOUR_LICENSE_KEY_HERE with an active account
# ID and license key combination associated with your MaxMind account. These
# are available from https://www.maxmind.com/en/my_license_key.
# AccountID YOUR_ACCOUNT_ID_HERE
# LicenseKey YOUR_LICENSE_KEY_HERE
# Enter the edition IDs of the databases you would like to update.
# Multiple edition IDs are separated by spaces.
#
# Include one or more of the following edition IDs:
# * GeoLite2-ASN - GeoLite 2 ASN
# * GeoLite2-City - GeoLite 2 City
# * GeoLite2-Country - GeoLite2 Country
EditionIDs GeoLite2-Country GeoLite2-City
AccountID       XXXXXXX #7桁の数字
LicenseKey      XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX #ライセンスキー。一度しか表示されないので注意
設定が完了したらデータベースを実際にダウンロードできるかを検証します。
1-3. geoipupdateコマンドを実際に使ってみる
geoipupdateコマンドでデータベースをダウンロードしていきましょう。-vオプション(verbose,つまり詳細オプション)をつけて何をしているか分かりやすくしておくと良いでしょう。
$ sudo geoipupdate -v
geoipupdate version 7.1.0 (linux-amd64)
Using config file /etc/GeoIP.conf
Using database directory /usr/share/GeoIP
Initializing file lock at /usr/share/GeoIP/.geoipupdate.lock
Acquired lock file at /usr/share/GeoIP/.geoipupdate.lock
Database does not exist, returning zeroed hash
Updates available for GeoLite2-Country
Database GeoLite2-Country successfully updated: c68f431b4f42d722fba522836b9c10b5
Database does not exist, returning zeroed hash
Updates available for GeoLite2-City
Database GeoLite2-City successfully updated: b13ff0b9bdeabcac38130b706245c99e
Lock file /usr/share/GeoIP/.geoipupdate.lock successfully released
このようにログが流れてダウンロードが正常にできていればOKです。では次に指定したディレクトリにデータベースをダウンロードします。-dオプションでディレクトリを指定できます。
$ sudo geoipupdate -v -d /usr/share/GeoIP
1-4. データベースからIPアドレスデータを抽出する
Python3を使って抽出します。仮想環境を作りそこにpipで必要なライブラリをインストールします。C/C++を使っても良いんですけど僕は上手くできなかったです。(maxminddbのヘッダーファイルが必要なんですけどそのバージョンが古かったのかな?それでコンパイルできなかった。)
$ python3.12 -m venv python3.12  #ディレクトリ名はなんでもいい
$ source python3.12/bin/activate
(python3.12)$ pip install maxminddb
ではスクリプトを記述していきます。大したことしてないスクリプトなんでもう少し最適化できるかもしれないですね。(面倒なのでやってない)
#!/usr/bin/env python3
import maxminddb
import sys
import os
# --- 設定 ---
GEOIP_DB_PATH = '/usr/share/GeoIP/GeoLite2-Country.mmdb'
TARGET_COUNTRY_CODE = 'JP'
OUTPUT_FILE = '/etc/nftables/geoip.nft'
# --- 設定終わり ---
def get_country_networks(reader, ip_version):
    """指定されたIPバージョンのネットワークリストを取得する"""
    networks = []
    try:
        for prefix, data in reader:
            if 'country' in data and data['country']['iso_code'] == TARGET_COUNTRY_CODE:
                if prefix.version == ip_version:
                    networks.append(str(prefix))
    except Exception as e:
        print(f"データベースの読み込み中にエラーが発生しました: {e}", file=sys.stderr)
    return networks
def write_nft_file(ipv4_networks, ipv6_networks):
    """nftablesが読み込むための設定ファイルを書き出す"""
    try:
        with open(OUTPUT_FILE, 'w') as f:
            f.write("# This file is automatically generated. DO NOT EDIT.\n\n")
            
            # IPv4セットの書き出し
            f.write("set geoip_jp_v4 {\n")
            f.write("    type ipv4_addr\n")
            f.write("    flags interval\n")
            f.write("    elements = {\n")
            if ipv4_networks:
                f.write("        " + ",\n        ".join(ipv4_networks) + "\n")
            f.write("    }\n")
            f.write("}\n\n")
            # IPv6セットの書き出し
            f.write("set geoip_jp_v6 {\n")
            f.write("    type ipv6_addr\n")
            f.write("    flags interval\n")
            f.write("    elements = {\n")
            if ipv6_networks:
                f.write("        " + ",\n        ".join(ipv6_networks) + "\n")
            f.write("    }\n")
            f.write("}\n")
            
        print(f"成功: {OUTPUT_FILE} を生成しました。")
        print(f"IPv4ネットワーク数: {len(ipv4_networks)}")
        print(f"IPv6ネットワーク数: {len(ipv6_networks)}")
    except IOError as e:
        print(f"ファイルの書き込みに失敗しました: {e}", file=sys.stderr)
        sys.exit(1)
def main():
    """メイン処理"""
    if not os.path.exists(GEOIP_DB_PATH):
        print(f"エラー: GeoIPデータベースが見つかりません: {GEOIP_DB_PATH}", file=sys.stderr)
        print("geoipupdateを実行してデータベースをダウンロードしてください。", file=sys.stderr)
        sys.exit(1)
    with maxminddb.open_database(GEOIP_DB_PATH) as reader:
        print("日本のIPv4ネットワークを抽出中...")
        ipv4_nets = get_country_networks(reader, 4)
        
        print("日本のIPv6ネットワークを抽出中...")
        ipv6_nets = get_country_networks(reader, 6)
    write_nft_file(ipv4_nets, ipv6_nets)
    print("\nnftablesをリロードして設定を反映してください: sudo systemctl reload nftables")
if __name__ == '__main__':
    # スクリプトをスーパーユーザー権限で実行する必要があることを確認
    if os.geteuid() != 0:
        print("エラー: このスクリプトはsudoを使ってスーパーユーザー権限で実行する必要があります。", file=sys.stderr)
        sys.exit(1)
    main()
実際にこのプログラムをPythonで実行するときにmaxminddbのライブラリが入っていないpython環境で実行しないように注意。
/etcディレクトリにnftablesディレクトリを作成します。その後に上記で作成したスクリプトを実行します。
# maxminddbがインストールされたpython環境のpythonバイナリファイルのシンボリックリンクを作成
$ sudo touch /home/shumaikun0716/python3.12/bin/python /usr/bin/set-nftables-ip
# /etcにディレクトリを作成
$ sudo mkdir -p /etc/nftables
# スクリプトを実行
$ sudo set-nftables-ip generate_geoip_sets.py
1-5. /etc/nftables.confの設定
例として今回は日本とCloudflareのIP,GoogleのDNS以外のIPアドレスからパケットのみを許可する設定を書きました。各自で設定したい部分がある場合はその都度変更してください。あくまで参考程度にしてください。まぁでも独自ドメイン使って,さらにCloudflareのネームサーバーを使って自前でWebサーバー建ててるならCloudflareのIPを許可しておかないと大変なことになります。では実際に設定を記述していきます。
#!/usr/sbin/nft -f
# 既存のルールセットをすべてクリア
flush ruleset
# inetファミリーのテーブルを定義 (IPv4とIPv6を両方扱う)
table inet filter {
    # -------------------------------------------------------------------------
    # セットの定義 (IPアドレスやポートのグループ)
    # -------------------------------------------------------------------------
    # ローカルIPアドレス
    set local_ipv4 {
        type ipv4_addr
        flags interval
        elements = {
            192.168.0.0/16, 172.16.0.0/12, 10.0.0.0/8
        }
    }
    set local_ipv6 {
        type ipv6_addr
        flags interval
        elements = {
            fe80::/10, ff00::/8, ::1/128
        }
    }
        
    # 例外として許可するCloudflareのIPアドレス
    set cloudflare_v4 {
        type ipv4_addr
        flags interval
        elements = {
            173.245.48.0/20, 103.21.244.0/22, 103.22.200.0/22,
            103.31.4.0/22, 141.101.64.0/18, 108.162.192.0/18,
            190.93.240.0/20, 188.114.96.0/20, 197.234.240.0/22,
            198.41.128.0/17, 162.158.0.0/15, 104.16.0.0/13,
            104.24.0.0/14, 172.64.0.0/13, 131.0.72.0/22
        }
    }
    set cloudflare_v6 {
        type ipv6_addr
        flags interval
        elements = {
            2400:cb00::/32, 2606:4700::/32, 2803:f800::/32,
            2405:b500::/32, 2405:8100::/32, 2a06:98c0::/29,
            2c0f:f248::/32
        }
    }
    # 例外として許可するGoogle Public DNSのIPアドレス
    set google_dns_v4 {
        type ipv4_addr
        elements = { 8.8.8.8, 8.8.4.4 }
    }
    set google_dns_v6 {
        type ipv6_addr
        elements = { 2001:4860:4860::8888, 2001:4860:4860::8844 }
    }
    # 日本からの接続のみを許可するTCP/UDPポート
    set japan_only_ports {
        type inet_service
        elements = {
            53, 80, 443, 587, 993, 995
        }
    }
    # --- GeoIPセットの読み込み ---
    # 外部スクリプトで生成した日本のIPアドレスリストを読み込む
    include "/etc/nftables/geoip.nft"
    # -------------------------------------------------------------------------
    # チェーンの定義 (パケットフィルタリングのルール)
    # -------------------------------------------------------------------------
    chain input {
        type filter hook input priority 0; policy drop;
        # --- 基本的な許可ルール ---
        ct state established,related accept
        ct state invalid drop
        iifname "lo" accept
        # --- 国に関わらず許可するルール ---
        icmp type { echo-request, echo-reply } accept
        icmpv6 type { echo-request, echo-reply, nd-neighbor-solicit, nd-neighbor-advert, nd-router-advert } accept
        ip saddr @local_ipv4 accept
        ip6 saddr @local_ipv6 accept
        ip saddr @cloudflare_v4 accept
        ip6 saddr @cloudflare_v6 accept
        ip saddr @google_dns_v4 accept
        ip6 saddr @google_dns_v6 accept
        # --- 日本国内に限定するルール ---
        # 読み込んだ日本のIPセットに一致するか確認する
        tcp dport @japan_only_ports ip saddr @geoip_jp_v4 accept
        udp dport @japan_only_ports ip saddr @geoip_jp_v4 accept
        tcp dport @japan_only_ports ip6 saddr @geoip_jp_v6 accept
        udp dport @japan_only_ports ip6 saddr @geoip_jp_v6 accept
        # 上記のいずれのルールにもマッチしなかったパケットは、デフォルトポリシーによりdropされる
    }
    chain forward {
        type filter hook forward priority 0; policy drop;
    }
    chain output {
        type filter hook output priority 0; policy accept;
    }
}
これで設定ファイルの記述が完了しました。この設定ではTCPとUDPの両方がフルで指定した番号が開いてしまいますのでUDPだけとTCPだけのように分けて記述しておくことを推奨します。この設定ファイルのデフォルト条件はICMPはCloudflareのIP・Google DNS・日本のIPのみを許可し,TCP・UDPは日本のIPのみを許可しています。ICMPは最低限度のEcho-ReplyとEcho-Requestしか記述していませんので他のリクエストを通したい場合は付け足してください。
では設定に問題がないかどうかをチェックしていきます。問題がなければ特に何も返されないはずです。
$ sudo nft -c -f /etc/nftables.conf
-c・・・厳密に文法をチェックしてくれます。
-f・・・コンフィグファイルをフルパスで指定します。このオプションは必ず必要です。
今回は使っていませんが-oオプション(optimizeかと)で最適化がされているかどうかをチェックし,最適化をするのも良いでしょう。
2. nftablesを動かしてみる
設定が完了したらsystemdを使ってnftablesを実行します。問題がなければエラーを吐かずに起動するはずです。問題があればエラーを吐きます。
$ sudo systemctl start nftables.service
#システムが再起動した時にも自動起動するようにする場合
$ sudo systemctl enable nftables.service
# --nowオプションを使うと自動起動するように設定し,さらにサービスなどをコマンド実行時に起動する。
$ sudo systemctl --now enable nftables.service
問題なくnftablesが作動しているかどうかを確認しましょう。
$ systemctl status nftables.service
● nftables.service - nftables
     Loaded: loaded (/usr/lib/systemd/system/nftables.service; enabled; preset: enabled)
     Active: active (exited) since Thu 2025-07-17 04:05:33 JST; 4 days ago
       Docs: man:nft(8)
             http://wiki.nftables.org
   Main PID: 504 (code=exited, status=0/SUCCESS)
        CPU: 272ms
 7月 17 04:05:33 localhost systemd[1]: Finished nftables.service - nftables.
Notice: journal has been rotated since unit was started, output may be incomplete.
このようにActive: active (exited)となっていれば問題ないです。ここに更にPortSentryなどを使ってポートスキャン対策をしても良いでしょう。
では実際にフィルタリングが成功しているかどうかを確かめてみましょう。別で用意したクライアントからnftablesでフィルタリングをしたPCに対してnmapでポートスキャンをしてみます。VPNとか使ってオーストラリアからポートスキャンをするとなんと接続が弾かれました。成功してますね。IPアドレスの解決ができていないのでそもそもポートスキャンをできないのでこうなるんでしょうね。ただ,問題があって9.9.9.9のようにCDN(Contents Delivery Network)を使っているタイプのネットワークからのスキャンだとCDNの地域に日本が含まれていたら例え海外のIPだとしてもポートスキャンを行えてしまうので注意が必要です。
$ nmap -Pn -sT {HOSTNAME}
Starting Nmap 7.97 ( https://nmap.org ) at 2025-07-20 08:34 +0900
Failed to resolve "{HOSTNAME}".
WARNING: No targets were specified, so 0 hosts scanned.
Nmap done: 0 IP addresses (0 hosts up) scanned in 4.52 seconds
3. おまけ
cronを使って自動でIPアドレスのデータベースを更新したり,nftablesのリロードを自動でしたりするように設定しておくと楽です。毎日実行されると面倒だと私は思っているので1週間に一度実行されるように設定しています。あくまで参考程度にしてください。
$ sudo crontab -u root -e
# m h  dom mon dow   command
  0 5  *   *   1 geoipupdate -d -v /usr/share/GeoIP
  5 5  *   *   1 /usr/bin/set-nftables-ip /etc/nftables/generate_geoip_sets.py
  10 5 *   *   1 systemctl restart nftables.service
4. 最後に
Linuxは中学2年生の時に初めて触った(Ubuntu20.04)のですが,最初はマインクラフトのサーバーを建てるために使ったのを今でも覚えています。今は大学1年生でマインクラフトのサーバーだけでなく,VPN・Web・プロキシ・DNSサーバーなど色々なサーバーを建てて実行しています。たった6年くらいでここまでやるようになるとは思ってませんでした(笑)。Linuxを触り始めた頃はマイクラのサーバーくらいしか動かしてなかったのでポート開放の仕方くらいしか知らなかったですね。ポート開放とかファイアウォールを調べていたときにufw,iptables,firewalld以外になんかnfなんとかって言うのがあるなぁーって思ってましたね。当時はufwがめっちゃ簡単だったのでそれを使ってました。実際ufwはめちゃめちゃ簡単に使えますよね。gufwっていうGUIプログラムも用意されてますし。firewalldもGUIプログラムがあってパッケージ名が確かfirewall-configだったかな?それも一時期触ってました。やっぱ色々と調べて実際にやってみるのは自分の糧になりますし何よりやりがいと楽しさがありますからより実力が付きやすい気がしますね。先人たちが素晴らしいプログラムを作っていることに感謝をしながら使ってます。本当にありがたいですね。前回のコマンド解説の時に次回もコマンド解説記事を出すって書いたのですが,これは番外編っていうことで書きました。調べた感じnftablesの記事って少ないので参考になれば嬉しい限りです。