Skip to main content

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ó​

Đâ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
  • -n tắ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.
  • -v bật cột pkts và bytes, phần quan trọng nhất của cả output.
  • --line-numbers cho số thứ tự, cần khi muốn xóa hay chèn đúng vị trí bằng -D và -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.

Reading iptables -nvL: which column answers which questionChain INPUT (policy DROP 24 packets, 1440 bytes)num pkts bytes target prot opt in out source destination1 412 31284 ACCEPT all -- lo * 0.0.0.0/0 0.0.0.0/02 8821 1204K ACCEPT all -- * * 0.0.0.0/0 0.0.0.0/0 ctstate RELATED,ESTABLISHED4 0 0 ACCEPT tcp -- * * 0.0.0.0/0 0.0.0.0/0 tcp dpt:443numonly shown with--line-numbers, and it isthe index -D and -I takepkts and byteshas this rule evermatched at all, zero afterweeks means dead or wrongtargeta verdict, or the name ofa custom chain that has tobe followed separatelythe trailing textprinted by each extensionmodule, here ctstate fromconntrack and tcp dptThis command shows the filter table and nothing elsenat, mangle and raw each need their own -t, so a missing NAT rule stays invisible until -t nat is passediptables-save dumps every table at once, which makes it the safer way to survey an unfamiliar machineRule 4 above has never matched: either it is redundant, or its condition is wrong

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 -nvL giố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-save là 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.

One policy, two rulesets: what separate IPv4 and IPv6 tools costiptables, table filter, chain INPUT-A INPUT -i lo -j ACCEPT--ctstate RELATED,ESTABLISHED -j ACCEPT--ctstate INVALID -j DROP-p icmp --icmp-type echo-request-p tcp --dport 22 -j ACCEPT-P INPUT DROPone blob, replaced whole on every changesaved and restored by the iptables pluginof netfilter-persistentip6tables, table filter, chain INPUT-A INPUT -i lo -j ACCEPT--ctstate RELATED,ESTABLISHED -j ACCEPT--ctstate INVALID -j DROP-p icmpv6 --icmpv6-type echo-requestplus neighbour solicit and advert, or the link breaks-p tcp --dport 22 -j ACCEPT-P INPUT DROPa second blob, in a second table modulekept in step with the first one by handand saved by a second pluginx 2two toolstwo filestwo blobsThe highlighted rows are the ones that cannot be copied acrossnftables folds both sides into one inet family, so the same policy is written once instead of twice

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 theoTrạng thái giữ ở đâuTín hiệu nó đọc được
iptables recentĐịa chỉ nguồn, hoặc đích với --rdestDanh sách trong kernel, đọc được ở /proc/net/xt_recent/Số lần mở kết nối TCP
iptables hashlimitsrcip, srcport, dstip, dstportBảng hash trong kernel, không đọc được danh sáchTốc độ mở kết nối TCP
nftables meter (mục 4)Bất kỳ expression nàoDynamic set, liệt kê được bằng nft list setTốc độ mở kết nối TCP
fail2ban (bài 1)Những gì regex bắt đượcCơ sở dữ liệu trên đĩaNộ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.

Four tools, one symptom, and the signal each one can actually seeiptables recentKEYS ONthe source addressor dest with --rdestSTATEan explicit list,readable under /procSIGNALhow many TCPconnection attemptscounts hits, then bansiptables hashlimitKEYS ONsrcip, srcport,dstip or dstportSTATEa token bucket perkey, no list to readSIGNALthe rate of new TCPconnectionsshapes a rate, never bansnftables meterKEYS ONany expression thelanguage can buildSTATEa dynamic set, listedby nft list setSIGNALthe rate of new TCPconnectionssame speed, cleaner syntaxfail2banKEYS ONwhatever the regexmanages to captureSTATEa database on disk,surviving restartsSIGNALthe SSH log itself,line by linereads meaning, not rateThe first three never open the SSH log, so none of them can separate a wrong password from a health checkwhich is why a kernel rate limit in front and a log reader behind it are complements, not alternativesPick recent when the policy is stated in hits, pick hashlimit when it is stated in rate

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.

Three rules, three chains, two tables, and one that gets forgottentable nat, PREROUTING-p tcp --dport 8080-j DNAT--to-destination 192.168.1.10:80table filter, FORWARD-d 192.168.1.10 -p tcp--dport 80 -j ACCEPTthe rule people leave outtable nat, POSTROUTING-o eth0 -j MASQUERADEdynamic SNAT, for a WANaddress that is not fixedpriority -100before the routing decision,so routing sees the new targetpriority 0with FORWARD at policy DROPthe translated packet still diespriority 100last of all, once the exitinterface is finally knownThree ways this goes wrong, all of them silentDNAT placed in POSTROUTING parses fine and does nothing, because the routing decision already happeneda nat table on its own forwards nothing, the filter table still has to let the translated packet throughand iptables -nvL hides all of this, because it reads the filter table and never the nat oneCounters on a nat rule count connections, not packets, so a small number there is normal

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.

limit keeps one bucket for the rule, hashlimit keeps one per key-m limit --limit 5/second10.0.0.110.0.0.210.0.0.3one shared bucketburst 10, refilled oneunit per spare tickone noisy source can spend the whole budgetand the other two get dropped for its sakecheapest option, and the usual choice forrate limiting a LOG rule rather than traffic--hashlimit-mode srcip10.0.0.110.0.0.210.0.0.3its own bucketits own bucketits own bucketno source can affect another sourceat the price of keeping a hash tabledrop --hashlimit-mode and it behaves likelimit, yet still pays the housekeepingxt_limit cannot be negated at all, so a not-this-rate match has to go through hashlimitAnd a hashlimit entry that expires resets its burst too, so burst is not a durable allowance

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).

One rule inside the blob, one set outside it, two things to back upthe iptables side-m set --match-set denylist src -j DROPone single entry in the blob, served by xt_setthe ipset sideipset denylist, type hash:netits own kernel subsystem, its own admin commandby nameiptables-save to rules.v4restored at boot by the iptables pluginof netfilter-persistentipset save to its own filerestored by a separate unit or scriptthat nothing ties to the one on the leftBoot order is a real constraint: the set has to exist before the rule that names itload them the other way round and iptables-restore fails on that line, and because each COMMIT replaces a whole table,the entire table is rejected rather than just the one rule

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-apply giố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.

  1. Đang xem đúng table không? iptables -nvL chỉ đọc table filter. Rule NAT nằm ở -t nat, rule sửa gói ở -t mangle, rule NOTRACK ở -t raw. Cách chắc nhất là iptables-save, vì nó xuất mọi table cùng lúc.
  2. Counter có tăng không? Gắn -v và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.
  3. Có rule nào phía trên bắt gói đó trước không? Thêm --line-numbers rồi đọc từ trên xuống. Một ACCEPT hay DROP vô 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).
  4. 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.
  5. Backend nào đang chạy? iptables -V in ra (nf_tables) hoặc (legacy). Nếu là legacy, kiểm tra thêm nft 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.
  6. Rule lọc theo ct state mà không khớp? Đọc thẳng bảng conntrack thay vì suy luận từ rule. Xem mục dưới.
  7. Cần theo dõi đúng một gói đi qua từng chain? Target TRACE cho 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:

ChainTableVai trò theo tài liệu Docker
DOCKER-USERfilterChỗ dành cho rule của người dùng, được xử lý trước rule trong DOCKER-FORWARD và DOCKER.
DOCKER-FORWARDfilterGiai đ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-INTERNALfilterQuyế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-CTfilterRule theo dõi connection cho từng bridge.
DOCKER-INGRESSfilterRule cho Swarm networking, FORWARD nhảy vào trực tiếp.
DOCKERnatThự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ế độ đó.

Why a rule appended to FORWARD never sees container traffictable filter, chain FORWARDDOCKER-USERthe place for your own rules, evaluated before all of Docker'sDOCKER-FORWARDaccepts established connections, passes the rest further downDOCKER · DOCKER-BRIDGE · DOCKER-INTERNALaccept or reject by the port forwarding config of running containersyour rule, appended with -A FORWARDreached only by traffic that nothing above already decidedalready past DNATpackets arriving here havebeen translated alreadyso match flags only see thecontainer internal addressmatching the original needsthe conntrack extensionDocker also routes container traffic in the nat table, so the packets never reach INPUT at allThe nftables backend has no DOCKER-USER chain: rules go into a separate table instead

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, --remove loại trừ nhau, --rsource mặc định, --rdest, --seconds, --hitcount phải dùng kèm --rcheck hoặ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-upto hoặc --hashlimit-above cùng --hashlimit-name, --hashlimit-burst mặc định 5, --hashlimit-mode nhận srcip/srcport/dstip/dstport, thiếu mode thì hành xử như limit như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 limit là token bucket, --limit mặc định 3/hour, --limit-burst mặc định 5, xt_limit không hỗ trợ phủ định và phải thay bằng -m hashlimit ! --hashlimit bỏ --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 flag src/dst, ý nghĩa phụ thuộc số chiều của set, cờ --return-nomatch và ! --update-counters — 2026-10-23, man iptables-extensions(8), bản 1.8.13
  • Cờ của iptables-restore: -t chỉ parse không commit, -n không flush nội dung cũ (mặc định là flush), -w chờ xtables lock, -T chỉ khôi phục một table, -c khôi phục counter — 2026-10-23, man iptables-restore(8) bản 1.8.10
  • iptables-apply nạ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ên ip6tables-apply thì dùng công cụ IPv6 — 2026-10-23, man iptables-apply(8) bản 1.8.10
  • Gói netfilter-persistent là bộ nạp cấu hình netfilter theo kiến trúc plugin, gói iptables-persistent cung cấp plugin iptables và ip6tables và phụ thuộc vào netfilter-persistent — 2026-10-23, Debian package: iptables-persistent
  • Docker route traffic container ở table nat nên gói bị chuyển hướng trước khi tới INPUT và OUTPUT, backend nftables chọn qua option firewall-backend và 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 ở table nat), DOCKER-USER xử lý trước DOCKER-FORWARD và DOCKER, rule thêm vào FORWARD xử lý sau rule của Docker, gói tới DOCKER-USER đã qua DNAT, dùng extension conntrack có thể giảm hiệu năng, backend nftables không có chain DOCKER-USER — 2026-10-23, Docker docs: Docker with iptables
  • kube-proxy chế độ iptables là mặc định trên Linux ở Kubernetes 1.37, cách sinh rule theo Service và theo endpoint dùng destination NAT, ClusterIP không ghi lại IP client còn NodePort và 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 TRACE chỉ đặt được trong table raw, và trên backend iptables-nft đọc bằng xtables-monitor --trace với định dạng output gồm dòng TRACE: cho rule, PACKET: cho gói tin, rồi return và policy — 2026-10-23, man iptables-extensions(8), bản 1.8.13, man xtables-monitor(8) bản 1.8.10
  • Lệnh conntrack của gói conntrack-tools: -L liệt kê flow kèm filter, -E stream event NEW/UPDATE/DESTROY theo thời gian thực, -D xoá 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ằng ip6tables -p ipv6-icmp -h; dạng neighbour- 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 ipset và iptables-nft deprecated trên RHEL 9 — 2026-10-23, Red Hat solution 6739041