Skip to main content

2.2 nftables: Vận Hành Thực Tế Và Các Use Case Phổ Biến

Bài nftables: Kiến Trúc Bên Dưới Lớp Cú Pháp đã mổ xẻ vì sao nftables được thiết kế như vậy — máy ảo thanh ghi, giao dịch atomic, set/map, flowtable. Bài này đi theo hướng ngược lại: cầm 1 server Linux thật, viết 1 ruleset thật, và đi qua những use case gặp nhiều nhất trong vận hành — chặn SSH brute-force mà không cần fail2ban, NAT cho router, rate-limit chống flood, quản lý allowlist/denylist lớn, và cách reload ruleset mà không tự đá mình ra khỏi SSH session.

Không nhắc lại lý thuyết ở bài trước trừ khi thật sự cần — mỗi khi 1 khái niệm (base chain, priority, set, meter...) được dùng mà cần hiểu vì sao nó hoạt động vậy, bài sẽ trỏ ngược lại đúng mục trong bài lý thuyết thay vì giải thích lại.

Mục lục


1. Cài đặt và vòng đời của 1 file cấu hình

Trên Ubuntu, nftables thường đã có sẵn (kernel hỗ trợ nf_tables từ 3.13). Kiểm tra nhanh:

nft --version
# nftables v1.0.9 (Old Doc Yak)

sudo nft list ruleset # ruleset hiện tại đang chạy trong kernel — nguồn sự thật duy nhất

Nếu nft chưa có: sudo apt install nftables, rồi bật service để load /etc/nftables.conf mỗi lúc boot:

sudo systemctl enable --now nftables.service

Vòng đời khuyến nghị cho 1 file cấu hình production: không sửa trực tiếp ruleset đang chạy bằng nft add rule rời rạc rồi quên đồng bộ lại file — cách đó dẫn tới tình trạng ruleset trong kernel và file trên đĩa lệch nhau, reboot 1 cái là mất hết thay đổi thủ công. Quy trình đúng: sửa /etc/nftables.conf, test cú pháp, rồi nạp lại nguyên khối (chi tiết ở mục 8).

2. Priority không phải số tùy chọn — nó có 1 chuẩn ngầm định

Bài lý thuyết đã nói base chain cần khai hook + priority (mục 3), nhưng chưa nói priority nên đặt bao nhiêu. Trên thực tế gần như không ai tự nghĩ ra số — nftables định nghĩa sẵn 1 dải giá trị chuẩn, mô phỏng đúng thứ tự 5 bảng quen thuộc của iptables cũ, dùng được trực tiếp bằng từ khóa (từ nftables 0.9.6):

Từ khóaGiá trị sốVai trò tương đương
raw-300Chạy trước conntrack — dùng để notrack cho traffic không cần theo dõi state
mangle-150Sửa thuộc tính gói tin (TTL, mark...) trước khi định tuyến
dstnat-100DNAT — phải chạy trước khi định tuyến quyết định gói tin đi đâu
filter0Lọc gói tin — nơi phần lớn rule accept/drop nằm ở đây
security50Dành cho SELinux secmark
srcnat100SNAT/masquerade — phải chạy sau khi đã định tuyến xong, biết chắc gói ra interface nào

Thứ tự tăng dần = chạy trước. Dùng từ khóa thay vì số cho dễ đọc, và vẫn cộng/trừ được nếu cần chen giữa 2 mốc: type filter hook prerouting priority dstnat - 5; tương đương priority số -105.

Priority tăng dần → chạy trước, tại cùng 1 hookpriority tăng dầnraw-300mangle-150dstnat-100filter0security50srcnat100dstnat (DNAT) luôn trước filter — gói tin phải được "đổi đích" trước khi rule lọc thấy đúng đích cuốisrcnat (SNAT/masquerade) luôn sau filter — vì cần biết chắc gói ra interface nào mới đổi được nguồn

3. Ruleset firewall cơ bản cho 1 server

Bộ khung tối thiểu, đủ dùng thật cho 1 VPS/server đứng độc lập (không làm router). Toàn bộ nằm trong 1 table inet để áp dụng chung IPv4/IPv6, đúng lợi thế đã nói ở mục 1 bài lý thuyết:

#!/usr/sbin/nft -f

flush ruleset

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

# loopback luôn phải accept trước tiên — thiếu dòng này, rất nhiều
# service nội bộ (systemd-resolved, postgres qua socket TCP local...) gãy
iif lo accept

# connection đã được xác lập/liên quan (vd reply của kết nối do chính
# server này mở ra) luôn accept — không đánh giá lại từ đầu
ct state established,related accept

# gói invalid — không khớp state nào hợp lệ trong conntrack — loại ngay,
# đây thường là dấu hiệu gói bị giả mạo/hỏng
ct state invalid drop

# icmp cần thiết cho path MTU discovery và chẩn đoán mạng, KHÔNG nên
# drop toàn bộ icmp như nhiều ruleset tự viết vẫn mắc lỗi này
ip protocol icmp icmp type { echo-request, destination-unreachable, time-exceeded } accept
ip6 nexthdr icmpv6 icmpv6 type { echo-request, nd-neighbor-solicit, nd-neighbor-advert, nd-router-advert } accept

tcp dport 22 accept
tcp dport { 80, 443 } accept

# mọi thứ còn lại: log rồi mới drop, để có dấu vết khi debug
# (bỏ dòng log nếu server có traffic lớn, log tốn I/O)
log prefix "nft-input-drop: " drop
}

chain forward {
type filter hook forward priority filter; policy drop;
}

chain output {
type filter hook output priority filter; policy accept;
}
}

Vài điểm dễ bỏ sót khi tự viết lần đầu:

  • policy drop trên input chỉ có tác dụng đúng nếu đặt SAU khi đã chắc chắn có rule accept SSH — áp dụng qua SSH mà quên rule tcp dport 22 accept, hoặc test sai thứ tự, tự khóa mình ra khỏi server ngay lập tức. Luôn có phương án dự phòng (console/KVM/serial) khi test lần đầu.
  • Thứ tự rule trong 1 chain vẫn quan trọng như iptables — nftables không "tối ưu hóa lại" thứ tự bạn viết, nft_do_chain() duyệt đúng tuần tự đã khai báo (đã nói ở mục 4 bài lý thuyết). Đặt ct state established,related accept trước tcp dport 22 accept là tối ưu tốc độ thật — phần lớn gói tin đi qua là traffic của kết nối đã mở, không cần đánh giá lại từng điều kiện TCP dport mỗi lần.
  • forward chain trống với policy drop: đúng cho 1 server thuần (không route traffic hộ ai), tồn tại chỉ để chặn hẳn khả năng server bị lợi dụng làm bàn đạp forward traffic không mong muốn.

4. Use case: chặn SSH brute-force mà không cần fail2ban

Bài Fail2Ban: Giải Phẫu Cơ Chế Hoạt Động Bên Trong đã mổ xẻ cách fail2ban làm việc này: đọc log, regex, đếm, gọi nft/iptables từ 1 tiến trình ngoài. Nếu chỉ cần đúng nhu cầu "giới hạn tốc độ thử SSH theo từng IP", nftables tự làm được việc này hoàn toàn trong kernel, không cần daemon riêng đọc log — bằng meter: 1 set động (dynamic set) mà chính rule ghi phần tử vào, không phải do người dùng nft add element tay.

table inet filter {
set ssh_ratelimit {
type ipv4_addr
timeout 60s
flags dynamic
}

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

iif lo accept
ct state established,related accept
ct state invalid drop

# với MỖI kết nối SSH mới: tra/tạo phần tử trong ssh_ratelimit khóa
# theo ip saddr, gắn 1 rate limiter riêng cho đúng IP đó (3 lần/phút).
# Vượt ngưỡng → rơi qua rule bên dưới bị drop; dưới ngưỡng → accept.
ct state new tcp dport 22 update @ssh_ratelimit { ip saddr limit rate 3/minute } accept
tcp dport 22 drop
}
}

Cơ chế update @ssh_ratelimit { ip saddr limit rate 3/minute }: gói tin đầu tiên từ 1 IP tạo 1 phần tử mới trong set, gắn kèm 1 token-bucket rate limiter riêng cho IP đó (không chia sẻ giữa các IP khác nhau — mỗi khóa trong set có bucket riêng). Mỗi gói SYN mới tiếp theo từ cùng IP làm mới (refresh) timeout 60 giây và tiêu tốn 1 token; hết token trong cửa sổ đó, packet không khớp limit rate nữa, rơi xuống tcp dport 22 drop phía dưới. Sau 60 giây không có kết nối mới, phần tử tự bị dọn khỏi set — không cần cron job dọn dẹp nào cả.

Muốn có hành vi gần giống fail2ban hơn — cấm hẳn 1 IP trong 1 khoảng thời gian dài sau khi vượt ngưỡng, thay vì chỉ giới hạn tốc độ tại thời điểm vượt — dùng thêm 1 set thứ hai đóng vai trò denylist, và từ khóa over để chỉ khớp đúng lúc rate bị VƯỢT quá:

table inet filter {
set ssh_ratelimit {
type ipv4_addr; timeout 60s; flags dynamic
}
set ssh_blackhole {
type ipv4_addr; timeout 1h; flags dynamic, timeout
}

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

iif lo accept
ip saddr @ssh_blackhole drop # IP đã bị đưa vào denylist: chặn ngay từ đầu
ct state established,related accept
ct state invalid drop

ct state new tcp dport 22 \
update @ssh_ratelimit { ip saddr limit rate over 3/minute } \
add @ssh_blackhole { ip saddr } # vượt ngưỡng → thêm vào denylist 1 giờ

ct state new tcp dport 22 update @ssh_ratelimit { ip saddr limit rate 3/minute } accept
tcp dport 22 drop
}
}

💡 So với fail2ban thật sự khác nhau ở đâu: cách này nhanh hơn hẳn về độ trễ phản ứng — không phải chờ service ghi log, không phải chờ vòng poll/inotify, không phải gọi subprocess nft/iptables qua _cmd_lock như mục 8 bài Fail2Ban đã mổ xẻ — mọi thứ chạy ngay trong đường xử lý gói tin ở kernel. Đổi lại, nó chỉ hiểu được đúng 1 tín hiệu: tốc độ kết nối TCP mới. Nó không đọc được nội dung log SSH để phân biệt "3 lần gõ sai mật khẩu" với "3 lần kết nối rồi ngắt hợp lệ (health check)" — đúng giới hạn đã nói ở mục 9 bài Fail2Ban khi so hashlimit thuần với 1 bộ phát hiện hiểu ngữ nghĩa log. Trong thực tế, kỹ thuật này và fail2ban không loại trừ nhau: đặt kernel rate-limit làm tuyến đầu (rẻ, tức thời), fail2ban (hoặc CrowdSec) làm tuyến sau để bắt đúng "brute-force mật khẩu" theo nghĩa ứng dụng.

5. Use case: NAT — masquerade và port forwarding

Masquerade (SNAT động — dùng khi IP nguồn ra ngoài không cố định, ví dụ router có IP WAN cấp DHCP): cho phép LAN sau router đi ra Internet qua đúng 1 IP public.

table inet nat {
chain postrouting {
type nat hook postrouting priority srcnat; policy accept;
oifname "eth0" masquerade
}
}

DNAT / port forwarding (đưa traffic từ ngoài vào 1 service nội bộ, ví dụ web server chạy trong LAN):

table inet nat {
chain prerouting {
type nat hook prerouting priority dstnat; policy accept;
tcp dport 8080 dnat to 192.168.1.10:80
}
chain postrouting {
type nat hook postrouting priority srcnat; policy accept;
oifname "eth0" masquerade
}
}

Đúng như bảng priority ở mục 2: prerouting/dstnat (-100) chạy trước khi kernel quyết định định tuyến, để định tuyến "thấy" đúng địa chỉ đích sau khi đã NAT — còn postrouting/srcnat (100) chạy sau cùng, khi kernel đã biết chắc gói tin sẽ ra qua interface nào. Đảo ngược 2 chain type này (đặt DNAT ở postrouting) là lỗi cấu hình phổ biến khiến rule "chạy nhưng không có tác dụng" — vì DNAT chỉ hợp lệ được kiểm tra bởi kernel ở đúng thời điểm định tuyến chưa xảy ra (mục 3 bài lý thuyết đã nói rule sai chain type âm thầm vô hiệu, không báo lỗi cú pháp).

Đừng quên: NAT cần forward chain cho phép traffic đi qua, không chỉ table nat là đủ — nếu inet filter/forward đang policy drop như mục 3, phải thêm rule accept tương ứng (thường lọc theo ct state established,related + rule accept cho traffic mới hướng vào port đã forward) trong chain forward.

6. Use case: rate limiting chống flood

Không phải mọi rate-limit đều gắn với 1 set như mục 4 — nhiều trường hợp chỉ cần giới hạn tốc độ chung cho cả loại traffic, dùng limit trực tiếp trên rule, không cần set riêng theo từng IP:

chain input {
type filter hook input priority filter; policy drop;
...
# ping flood: cho phép tối đa 5 gói ICMP echo-request/giây, dư ra thì drop
ip protocol icmp icmp type echo-request limit rate 5/second accept
ip protocol icmp icmp type echo-request drop

# SYN flood ở mức cơ bản (không thay được SYN cookie của kernel, chỉ
# giảm tải sớm trước khi tới tầng ứng dụng)
tcp flags syn tcp dport { 80, 443 } limit rate 50/second burst 20 packets accept
tcp flags syn tcp dport { 80, 443 } drop
}

burst cho phép 1 khoảng dung sai ngắn vượt rate trung bình (token bucket có sẵn "vốn" ban đầu) — cần thiết vì traffic thật hiếm khi đến đều tăm tắp theo đúng nhịp giây, thiếu burst hợp lý dễ drop nhầm traffic hợp lệ ngay ở ngưỡng biên.

7. Use case: allowlist/denylist quy mô lớn

Đây đúng là trường hợp ipset từng phải tồn tại riêng để giải quyết (đã nói ở mục 1 bài lý thuyết) — với nftables, named set làm thẳng việc này, tách hẳn khỏi rule, và update runtime không cần reload cả ruleset:

table inet filter {
set trusted_admins {
type ipv4_addr
elements = { 203.0.113.10, 203.0.113.11 }
}

chain input {
type filter hook input priority filter; policy drop;
...
ip saddr @trusted_admins tcp dport 22 accept # admin không bị rate-limit ở mục 4
}
}

Thêm/bớt phần tử khi đang chạy, không đụng tới rule hay reload:

sudo nft add element inet filter trusted_admins { 198.51.100.5 }
sudo nft delete element inet filter trusted_admins { 203.0.113.11 }
sudo nft list set inet filter trusted_admins

Với danh sách rất lớn (vài nghìn IP, ví dụ nạp từ 1 nguồn threat-intel ngoài), tách hẳn ra 1 file riêng và include vào (directive đã nhắc ở mục 10 bài lý thuyết):

# /etc/nftables.conf
include "/etc/nftables.d/denylist.conf"
# /etc/nftables.d/denylist.conf — sinh tự động bởi script đồng bộ threat-intel
table inet filter {
set denylist {
type ipv4_addr
elements = { 198.51.100.20, 198.51.100.21, ... }
}
}

Tách file giúp script đồng bộ chỉ cần ghi đè đúng 1 file nhỏ rồi nft -f /etc/nftables.conf lại toàn bộ (atomic, mục 5 bài lý thuyết) — không phải tự viết logic diff/patch ruleset đang chạy.

8. Vận hành: test, reload an toàn, backup/restore

Luôn kiểm tra cú pháp trước khi áp dụngnft -c (check-only) parse và validate ngữ nghĩa mà không đụng gì tới ruleset đang chạy:

sudo nft -c -f /etc/nftables.conf

Reload nguyên khối — vì file thường mở đầu bằng flush ruleset, và cả file được gửi trong đúng 1 batch netlink (mục 5 bài lý thuyết), việc "flush rồi nạp lại" diễn ra atomic — không có khoảnh khắc ruleset trống hoàn toàn giữa chừng để lộ ra 1 cửa sổ traffic đi qua tự do:

sudo systemctl reload nftables # tương đương nft -f /etc/nftables.conf

💡 Bẫy hay gặp nhất khi đang SSH vào server: test rule mới bằng nft -f trực tiếp, policy drop đã áp dụng nhưng thiếu rule accept SSH đúng cổng/đúng interface → mất kết nối ngay lập tức, không có cách nào gõ lệnh sửa lại qua chính session đó. Luôn ưu tiên nft -c -f trước, và nếu thử nghiệm rule chưa chắc chắn, cân nhắc đặt trước 1 lệnh tự-rollback bằng at/cron chạy sau vài phút để phục hồi ruleset cũ nếu chưa kịp xác nhận.

Backup/restore — vì nft list ruleset xuất đúng cú pháp có thể nạp lại được, tự bản thân nó đã là định dạng backup:

sudo nft list ruleset > /root/nftables-backup-$(date +%F).conf
# khôi phục:
sudo nft -f /root/nftables-backup-2026-08-24.conf

9. Checklist debug khi rule "không hoạt động"

Đi đúng thứ tự dưới đây trước khi nghi ngờ có bug lạ — phần lớn trường hợp rơi vào 1 trong 5 gạch đầu dòng này:

  1. Rule có thật sự nằm trong ruleset đang chạy không? nft list ruleset | grep -A2 "tên rule" — kiểm tra file trên đĩa và ruleset trong kernel không lệch nhau (đã cảnh báo ở mục 1).
  2. Rule có đứng sau 1 rule terminal khác đã bắt gói tin đó trước không? Nhớ lại mục 4 bài lý thuyết: statement terminal chấm dứt đánh giá ngay — 1 rule drop/accept phía trên vô tình khớp trước sẽ khiến rule phía dưới không bao giờ được xét tới.
  3. Chain type có đúng không? DNAT/SNAT đặt sai nat/filter, hay đặt sai hook (prerouting thay vì postrouting) là nguyên nhân phổ biến nhất khiến NAT "cú pháp đúng nhưng không NAT" (mục 5).
  4. Gói tin có thật sự khớp điều kiện set/counter không? Gắn counter vào rule nghi ngờ, generate traffic thử, rồi nft list ruleset | grep -A1 counter xem số packet có tăng — cách nhanh nhất phân biệt "rule chưa từng được test đúng" với "rule chạy nhưng logic sai".
  5. Cần theo dõi đúng 1 gói tin đi qua từng chain? Gắn meta nftrace set 1 vào rule, sau đó nft monitor trace — đúng công cụ đã nhắc ở mục 10 bài lý thuyết, hữu ích hơn hẳn log khi ruleset có nhiều chain gọi chéo nhau qua jump/goto.

10. Bẫy thường gặp khi chuyển từ iptables sang nftables thuần

  • Quên default policy. iptables mặc định -P INPUT ACCEPT nếu không đặt gì; nftables buộc khai policy tường minh trên mỗi base chain — thiếu policy drop; không có nghĩa là "an toàn theo mặc định", nó chỉ đơn giản là fallback về accept nếu không ghi rõ.
  • Thiếu ct state established,related accept. Ai quen iptables -A INPUT -m state --state ESTABLISHED,RELATED -j ACCEPT đôi khi quên dịch đúng dòng này sang nftables, dẫn tới ruleset lọc rất chặt chiều vào nhưng lại chặn luôn traffic reply hợp lệ.
  • Trộn iptables/ufw với rule nft viết tay. Đúng như đã nói ở mục 9 bài lý thuyếtufw không đọc hiểu được rule nftables thuần nằm ngoài table nó tự quản lý, ufw status sẽ báo sai lệch so với ruleset thật đang chạy.
  • Copy nguyên rule iptables cũ mà không kiểm tra lại thứ tự. nftables không tự sắp xếp lại rule theo "độ cụ thể" — thứ tự viết chính là thứ tự đánh giá, y hệt iptables, nhưng vì cú pháp mới rất dễ khiến người mới lầm tưởng đây là hệ khai báo không quan tâm thứ tự.
  • Không kiểm tra nft -c trước khi áp dụng qua remote session — rủi ro tự khóa mình ra khỏi server đã nhắc ở mục 8, nhưng đáng lặp lại vì đây là lỗi vận hành phổ biến nhất, không phải lỗi kiến thức.

Nguồn tham khảo