3.2 iptables: Vận Hành Thực Tế Và Đọc Ruleset Thừa Hưởng
Tình huống hay gặp nhất với iptables không phải viết một ruleset mới. Nó là tiếp quản một server đang chạy, gõ iptables -nvL, và nhận lại một danh sách rule không có tài liệu kèm theo, không rõ ai thêm, không rõ dòng nào còn tác dụng. Rồi thêm một rule để mở cổng, khởi động lại máy, và rule đó biến mất.
Bài 3.1 về kiến trúc xtables giải thích vì sao bên dưới lại như vậy: ruleset là một khối byte trong kernel, không có file cấu hình nào của riêng iptables, và mỗi lệnh là một chu trình đọc ghi cả khối. Bài này đi theo hướng ngược lại, cầm một server thật và trả lời:
- Làm sao đọc được một ruleset thừa hưởng, biết dòng nào còn sống và dòng nào đã chết?
- Viết rule chặn SSH brute-force, NAT, rate limit, allowlist lớn bằng iptables thì cú pháp thật là gì?
- Áp dụng một ruleset mới qua SSH mà không tự cắt mất đường vào, bằng công cụ nào?
Mọi lệnh dưới đây chạy trên iptables 1.8.x. Những chỗ hành vi phụ thuộc backend legacy hay nf_tables đều được nói rõ tại chỗ.
Mục lục
- 1. iptables không có file cấu hình của riêng nó
- 2. Đọc một ruleset thừa hưởng
- 3. Ruleset cơ bản cho một server
- 4. Chặn SSH brute-force bằng recent và hashlimit
- 5. NAT: masquerade và port forwarding
- 6. Rate limit: limit cho cả rule, hashlimit cho từng khóa
- 7. Danh sách lớn: ipset và match set
- 8. Áp dụng an toàn mà không tự khóa khỏi SSH
- 9. Checklist debug khi rule không có tác dụng
- 10. Docker và Kubernetes sinh ruleset của riêng chúng
1. iptables không có file cấu hình của riêng nó
Đây là khác biệt vận hành đầu tiên và lớn nhất so với nftables. nftables có /etc/nftables.conf, một file chính thức, nạp được trực tiếp, có shebang để chạy như script. iptables không có gì tương đương. Nguồn sự thật duy nhất là khối byte trong kernel, và không có cơ chế nào tự ghi nó ra đĩa.
Thứ upstream cung cấp chỉ là hai công cụ xuất và nhập:
# xuất toàn bộ mọi table ra stdout, đúng định dạng nạp lại được
sudo iptables-save > /root/ruleset-$(date +%F).rules
# nạp lại từ file đó
sudo iptables-restore < /root/ruleset-2026-08-27.rules
Định dạng xuất ra đáng để đọc kỹ, vì đó là định dạng nên dùng khi viết ruleset thành file:
*filter
:INPUT DROP [24:1440]
:FORWARD DROP [0:0]
:OUTPUT ACCEPT [1842:238914]
-A INPUT -i lo -j ACCEPT
-A INPUT -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT
-A INPUT -p tcp -m tcp --dport 22 -j ACCEPT
COMMIT
(Output minh họa, cấu trúc và cú pháp đúng theo lệnh thật, số liệu cụ thể chỉ mang tính ví dụ.)
Mỗi table mở bằng *tên, mỗi chain built-in khai bằng :TÊN POLICY [gói:byte], rule viết dạng -A, và COMMIT đóng table lại. Một COMMIT tương ứng một lần IPT_SO_SET_REPLACE cho đúng table đó, đúng cơ chế ở mục 5 bài kiến trúc.
Phần giữ ruleset qua reboot là việc các distro tự thêm, không phải của iptables. Trên Debian và Ubuntu, gói netfilter-persistent là một bộ nạp cấu hình netfilter theo kiến trúc plugin, còn gói iptables-persistent cung cấp plugin cho iptables và ip6tables. Muốn biết chính xác plugin đó đặt file ở đâu trên máy đang dùng thì hỏi thẳng hệ thống đóng gói, chắc hơn là tin một bài hướng dẫn:
dpkg -L iptables-persistent | grep -E "etc|bin"
Vòng đời nên theo: coi file trên đĩa là bản gốc, sửa file, test, rồi nạp lại nguyên khối. Cách làm ngược lại, gõ iptables -A lẻ rồi quên xuất lại ra file, dẫn tới trạng thái kernel và đĩa lệch nhau, và reboot một cái là mất hết thay đổi thủ công. Rủi ro này giống mục 1 bài nftables, nhưng nặng hơn, vì ở nftables ít nhất file chuẩn luôn tồn tại sẵn.
2. Đọc một ruleset thừa hưởng
Trước khi đọc rule, xác định đang nói chuyện với backend nào. Chữ trong ngoặc của iptables -V trả lời:
iptables -V
# iptables v1.8.10 (nf_tables)
(nf_tables) nghĩa là lệnh iptables đang đi qua lớp dịch sang nf_tables (mục 8 bài kiến trúc), nên ruleset thật nằm chung chỗ với mọi thứ nft quản lý. (legacy) nghĩa là đang dùng đường setsockopt cũ, và lúc đó rất có thể trên máy tồn tại song song hai ruleset không thấy nhau. Trường hợp đó, đọc thêm sudo nft list ruleset trước khi kết luận.
Lệnh liệt kê nên gõ đủ ba cờ:
sudo iptables -nvL --line-numbers
-ntắt tra cứu tên: thiếu cờ này, iptables cố reverse DNS mọi địa chỉ và lệnh có thể treo rất lâu trên server không ra được Internet.-vbật cộtpktsvàbytes, phần quan trọng nhất của cả output.--line-numberscho số thứ tự, cần khi muốn xóa hay chèn đúng vị trí bằng-Dvà-I.
Cột pkts là thứ biến một danh sách rule không ai hiểu thành một danh sách có thể suy luận được. Mỗi struct ipt_entry mang theo counter sẵn (mục 4 bài kiến trúc), nên không cần bật gì thêm. Một rule ở 0 gói trên máy đã uptime vài tuần là rule chưa từng khớp lần nào, và đó là hai khả năng rất khác nhau: hoặc nó dư, hoặc nó sai điều kiện nên không bao giờ chạm tới. Dòng policy DROP ở đầu mỗi chain cũng đếm riêng, cho biết bao nhiêu gói rơi tới cuối chain mà không khớp rule nào.
Counter cộng dồn từ lúc rule được nạp, nên trên một máy chạy lâu con số chỉ nói "đã từng khớp", không nói "còn khớp". Muốn biết một rule có đang nhận traffic ngay lúc này thì đặt mốc lại bằng -Z:
# xem counter lần cuối rồi xoá về 0 trong cùng một lệnh
sudo iptables -L INPUT -nvZ
# ... đợi vài phút, hoặc tự generate traffic thử, rồi đọc lại
sudo iptables -L INPUT -nv
Theo man page, -Z nhận dạng -Z [chain [rulenum]], nên xoá được counter của cả ruleset, của một chain, hoặc của đúng một rule. Và nó dùng chung được với -L để in counter ngay trước khi xoá, nên không mất số liệu cũ. Đây là cách phân biệt rule chết hẳn với rule chỉ hiếm khi khớp.
Hai cách đọc khác bổ sung cho -nvL. Dạng -S in ra đúng cú pháp rule, copy dán lại được thành lệnh:
sudo iptables -S INPUT
# -P INPUT DROP
# -A INPUT -i lo -j ACCEPT
# -A INPUT -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT
Và iptables-save cho bức tranh tổng thể, vì nó xuất mọi table cùng lúc. Đây là chỗ nhiều người bỏ sót: iptables -nvL chỉ đọc table filter. Một server có NAT cấu hình sai mà chỉ xem -nvL thì không thấy gì cả, phải thêm -t nat.
💡 Hình dung như thế này:
iptables -nvLgiống mở đúng một sổ trong năm quyển sổ đặt cùng kệ. Quyển mặc định hay dùng nhất nên dễ quên bốn quyển kia vẫn đang có hiệu lực.iptables-savelà người lấy cả năm quyển xuống bàn một lượt.
3. Ruleset cơ bản cho một server
Bộ khung tối thiểu, đủ dùng thật cho một VPS đứng độc lập, viết sẵn theo định dạng iptables-restore:
*filter
:INPUT DROP [0:0]
:FORWARD DROP [0:0]
:OUTPUT ACCEPT [0:0]
# loopback phải accept trước tiên, thiếu dòng này rất nhiều service nội bộ gãy
-A INPUT -i lo -j ACCEPT
# connection đã xác lập hoặc liên quan, không đánh giá lại từ đầu
-A INPUT -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT
# gói không khớp state hợp lệ nào, loại ngay
-A INPUT -m conntrack --ctstate INVALID -j DROP
# icmp cần cho path MTU discovery và chẩn đoán, không nên drop toàn bộ
-A INPUT -p icmp --icmp-type echo-request -j ACCEPT
-A INPUT -p icmp --icmp-type destination-unreachable -j ACCEPT
-A INPUT -p icmp --icmp-type time-exceeded -j ACCEPT
-A INPUT -p tcp --dport 22 -j ACCEPT
-A INPUT -p tcp -m multiport --dports 80,443 -j ACCEPT
# mọi thứ còn lại: log rồi mới rơi về policy
-A INPUT -m limit --limit 5/minute -j LOG --log-prefix "ipt-input-drop: "
COMMIT
Thứ tự ở đây không phải tùy ý. Rule ct state đặt ngay sau lo là tối ưu tốc độ thật, vì phần lớn gói đi qua là traffic của kết nối đã mở, và đặt nó trên cùng thì chúng không phải đi qua từng điều kiện phía dưới. nft_do_chain của backend nft hay vòng duyệt blob của backend legacy đều đi đúng tuần tự đã khai, không tự sắp xếp lại (mục 5 bài kiến trúc).
Rule LOG cuối cùng có -m limit --limit 5/minute đi kèm, và đó là điều bắt buộc chứ không phải tùy chọn. LOG ghi ra kernel log mỗi lần khớp, nên một cổng bị scan liên tục có thể làm log ngốn hết I/O. Giới hạn tốc độ cho chính dòng log là cách dùng limit phổ biến nhất trong thực tế.
Điểm đắt nhất của ruleset trên nằm ở chỗ nó chỉ phục vụ IPv4. Policy cho IPv6 phải viết lại bằng ip6tables, thành một file thứ hai, một blob thứ hai, tự đồng bộ bằng tay. Chi phí "viết hai lần" ở mục 1 bài kiến trúc hiện ra đúng như vậy trên một server thật.
Và nó không phải chuyện copy rồi đổi tên lệnh. Nhóm rule ICMP phải viết khác hẳn, vì IPv6 dùng ICMPv6 cho những việc mà IPv4 giao cho ARP và cho phân mảnh, nên chặn sai là mất liên lạc ở tầng link. Bản IPv6 tương ứng, nạp bằng ip6tables-restore:
*filter
:INPUT DROP [0:0]
:FORWARD DROP [0:0]
:OUTPUT ACCEPT [0:0]
-A INPUT -i lo -j ACCEPT
-A INPUT -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT
-A INPUT -m conntrack --ctstate INVALID -j DROP
# Neighbor Discovery: IPv6 thay ARP bằng 4 loại message này.
# Thiếu chúng là mất phân giải địa chỉ tầng link, host coi như biến mất khỏi LAN.
-A INPUT -p ipv6-icmp --icmpv6-type neighbour-solicitation -j ACCEPT
-A INPUT -p ipv6-icmp --icmpv6-type neighbour-advertisement -j ACCEPT
-A INPUT -p ipv6-icmp --icmpv6-type router-solicitation -j ACCEPT
-A INPUT -p ipv6-icmp --icmpv6-type router-advertisement -j ACCEPT
# packet-too-big là bắt buộc với IPv6: router trên đường KHÔNG được phân mảnh hộ,
# chặn message này là TCP treo giữa phiên với MTU nhỏ hơn mặc định.
-A INPUT -p ipv6-icmp --icmpv6-type packet-too-big -j ACCEPT
-A INPUT -p ipv6-icmp --icmpv6-type destination-unreachable -j ACCEPT
-A INPUT -p ipv6-icmp --icmpv6-type time-exceeded -j ACCEPT
-A INPUT -p ipv6-icmp --icmpv6-type parameter-problem -j ACCEPT
-A INPUT -p ipv6-icmp --icmpv6-type echo-request -j ACCEPT
-A INPUT -p tcp --dport 22 -j ACCEPT
-A INPUT -p tcp -m multiport --dports 80,443 -j ACCEPT
COMMIT
Tên type lấy ở đâu cho chắc: chính công cụ in ra danh sách hợp lệ, không cần tra tài liệu ngoài.
ip6tables -p ipv6-icmp -h
# Output minh họa (rút gọn)
Valid ICMPv6 Types:
destination-unreachable
packet-too-big
time-exceeded (ttl-exceeded)
parameter-problem
echo-request (ping)
neighbour-solicitation (neighbor-solicitation)
neighbour-advertisement (neighbor-advertisement)
Dạng viết Anh Anh là tên chính (neighbour-), còn neighbor- là alias, cả hai đều nhận.
Diagram dưới đây tô đậm đúng hàng ICMP ở mỗi bên, vì đó là hàng duy nhất không copy sang được. Phần còn lại giống hệt nhau, và đó mới là chỗ mệt: giống tới mức dễ sửa một bên rồi quên bên kia.
4. Chặn SSH brute-force bằng recent và hashlimit
iptables có hai module khác nhau cho việc này, và chúng trả lời hai câu hỏi khác nhau. Chọn sai module là lý do nhiều ruleset chặn brute-force hoạt động không như mong đợi.
Module recent giữ một danh sách địa chỉ trong kernel và đếm số lần chạm. Cách dùng chuẩn là hai rule đi cặp, một rule ghi và một rule kiểm tra:
# rule ghi: mọi kết nối SSH mới đều ghi địa chỉ nguồn vào danh sách tên SSH
sudo iptables -A INPUT -p tcp --dport 22 -m conntrack --ctstate NEW \
-m recent --name SSH --set
# rule kiểm tra: drop nếu địa chỉ đó đã chạm từ 4 lần trở lên trong 60 giây
sudo iptables -A INPUT -p tcp --dport 22 -m conntrack --ctstate NEW \
-m recent --name SSH --update --seconds 60 --hitcount 4 -j DROP
Theo man page, --set thêm địa chỉ nguồn vào danh sách, và nếu đã có thì cập nhật entry sẵn có. --update giống --rcheck nhưng đồng thời làm mới mốc thời gian "last seen". --seconds thu hẹp match về khoảng thời gian, --hitcount thu hẹp về số gói đã nhận, và cả hai đều phải dùng kèm --rcheck hoặc --update. Mặc định danh sách khóa theo địa chỉ nguồn, đổi được bằng --rdest.
Thứ tự hai rule trên quan trọng. Rule --set đứng trước nên mọi kết nối đều được ghi, kể cả kết nối sẽ bị rule sau drop. Đặt ngược lại thì địa chỉ không bao giờ vào được danh sách.
Danh sách này đọc và sửa được trực tiếp từ userspace, điều mà hashlimit không có:
cat /proc/net/xt_recent/SSH
# src=203.0.113.45 ttl: 64 last_seen: 4294961234 oldest_pkt: 3 4294959100, 4294960200, 4294961234
(Output minh họa, cấu trúc và tên field đúng theo file thật, số liệu cụ thể chỉ mang tính ví dụ.)
Module hashlimit làm việc khác: nó giữ một token bucket cho mỗi khóa. Không có danh sách để đọc, chỉ có tốc độ được định hình:
sudo iptables -A INPUT -p tcp --dport 22 -m conntrack --ctstate NEW \
-m hashlimit --hashlimit-upto 3/minute --hashlimit-mode srcip \
--hashlimit-name ssh -j ACCEPT
sudo iptables -A INPUT -p tcp --dport 22 -m conntrack --ctstate NEW -j DROP
Man page yêu cầu bắt buộc có một trong --hashlimit-upto hoặc --hashlimit-above, và bắt buộc có --hashlimit-name. --hashlimit-mode nhận danh sách trong nhóm srcip, srcport, dstip, dstport, và chính nó quyết định "mỗi khóa" nghĩa là gì. --hashlimit-burst mặc định là 5.
Hai cảnh báo nằm ngay trong man page, đáng chép lại vì dễ bị bỏ qua. Thứ nhất, thiếu --hashlimit-mode thì hashlimit hoạt động y như limit, nhưng vẫn phải trả chi phí duy trì bảng hash. Thứ hai, nếu entry hết hạn thì giá trị burst cũng bị reset theo, nên burst không phải một hạn mức bền vững.
Đặt cạnh hai lựa chọn ở các bài khác trong loạt, bức tranh đầy đủ gồm bốn công cụ cho cùng một triệu chứng:
| Công cụ | Khóa theo | Trạng thái giữ ở đâu | Tín hiệu nó đọc được |
|---|---|---|---|
iptables recent | Địa chỉ nguồn, hoặc đích với --rdest | Danh sách trong kernel, đọc được ở /proc/net/xt_recent/ | Số lần mở kết nối TCP |
iptables hashlimit | srcip, srcport, dstip, dstport | Bảng hash trong kernel, không đọc được danh sách | Tốc độ mở kết nối TCP |
| nftables meter (mục 4) | Bất kỳ expression nào | Dynamic set, liệt kê được bằng nft list set | Tốc độ mở kết nối TCP |
| fail2ban (bài 1) | Những gì regex bắt được | Cơ sở dữ liệu trên đĩa | Nội dung log SSH |
Ba dòng đầu chạy trong kernel nên phản ứng tức thời, và cả ba đều có cùng một giới hạn: không dòng nào đọc được log SSH. Chúng không phân biệt được bốn lần gõ sai mật khẩu với bốn lần health check kết nối rồi ngắt. Đây đúng là giới hạn đã mổ xẻ ở mục 9 bài Fail2Ban. Trong thực tế hai nhóm không loại trừ nhau: đặt kernel rate limit làm tuyến đầu vì nó rẻ, rồi fail2ban làm tuyến sau để bắt đúng brute-force theo nghĩa ứng dụng.
5. NAT: masquerade và port forwarding
Masquerade là dạng SNAT động, dùng khi địa chỉ đi ra ngoài không cố định, ví dụ router nhận IP WAN qua DHCP:
sudo iptables -t nat -A POSTROUTING -o eth0 -j MASQUERADE
DNAT đưa traffic từ ngoài vào một service nội bộ:
sudo iptables -t nat -A PREROUTING -p tcp --dport 8080 \
-j DNAT --to-destination 192.168.1.10:80
Hai chain này không đổi chỗ được, và lý do nằm ở thang priority đã đọc ở mục 3 bài kiến trúc. Nửa DNAT của table nat chạy ở -100, trước khi kernel quyết định định tuyến, để định tuyến thấy đúng địa chỉ đích sau khi đã đổi. Nửa SNAT chạy ở 100, sau cùng, khi đã biết chắc gói ra interface nào. Đặt DNAT vào POSTROUTING thì cú pháp vẫn hợp lệ, lệnh vẫn chạy, và rule âm thầm không có tác dụng vì thời điểm đã qua.
Phần hay bị quên nhất là rule thứ ba. Table nat chỉ đổi địa chỉ, nó không cho phép gói đi qua. Với FORWARD đang ở policy DROP như mục 3, gói đã DNAT xong vẫn bị chặn ở table filter:
# không có dòng này thì DNAT ở trên vô nghĩa
sudo iptables -A FORWARD -d 192.168.1.10 -p tcp --dport 80 \
-m conntrack --ctstate NEW,RELATED,ESTABLISHED -j ACCEPT
Một chi tiết suy ra được từ mục 6 bài kiến trúc và rất hữu ích khi debug: counter trên rule NAT đếm số connection, không phải số gói, vì table nat chỉ được đánh giá cho gói đầu tiên. Một rule DNAT phục vụ hàng triệu gói mỗi ngày vẫn có thể hiện con số nhỏ, và đó là bình thường.
6. Rate limit: limit cho cả rule, hashlimit cho từng khóa
Hai module ở mục 4 cũng là hai lựa chọn cho rate limit nói chung, và điểm khác nhau quyết định là một bucket dùng chung hay một bucket cho mỗi khóa.
Module limit là token bucket cho chính rule đó, dùng chung cho mọi nguồn. Man page cho biết --limit mặc định 3/hour và --limit-burst mặc định 5. Mẫu dùng luôn là hai rule, một rule cho qua dưới ngưỡng và một rule drop phần dư:
# ping flood: tối đa 5 gói ICMP echo-request mỗi giây cho toàn bộ server
sudo iptables -A INPUT -p icmp --icmp-type echo-request \
-m limit --limit 5/second --limit-burst 10 -j ACCEPT
sudo iptables -A INPUT -p icmp --icmp-type echo-request -j DROP
--limit-burst là dung sai ngắn cho phép vượt tốc độ trung bình, và man page mô tả chính xác cơ chế: nó được nạp lại một đơn vị mỗi khi tốc độ chưa đạt ngưỡng, tối đa tới giá trị burst. Traffic thật hiếm khi đến đều tăm tắp theo nhịp giây, nên thiếu burst hợp lý dễ drop nhầm traffic hợp lệ ngay ở ngưỡng biên.
Nhược điểm của limit lộ ra khi cần công bằng giữa các nguồn: một địa chỉ ồn ào có thể tiêu hết hạn mức của cả rule, và những nguồn còn lại bị drop oan. Lúc đó cần --hashlimit-mode srcip, mỗi địa chỉ một bucket riêng:
sudo iptables -A INPUT -p icmp --icmp-type echo-request \
-m hashlimit --hashlimit-upto 5/second --hashlimit-mode srcip \
--hashlimit-name icmp-per-src -j ACCEPT
sudo iptables -A INPUT -p icmp --icmp-type echo-request -j DROP
Một chi tiết cú pháp đáng nhớ vì nó hay làm người mới bí: xt_limit không hỗ trợ phủ định. Không viết được ! -m limit --limit .... Man page chỉ thẳng cách thay thế là dùng -m hashlimit ! --hashlimit <rate> và bỏ hẳn --hashlimit-mode, lúc đó hashlimit hành xử như limit nhưng cho phép phủ định.
7. Danh sách lớn: ipset và match set
Ngôn ngữ của iptables không có khái niệm tập hợp, nên chặn vài nghìn địa chỉ bằng iptables thuần là vài nghìn rule, với cái giá đã phân tích ở mục 7 bài kiến trúc. Lời giải thực tế là ipset, một công cụ riêng với kernel subsystem riêng.
# tạo set, chọn kiểu theo dữ liệu sẽ chứa
sudo ipset create denylist hash:net
sudo ipset add denylist 198.51.100.0/24
sudo ipset add denylist 203.0.113.45
# iptables tham chiếu set bằng tên, qua match module set
sudo iptables -I INPUT 1 -m set --match-set denylist src -j DROP
# thêm bớt phần tử khi đang chạy, không đụng tới rule
sudo ipset add denylist 192.0.2.77
sudo ipset list denylist | head -8
Cầu nối giữa hai bên là một match extension bình thường tên xt_set, cùng loại kernel module với xt_conntrack hay xt_tcpudp ở mục 4 bài kiến trúc. Nó không chứa dữ liệu, chỉ biết cách hỏi ipset xem một khóa có thuộc set hay không.
Theo man page, --match-set <tên> <flag>[,<flag>...] nhận danh sách phân cách bằng dấu phẩy gồm src và dst, nhiều nhất sáu flag, và ý nghĩa phụ thuộc số chiều của set. Set một chiều như hash:ip thì src nghĩa là khớp địa chỉ nguồn. Set nhiều chiều thì thứ tự flag tương ứng thứ tự các chiều. Có thêm ! --update-counters nếu không muốn rule này cập nhật counter của phần tử khớp.
Cái giá của ipset là một vòng đời thứ hai phải tự quản. Ruleset iptables và nội dung set là hai thứ lưu riêng, khôi phục riêng:
sudo iptables-save > /etc/iptables/rules.v4
sudo ipset save > /etc/ipset.conf
Và thứ tự khôi phục lúc boot có ràng buộc thật: set phải tồn tại trước rule tham chiếu tới nó. Nạp ngược lại thì iptables-restore gặp dòng --match-set denylist src trỏ tới một set chưa có, báo lỗi, và vì mỗi COMMIT là một lần thay cả table nên toàn bộ table đó bị từ chối, không phải chỉ một dòng. Đây là lỗi boot khó đoán nếu không biết cơ chế ở mục 5 bài kiến trúc.
Cũng nên biết ipset nằm trong cùng danh sách deprecated của RHEL 9 với iptables-nft. Phía nftables thì set là thành phần hạng nhất của chính ngôn ngữ, nằm chung ruleset, thêm bớt phần tử runtime mà không cần công cụ thứ hai (mục 7 bài nftables).
8. Áp dụng an toàn mà không tự khóa khỏi SSH
Ba cờ của iptables-restore nên thuộc lòng, vì mặc định của nó không phải thứ nhiều người tưởng.
# -t: chỉ parse và dựng ruleset, KHÔNG commit. Chạy cái này trước mọi lần áp dụng
sudo iptables-restore -t < /etc/iptables/rules.v4
# -n: KHÔNG flush nội dung cũ của table, chỉ thêm vào
sudo iptables-restore -n < /etc/iptables/extra.rules
# -w: chờ xtables lock thay vì thoát ngay khi không lấy được
sudo iptables-restore -w 5 < /etc/iptables/rules.v4
Mặc định, iptables-restore flush sạch nội dung cũ của mọi table xuất hiện trong input. Cờ -n tắt hành vi đó. Nghĩa là một file chỉ chứa vài dòng bổ sung, nạp mà quên -n, sẽ xóa trắng phần còn lại của table đó. Còn -T <tên> giới hạn chỉ khôi phục đúng một table dù input có nhiều hơn.
Cờ -w tồn tại vì chu trình đọc ghi cả blob không atomic đầu cuối, đúng cơ chế ở mục 5 bài kiến trúc. Mặc định chương trình thoát nếu không lấy được exclusive lock, -w cho nó chờ.
Phần quan trọng nhất của mục này là bài toán tự cắt mất đường vào. Áp policy DROP qua SSH mà thiếu rule accept cổng 22 là mất kết nối tức thì, và chính session vừa mất là đường duy nhất để sửa lại. Khác với nftables, iptables có sẵn một công cụ giải đúng chuyện này, ship kèm bộ iptables:
sudo iptables-apply -t 30 /etc/iptables/rules.v4
Theo man page, iptables-apply nạp một file rulesfile đúng định dạng iptables-save, rồi hỏi lại người dùng xem thay đổi có ổn không. Nếu ruleset mới cắt mất kết nối đang dùng thì người dùng không thể trả lời được, và script tự khôi phục ruleset cũ sau khi hết timeout. Mặc định timeout là 10 giây, đổi bằng -t. Cờ -w <savefile> ghi lại ruleset đã áp thành công, dùng để dựng cơ chế lưu cấu hình tốt cuối cùng. Cờ -c <runcmd> cho phép chạy một lệnh cấu hình thay vì nạp file. Gọi dưới tên ip6tables-apply thì nó dùng ip6tables-save và ip6tables-restore.
💡 Hình dung như thế này:
iptables-applygiống một cánh cửa có bộ hẹn giờ tự mở lại. Đóng cửa xong phải bước ra bấm xác nhận trong vòng ba mươi giây, nếu không cửa tự mở trở lại trạng thái cũ. Người đứng ngoài không bấm được thì chính là bằng chứng cửa vừa khóa sai.
Quy trình đầy đủ cho một lần đổi ruleset trên server production: sao lưu bản đang chạy bằng iptables-save, sửa file, kiểm tra cú pháp bằng iptables-restore -t, rồi áp bằng iptables-apply với timeout đủ dài để kịp kiểm tra lại dịch vụ. Luôn giữ một đường vào thứ hai, console hoặc serial, cho lần đổi đầu tiên.
9. Checklist debug khi rule không có tác dụng
Đi đúng thứ tự dưới đây trước khi nghi ngờ có bug lạ, vì phần lớn trường hợp rơi vào một trong bảy câu hỏi này.
- Đang xem đúng table không?
iptables -nvLchỉ đọc tablefilter. Rule NAT nằm ở-t nat, rule sửa gói ở-t mangle, ruleNOTRACKở-t raw. Cách chắc nhất làiptables-save, vì nó xuất mọi table cùng lúc. - Counter có tăng không? Gắn
-vvào lệnh liệt kê, generate traffic thử, rồi xem lại. Rule ở 0 gói chưa từng khớp, khác hẳn với rule khớp nhưng làm sai việc, và hai nguyên nhân này cần hai hướng sửa hoàn toàn khác nhau. - Có rule nào phía trên bắt gói đó trước không? Thêm
--line-numbersrồi đọc từ trên xuống. MộtACCEPThayDROPvô tình khớp sớm sẽ khiến mọi rule phía dưới không bao giờ được xét tới, vì verdict terminal chấm dứt việc duyệt ngay (mục 5 bài kiến trúc). - Có chain tùy biến nào chen vào giữa không? Target không phải verdict mà là một tên chain thì phải đi theo chain đó mà đọc tiếp. Đây là chỗ Docker và kube-proxy làm người đọc bối rối nhất, xem mục 10.
- Backend nào đang chạy?
iptables -Vin ra(nf_tables)hoặc(legacy). Nếu làlegacy, kiểm tra thêmnft list ruleset, vì có thể đang tồn tại song song hai ruleset không thấy nhau và chỉ một trong hai được áp dụng. - Rule lọc theo
ct statemà không khớp? Đọc thẳng bảng conntrack thay vì suy luận từ rule. Xem mục dưới. - Cần theo dõi đúng một gói đi qua từng chain? Target
TRACEcho biết cả đường đi, không chỉ chỗ khớp nhưLOG. Nhưng nó có hai ràng buộc, xem mục dưới.
Đọc bảng conntrack. Mọi rule dùng --ctstate đều tra vào bảng này, nên khi rule không khớp như kỳ vọng thì câu hỏi thật là "entry trong bảng đang ở state nào". Công cụ nằm ở gói conntrack-tools:
# liệt kê flow đang được theo dõi, lọc được như rule
sudo conntrack -L -p tcp --dport 993
# Output minh họa (rút gọn)
tcp 6 431982 ESTABLISHED src=192.168.2.100 dst=123.59.27.117 sport=34846 dport=993 packets=169 bytes=14322 [ASSURED] mark=0 use=1
conntrack v1.4.6 (conntrack-tools): 2 flow entries have been shown.
(Output minh họa, cấu trúc và tên field đúng theo tài liệu conntrack-tools, số liệu cụ thể chỉ mang tính ví dụ.)
Cột thứ ba là timeout còn lại tính bằng giây, và [ASSURED] nghĩa là flow đã thấy traffic cả hai chiều nên sẽ không bị dọn sớm khi bảng đầy. Cần xem diễn biến ngay lúc nó xảy ra thì dùng -E, nó stream event NEW, UPDATE, DESTROY theo thời gian thực. Xoá một entry để buộc connection mở lại từ đầu thì dùng -D với cùng bộ filter.
Dùng TRACE cho đúng. Hai ràng buộc, cả hai đều nằm trong man page và đều hay bị bỏ qua. Thứ nhất, TRACE chỉ đặt được trong table raw:
sudo iptables -t raw -A PREROUTING -p icmp -j TRACE
Thứ hai, chỗ đọc kết quả phụ thuộc backend đang chạy, đúng cái đã kiểm ở câu 5. Trên iptables-nft, target này thành expression meta nftrace, kernel gửi event qua netlink, và chỗ đọc là xtables-monitor chứ không phải kernel log:
sudo xtables-monitor --trace
# Output minh họa
1 TRACE: 2 fc475095 raw:PREROUTING:rule:0x3:CONTINUE -4 -t raw -A PREROUTING -p icmp -j TRACE
2 PACKET: 0 fc475095 IN=lo LL=0x304 SRC=127.0.0.1 DST=127.0.0.1 LEN=84 TOS=0x0 TTL=64
3 TRACE: 2 fc475095 raw:PREROUTING:return:
4 TRACE: 2 fc475095 raw:PREROUTING:policy:ACCEPT
(Output minh họa, cấu trúc đúng theo ví dụ trong man page, số liệu cụ thể chỉ mang tính ví dụ.)
Đọc bốn dòng trên theo thứ tự: dòng 1 là rule đã khớp, dòng 2 là chính gói tin, dòng 3 là lúc gói rời chain, dòng 4 là verdict cuối từ policy. Trên iptables-legacy thì khác: phải nạp một logging backend như ipt_LOG hoặc nfnetlink_log, và output ra kernel log theo định dạng TRACE: tablename:chainname:type:rulenum, với type là rule, return hoặc policy. Tìm trace ở kernel log trên một máy backend nft là lý do phổ biến khiến TRACE trông như không hoạt động.
10. Docker và Kubernetes sinh ruleset của riêng chúng
Triệu chứng kinh điển: thêm -A INPUT -p tcp --dport 8080 -j DROP để chặn một port container đã publish, counter không tăng, và port vẫn mở.
Nguyên nhân có hai tầng, và tài liệu chính thức của Docker nói rõ cả hai. Tầng thứ nhất, Docker route traffic container ở table nat, nên gói bị chuyển hướng trước khi tới INPUT hay OUTPUT, khiến cấu hình firewall ở chain đó bị bỏ qua. Tầng thứ hai, traffic container đi qua FORWARD, và Docker chèn sẵn một chuỗi chain riêng ở đó.
Theo trang Docker with iptables, chain FORWARD nhảy vào DOCKER-USER, DOCKER-FORWARD và DOCKER-INGRESS. Vai trò từng chain:
| Chain | Table | Vai trò theo tài liệu Docker |
|---|---|---|
DOCKER-USER | filter | Chỗ dành cho rule của người dùng, được xử lý trước rule trong DOCKER-FORWARD và DOCKER. |
DOCKER-FORWARD | filter | Giai đoạn đầu cho network của Docker: accept gói thuộc connection đã xác lập, đẩy phần còn lại xuống các chain dưới. |
DOCKER, DOCKER-BRIDGE, DOCKER-INTERNAL | filter | Quyết định gói chưa established có được accept không, dựa trên cấu hình port forwarding của container đang chạy. |
DOCKER-CT | filter | Rule theo dõi connection cho từng bridge. |
DOCKER-INGRESS | filter | Rule cho Swarm networking, FORWARD nhảy vào trực tiếp. |
DOCKER | nat | Thực hiện masquerade và port mapping. |
Kết luận vận hành: rule thêm bằng -A FORWARD được xử lý sau rule của Docker, nên nó chỉ nhìn thấy những gói mà không chain nào phía trên đã quyết định. Chỗ đúng để đặt rule tự viết là DOCKER-USER.
# dung: chen vao DOCKER-USER, chay truoc moi chain cua Docker
sudo iptables -I DOCKER-USER -s 203.0.113.0/24 -j DROP
Còn một chỗ dễ vấp nữa ngay tại DOCKER-USER, và tài liệu Docker nói thẳng: khi gói tới chain này, chúng đã đi qua DNAT rồi. Nghĩa là các flag match chỉ thấy được địa chỉ và cổng nội bộ của container, không thấy địa chỉ và cổng gốc trong request. Muốn match theo thông tin gốc thì phải dùng extension conntrack, và tài liệu cũng cảnh báo việc đó có thể làm giảm hiệu năng. Ngoài ra Docker ghi rõ không nên sửa những rule do chính Docker tạo, vì chúng cần thiết để bridge network hoạt động đúng.
Một điểm đáng lưu ý nếu đang cân nhắc chuyển sang nftables: tài liệu Docker cho biết backend nftables không có chain DOCKER-USER, vì DOCKER-USER là cơ chế chèn rule vào chain FORWARD của table filter dành riêng cho backend iptables. Với nftables, rule của người dùng đặt trong table riêng. Backend nftables của Docker còn được đánh dấu experimental, và nó không tự bật IP forwarding cũng không tạo policy drop mặc định.
Phía Kubernetes, kube-proxy ở chế độ iptables là mặc định trên Linux ở Kubernetes 1.37, và tài liệu chính thức mô tả cách nó sinh rule: khi thấy một Service mới, nó cài một loạt rule iptables chuyển hướng từ virtual IP sang các rule khác định nghĩa theo từng Service, rồi rule theo Service trỏ tiếp tới rule cho từng backend endpoint, và rule theo endpoint dùng destination NAT để đưa traffic về backend. Với Service kiểu ClusterIP, gói được chuyển về backend mà không ghi lại địa chỉ IP của client; với NodePort hoặc qua load balancer thì địa chỉ client bị thay đổi.
Con số ở quy mô lớn là chỗ mọi thứ ở mục 5 bài kiến trúc hiện ra thành vấn đề thật. Tài liệu Kubernetes nói ở chế độ iptables, kube-proxy tạo vài rule cho mỗi Service và vài rule cho mỗi địa chỉ IP endpoint, nên một cluster có hàng chục nghìn Pod và Service nghĩa là hàng chục nghìn rule iptables, và kube-proxy có thể mất rất nhiều thời gian để cập nhật rule trong kernel khi Service hoặc EndpointSlice đổi. Đó đúng là cái giá của chu trình đọc cả blob, sửa, ghi đè cả blob. Kubernetes cũng có chế độ nftables, và tài liệu ghi rõ một phiên bản tương lai sẽ đổi mặc định sang chế độ đó.
Khi tiếp quản một server có Docker hoặc Kubernetes, thứ tự đọc nên đảo lại so với thói quen. Đọc iptables-save trước để thấy toàn bộ chain tùy biến, lần theo từng -j TÊNCHAIN để biết ai chèn gì vào đâu, rồi mới tới iptables -nvL. Làm ngược lại sẽ thấy một chain FORWARD gần như trống và kết luận sai rằng không có rule nào đang chạy.
Nguồn tham khảo
- Cú pháp module
recent(--name,--set,--rcheck,--update,--removeloại trừ nhau,--rsourcemặc định,--rdest,--seconds,--hitcountphải dùng kèm--rcheckhoặc--update) — 2026-10-23, man iptables-extensions(8), bản 1.8.13 - Cú pháp module
hashlimit: bắt buộc có--hashlimit-uptohoặc--hashlimit-abovecùng--hashlimit-name,--hashlimit-burstmặc định 5,--hashlimit-modenhậnsrcip/srcport/dstip/dstport, thiếu mode thì hành xử nhưlimitnhưng vẫn tốn chi phí bảng hash, entry hết hạn thì burst reset theo — 2026-10-23, man iptables-extensions(8), bản 1.8.13 - Module
limitlà token bucket,--limitmặc định3/hour,--limit-burstmặc định 5,xt_limitkhông hỗ trợ phủ định và phải thay bằng-m hashlimit ! --hashlimitbỏ--hashlimit-mode— 2026-10-23, man iptables-extensions(8), bản 1.8.13 - Match
set: cú pháp--match-set setname flag[,flag], tối đa sáu flagsrc/dst, ý nghĩa phụ thuộc số chiều của set, cờ--return-nomatchvà! --update-counters— 2026-10-23, man iptables-extensions(8), bản 1.8.13 - Cờ của
iptables-restore:-tchỉ parse không commit,-nkhông flush nội dung cũ (mặc định là flush),-wchờ xtables lock,-Tchỉ khôi phục một table,-ckhôi phục counter — 2026-10-23, maniptables-restore(8)bản 1.8.10 iptables-applynạp rulesfile rồi hỏi xác nhận, tự rollback về ruleset cũ sau timeout nếu ruleset mới cắt mất kết nối, timeout mặc định 10 giây, cờ-t/-w/-c, gọi dưới tênip6tables-applythì dùng công cụ IPv6 — 2026-10-23, maniptables-apply(8)bản 1.8.10- Gói
netfilter-persistentlà bộ nạp cấu hình netfilter theo kiến trúc plugin, góiiptables-persistentcung cấp pluginiptablesvàip6tablesvà phụ thuộc vàonetfilter-persistent— 2026-10-23, Debian package: iptables-persistent - Docker route traffic container ở table
natnên gói bị chuyển hướng trước khi tớiINPUTvàOUTPUT, backend nftables chọn qua optionfirewall-backendvà còn experimental — 2026-10-23, Docker docs: Packet filtering and firewalls - Danh sách chain Docker tạo (
DOCKER-USER,DOCKER-FORWARD,DOCKER,DOCKER-BRIDGE,DOCKER-INTERNAL,DOCKER-CT,DOCKER-INGRESS,DOCKERở tablenat),DOCKER-USERxử lý trướcDOCKER-FORWARDvàDOCKER, rule thêm vàoFORWARDxử lý sau rule của Docker, gói tớiDOCKER-USERđã qua DNAT, dùng extensionconntrackcó thể giảm hiệu năng, backend nftables không có chainDOCKER-USER— 2026-10-23, Docker docs: Docker with iptables kube-proxychế độiptableslà mặc định trên Linux ở Kubernetes 1.37, cách sinh rule theo Service và theo endpoint dùng destination NAT,ClusterIPkhông ghi lại IP client cònNodePortvà load balancer thì có, quy mô hàng chục nghìn rule làm việc cập nhật kernel chậm, và một phiên bản tương lai sẽ đổi mặc định sang chế độnftables— 2026-10-23, Kubernetes docs: Virtual IPs and Service Proxies- Cờ
-Z, --zero [chain [rulenum]]xoá counter của cả ruleset, một chain hoặc một rule, và dùng chung được với-Lđể in counter ngay trước khi xoá — 2026-10-23, man iptables(8), bản 1.8.13 - Target
TRACEchỉ đặt được trong tableraw, và trên backendiptables-nftđọc bằngxtables-monitor --tracevới định dạng output gồm dòngTRACE:cho rule,PACKET:cho gói tin, rồireturnvàpolicy— 2026-10-23, man iptables-extensions(8), bản 1.8.13, manxtables-monitor(8)bản 1.8.10 - Lệnh
conntrackcủa góiconntrack-tools:-Lliệt kê flow kèm filter,-Estream eventNEW/UPDATE/DESTROYtheo thời gian thực,-Dxoá entry khớp filter, và định dạng output gồm timeout còn lại cùng cờ[ASSURED]— 2026-10-23, conntrack-tools user manual - Danh sách tên type ICMPv6 hợp lệ (
packet-too-big,neighbour-solicitation,neighbour-advertisement,router-solicitation,router-advertisement,parameter-problem...), lấy từ chính công cụ bằngip6tables -p ipv6-icmp -h; dạngneighbour-là tên chính vàneighbor-là alias — 2026-10-23, man iptables-extensions(8), mục icmp6, bản 1.8.13 - Gói
ipsetvàiptables-nftdeprecated trên RHEL 9 — 2026-10-23, Red Hat solution 6739041