Przejdź do treści
Bezpieczeństwo·~27 min czytania

Z iptables na nftables na Debianie 13: praktyczne tłumaczenie reguł

Od automatycznego tłumaczenia pojedynczej reguły, przez słownik poleceń, po gotowy plik konfiguracyjny wdrożony z planem wycofania.

Autor: Zespół LinuxLab

W LinuxLab firewall migrujemy zawsze w tej samej kolejności: najpierw zrzut stanu obecnego, potem tłumaczenie reguł, na końcu wdrożenie z automatycznym wycofaniem. Reguła, która odcina zdalny dostęp, kosztuje więcej niż tydzień pracy nad optymalizacją, dlatego każdą zmianę zakładamy z góry jako odwracalną.

W tym artykule pokazujemy przejście z iptables na nftables na Debianie 13 (Trixie) na konkretnych poleceniach. Zaczynamy od tego, co realnie działa w jądrze po świeżej instalacji, następnie tłumaczymy reguły - automatycznie i ręcznie - i budujemy docelowy /etc/nftables.conf. Osobno omawiamy trzy rzeczy, które najczęściej zaskakują po latach pracy z iptables: brak liczników w domyślnym wyjściu, to, że accept nie kończy przetwarzania pakietu, oraz działanie reguły stanu połączeń, bez której serwer traci możliwość korzystania z sieci. Całość spinamy kompletną polityką filtrowania serwera WWW, przepisaną z iptables na nftables reguła po regule. Wszystkie wyniki poleceń w tekście pochodzą z rzeczywistego uruchomienia narzędzi w wersjach iptables 1.8.11 i nftables 1.1.6.

Spis treści

  1. Co masz w Debianie 13
  2. Inwentaryzacja przed zmianą
  3. Automatyczne tłumaczenie
  4. Słownik poleceń
  5. Co robi reguła ct state
  6. Rodziny, łańcuchy, priorytety
  7. Kompletna polityka w obu dialektach
  8. Gdzie się podziały liczniki
  9. Zbiory zamiast setek reguł
  10. Docelowy nftables.conf
  11. Wdrożenie bez odcięcia SSH
  12. Docker, fail2ban i ufw
  13. Checklista migracji

Co w Debianie 13 filtruje pakiety, zanim cokolwiek zmienimy

Migrację zamawia się zwykle słowami „mamy iptables, przejdźmy na nftables". Na Debianie od kilku wydań polecenie iptables nie rozmawia już jednak ze starym silnikiem jądra. Jest dowiązaniem do iptables-nft, czyli warstwy zgodności, która przyjmuje składnię iptables, a reguły zapisuje do podsystemu nftables. Sprawdza to jedno polecenie:

iptables --version

Wynik iptables v1.8.11 (nf_tables) oznacza warstwę zgodności, czyli reguły trafiające do nftables. Wynik z dopiskiem (legacy) oznacza stary silnik. Który wariant obowiązuje w systemie, pokazuje mechanizm alternatyw Debiana:

update-alternatives --display iptables
iptables - tryb auto
najlepszą wersją dowiązania jest "/usr/sbin/iptables-nft".
  dowiązanie obecnie wskazuje na /usr/sbin/iptables-nft
/usr/sbin/iptables-legacy - priorytet 10
/usr/sbin/iptables-nft - priorytet 20

W typowej instalacji Debiana 13 migracja nie polega więc na wymianie silnika filtrowania, bo ten już działa. Polega na zmianie narzędzia i sposobu opisu reguł. Widać to od razu, gdy dopiszemy regułę przez iptables, a następnie obejrzymy ją narzędziem nft:

iptables -A INPUT -p tcp --dport 28 -j ACCEPT
nft list ruleset
table ip filter {
	chain INPUT {
		type filter hook input priority filter; policy accept;
		tcp dport 28 counter packets 0 bytes 0 accept
	}
}

Jest to ta sama reguła, pokazana natywnym językiem nftables. Warstwa zgodności utworzyła tabelę ip filter i łańcuch INPUT, żeby odwzorować układ znany z iptables. Skoro reguły i tak lądują w nftables, sens migracji leży gdzie indziej niż w samym silniku: składnia iptables nie daje dostępu do zbiorów i map, do wspólnej rodziny inet obsługującej IPv4 i IPv6 jedną regułą, do komentarzy przy regułach ani do atomowego przeładowania całego pliku konfiguracyjnego.

Dalszą część opisujemy na konkretnym punkcie wyjścia, ponieważ od jego rozpoznania zależy reszta pracy: serwer z Debianem 13, aplikacja WWW na portach 80 i 443, SSH przeniesiony na port 28 oraz firewall opisany kilkudziesięcioma regułami iptables narosłymi przez lata i odtwarzanymi przy starcie przez iptables-persistent.

Dwa warianty iptables nie mogą działać równolegle. Jeżeli część reguł powstała przez iptables-legacy, a część przez iptables-nft, w jądrze istnieją dwa niezależne zestawy. nft list ruleset pokazuje tylko drugi z nich, a pakiet i tak przechodzi przez oba. Przed migracją sprawdzamy iptables-legacy -L -n i upewniamy się, że nic tam nie zostało.

Zrzut stanu i ustalenie, kto ładuje reguły po restarcie

Stan zapisujemy w obu formatach. Zrzut iptables posłuży jako materiał do tłumaczenia, zrzut nftables jako punkt wycofania:

iptables-save  > /root/backup-$(date +%F).v4
ip6tables-save > /root/backup-$(date +%F).v6
nft list ruleset > /root/backup-$(date +%F).nft

Druga część inwentaryzacji bywa ważniejsza od pierwszej i polega na ustaleniu, który mechanizm odtwarza reguły po restarcie. Bez tego po pierwszym reboocie wracają stare reguły, a migracja cofa się sama. W Debianie odpowiada za to kilka różnych usług:

systemctl is-enabled nftables netfilter-persistent ufw firewalld fail2ban docker 2>&1
ls -la /etc/iptables/ /etc/nftables.conf 2>&1

Pakiet iptables-persistent trzyma reguły w /etc/iptables/rules.v4 oraz rules.v6 i odtwarza je usługą netfilter-persistent. Pakiet nftables ładuje przy starcie plik /etc/nftables.conf przez usługę nftables.service. Celem migracji jest stan, w którym działa dokładnie jeden z tych mechanizmów. Osobną kategorią są nakładki ufw i firewalld, zarządzające regułami po swojemu; jeżeli któraś jest włączona, decyzja o tym, co zostaje, zapada przed dopisaniem czegokolwiek ręcznie.

Automatyczne tłumaczenie i jego granice

Pakiet iptables zawiera narzędzie zamieniające polecenie iptables na odpowiednik nftables, bez wprowadzania zmian w systemie. Do istniejącego polecenia wystarczy dopisać przyrostek -translate. Dla reguły otwierającej port 28, czyli w opisywanym przypadku SSH:

iptables-translate -A INPUT -p tcp -m tcp --dport 28 -j ACCEPT
nft 'add rule ip filter INPUT tcp dport 28 counter accept'

Zmian względem oryginału jest kilka. Moduł -m tcp zniknął, ponieważ w nftables wyrażenie tcp dport samo oznacza dopasowanie protokołu. Cel -j ACCEPT stał się werdyktem accept na końcu reguły. Rodzina ip, tabela filter i łańcuch INPUT zostały wypisane wprost, ponieważ w nftables nic z tego nie istnieje domyślnie. Doszło też słowo counter, którego w poleceniu nie było; wyjaśnia to osobna sekcja o licznikach.

Całą konfigurację tłumaczy jeden przebieg na pliku ze zrzutu iptables-save:

iptables-restore-translate -f /root/backup-2026-08-10.v4
# Translated by iptables-restore-translate v1.8.11
add table ip filter
add chain ip filter INPUT { type filter hook input priority 0; policy drop; }
add chain ip filter FORWARD { type filter hook forward priority 0; policy drop; }
add chain ip filter OUTPUT { type filter hook output priority 0; policy accept; }
add rule ip filter INPUT iifname "lo" counter accept
add rule ip filter INPUT ct state related,established counter accept
add rule ip filter INPUT tcp dport 28 counter accept
add rule ip filter INPUT ip protocol tcp tcp dport { 80, 443 } counter accept
add rule ip filter INPUT icmp type echo-request counter accept
add rule ip filter INPUT counter log prefix "drop-in "

Wersja plikowa obsługuje rzeczy, których wersja jednoregułowa nie przyjmuje - na przykład polityki łańcuchów, zapisane w zrzucie jako :INPUT DROP, a w wyniku wchodzące do definicji łańcucha. Próba przetłumaczenia samej polityki kończy się komunikatem:

iptables-translate -P INPUT DROP
Translation not implemented

Podobnie wyglądają moduły bez bezpośredniego odpowiednika. Reguła z -m recent nie zostaje przetłumaczona; narzędzie zwraca ją jako komentarz i zostawia decyzję człowiekowi:

iptables-translate -A INPUT -p tcp --dport 22 -m state --state NEW -m recent --set
nft # -A INPUT -p tcp --dport 22 -m state --state NEW -m recent --set

Wynik tłumaczenia traktujemy więc jako materiał wyjściowy, a nie jako docelowy plik konfiguracyjny. Jest to przekład dosłowny: zachowuje podział na rodziny ip i ip6, wielkie litery w nazwach łańcuchów oraz kolejność reguł jeden do jednego. Działa poprawnie, ale nie korzysta z niczego, co nftables oferuje ponad iptables.

To samo zadanie w obu narzędziach

Kontakt z firewallem zaczyna się zwykle od podglądu reguł:

# iptables
iptables -L -n -v

# nftables
nft list ruleset                       # wszystko, wszystkie rodziny
nft list table inet filter             # jedna tabela
nft -a list chain inet filter input    # z uchwytami reguł
nft -s list ruleset                    # bez liczników, do zapisu w repozytorium

Przełączniki -n i -v nie mają odpowiedników, ponieważ są zbędne. nft nigdy nie rozwiązuje adresów na nazwy, więc -n opisuje zachowanie domyślne. Zamiast -v obowiązuje zasada, że wyjście nft list ruleset jest jednocześnie poprawnym plikiem wejściowym dla nft -f, a więc pokazuje pełną treść reguł, a nie ich skrót. Przydają się natomiast dwa inne przełączniki. Pierwszy, -a, dopisuje uchwyty (handles), czyli numery potrzebne do usuwania pojedynczych reguł. Drugi, -s, pomija liczniki, dzięki czemu ten sam ruleset zrzucony dwukrotnie daje identyczny plik i nadaje się do porównywania w systemie kontroli wersji.

Drugim odruchem jest dopisanie reguły otwierającej port:

# iptables
iptables -A INPUT -p tcp -m tcp --dport 28 -j ACCEPT

# nftables
nft add rule inet filter input tcp dport 28 accept

Na tym poleceniu potykają się najczęściej osoby przyzwyczajone do iptables, ponieważ trzeba podać rodzinę (inet), tabelę (filter) i łańcuch (input) - w nftables żadne z nich nie jest wbudowane. Nazwy pozostają dowolne, filter i input to konwencja, nie wymóg. Tabela i łańcuch muszą przy tym istnieć wcześniej, w przeciwnym razie polecenie kończy się błędem. Reszta czyta się niemal tak samo jak w iptables, bez modułu -m tcp i z werdyktem zapisanym na końcu bez -j.

Pozostałe odpowiedniki zebraliśmy w tabeli. Wszystkie polecenia nft zapisano w rodzinie inet, obsługującej jednocześnie IPv4 i IPv6.

iptablesnftables
iptables -L -n -vnft list ruleset
iptables -Snft -s list ruleset
iptables -A INPUT … -j ACCEPTnft add rule inet filter input … accept
iptables -I INPUT 1 …nft insert rule inet filter input …
iptables -D INPUT 3nft delete rule inet filter input handle 15
iptables -F INPUTnft flush chain inet filter input
iptables -P INPUT DROPpolicy drop; w definicji łańcucha
iptables -N moj_lancuchnft add chain inet filter moj_lancuch
-j moj_lancuchjump moj_lancuch lub goto moj_lancuch
-p tcp --dport 28tcp dport 28
-m multiport --dports 80,443tcp dport { 80, 443 }
-s 10.0.0.0/8ip saddr 10.0.0.0/8
-i eth0 / -o eth0iifname "eth0" / oifname "eth0"
-m conntrack --ctstate ESTABLISHED,RELATEDct state established,related
-m limit --limit 10/secondlimit rate 10/second
-j LOG --log-prefix "drop-in "log prefix "drop-in " level info
-j REJECT --reject-with tcp-resetreject with tcp reset
-t nat -A POSTROUTING -j MASQUERADEłańcuch type nat hook postrouting + masquerade
-m recent / -m hashlimitzbiór dynamiczny z timeout
iptables i ip6tables osobnojedna reguła w rodzinie inet
iptables-save / iptables-restorenft list ruleset > plik / nft -f plik

Osobnego komentarza wymaga usuwanie reguły. W iptables kasuje się ją po numerze porządkowym, który zmienia się przy każdej modyfikacji listy. W nftables każda reguła ma trwały uchwyt, niezależny od pozycji. Najpierw go odczytujemy, a potem używamy:

nft -a list chain inet filter input
chain input { # handle 1
	type filter hook input priority filter; policy drop;
	ct state established,related accept # handle 6
	iif "lo" accept # handle 8
	tcp dport { 80, 443 } accept # handle 15
}
nft delete rule inet filter input handle 15

Co robi reguła ct state, czyli conntrack bez skrótów

Wiersz z -m conntrack --ctstate ESTABLISHED,RELATED pojawia się niemal w każdej konfiguracji iptables, zwykle jako pierwsza reguła łańcucha, i bywa przepisywany bez zastanowienia. Warto rozłożyć go na części, ponieważ od niego zależy, czy serwer w ogóle będzie potrafił korzystać z sieci.

Jądro Linuksa prowadzi osobny mechanizm zwany conntrack, od connection tracking, czyli śledzenia połączeń. Jest to tablica trwających połączeń: przy pierwszym pakiecie nowego połączenia jądro zapisuje w niej protokół, adres źródłowy i docelowy oraz oba porty. Dzięki temu potrafi później rozpoznać pakiety należące do tego samego połączenia, również te płynące w przeciwną stronę, mimo że mają zamienione miejscami adresy i porty. Sam mechanizm działa niezależnie od firewalla; reguła -m conntrack jedynie odpytuje jego wynik.

Każdy pakiet zostaje przez conntrack zaklasyfikowany do jednego ze stanów:

  • NEW - pierwszy pakiet połączenia, którego jądro jeszcze nie zna;
  • ESTABLISHED - pakiet należący do połączenia już zapisanego w tablicy, w dowolnym z dwóch kierunków;
  • RELATED - pakiet rozpoczynający nowe połączenie, ale logicznie powiązany z już istniejącym; najczęściej komunikat ICMP o błędzie dotyczącym trwającego połączenia, a także kanał danych FTP;
  • INVALID - pakiet, którego nie da się przypisać do żadnego połączenia: spóźniony, spoza okna TCP albo taki, którego wpis w tablicy zdążył wygasnąć.

Reguła -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT przepuszcza więc pakiety należące do połączeń już trwających oraz z nimi powiązane, nie zajmując stanowiska wobec pakietów rozpoczynających nowe połączenia. Tymi zajmują się dalsze reguły, otwierające konkretne porty.

Powód, dla którego bez niej konfiguracja nie działa, jest następujący. Reguły typu „otwórz port 443" dopasowują pakiety przychodzące do usługi. Gdy jednak to serwer nawiązuje połączenie na zewnątrz - pobiera pakiety podczas apt update, odpytuje DNS, wysyła pocztę, łączy się z API - odpowiedź wraca z portu usługi zdalnej na losowy port lokalny, przydzielony na czas tego jednego połączenia. Żadna reguła otwierająca stały port nigdy takiego pakietu nie dopasuje, a przy polityce DROP zostanie on odrzucony i połączenie zawiśnie.

Widać to bezpośrednio na łańcuchu z polityką drop, w którym jedyną regułą jest otwarcie portu usługi:

nft add rule inet demo input tcp dport 9999 accept
curl http://127.0.0.1:9999/
# brak odpowiedzi - połączenie wisi do czasu przekroczenia limitu

Port jest otwarty, a mimo to nic nie działa. Zapytanie dociera do usługi, ale jej odpowiedź wychodzi z portu 9999 na losowy port klienta i jako pakiet przychodzący nie pasuje do warunku tcp dport 9999. Trafia więc na politykę drop. Dopisanie reguły stanu na początku łańcucha kończy problem:

nft insert rule inet demo input ct state established,related accept
curl http://127.0.0.1:9999/
# HTTP 200

Drugim powodem jest kolejność i koszt. Ruch należący do trwających połączeń stanowi zdecydowaną większość pakietów. Reguła stanu ustawiona na początku łańcucha rozstrzyga ich los od razu, dzięki czemu nie muszą być porównywane z całą resztą warunków.

Stan RELATED bywa pomijany, a jego brak daje mylące objawy. Bez tego stanu odrzucane są komunikaty ICMP o błędach dotyczących trwających połączeń, w tym „fragmentation needed", którym router informuje o zbyt dużym rozmiarze pakietu. Objaw jest wtedy mylący: krótkie połączenia działają, a większe transfery zatrzymują się w połowie bez komunikatu błędu.

W konfiguracjach iptables spotyka się dwa zapisy tego samego warunku. Starszy moduł -m state --state, zachowany dla zgodności wstecznej, oraz nowszy -m conntrack --ctstate. W nftables rozróżnienie znika, ponieważ oba tłumaczą się na jedno wyrażenie:

ct state established,related accept

Rodziny, łańcuchy i priorytety

W iptables struktura była dana z góry. Istniały tabele filter, nat i mangle, a w nich łańcuchy INPUT, FORWARD i OUTPUT. Nie trzeba było ich tworzyć ani wiedzieć, skąd się wzięły, ponieważ reguły dopisywało się do gotowych szuflad. W nftables żadna z tych rzeczy nie istnieje. Tabele i łańcuchy zakłada administrator, nadaje im dowolne nazwy i sam wskazuje, w którym momencie mają oglądać pakiet.

Kluczowe jest tu pojęcie, które w iptables pozostawało ukryte: punkt zaczepienia, w dokumentacji nazywany hookiem. Pakiet nie trafia do systemu w jednej chwili, tylko przechodzi przez kilka etapów obsługi w jądrze. Najpierw pojawia się na karcie sieciowej i zanim cokolwiek się z nim stanie, jądro pozwala mu się przyjrzeć; jest to etap prerouting. Potem zapada decyzja routingu, rozstrzygająca, czy pakiet jest adresowany do tej maszyny, czy tylko przez nią przechodzi. W pierwszym przypadku trafia na etap input, w drugim na forward. Ruch wychodzący z samego serwera ma własne etapy, output oraz postrouting.

Deklaracja łańcucha wskazuje jeden z tych etapów. Zapis hook input znaczy tyle, że łańcuch ma oglądać pakiety w chwili, gdy jądro ustaliło już, iż są adresowane do tego serwera. W iptables to samo przychodziło pod postacią gotowego łańcucha o nazwie INPUT, w którym nazwa i etap były ze sobą zrośnięte. W nftables zostały rozdzielone: nazwa łańcucha jest kwestią konwencji, a etap deklaruje się osobno.

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

W tej deklaracji mieszczą się cztery informacje. type filter określa, że łańcuch służy do filtrowania; istnieją jeszcze typy nat i route, przeznaczone do innych zadań. hook input wskazuje etap. priority filter ustala kolejność względem innych łańcuchów przypiętych do tego samego etapu. policy drop określa los pakietu, którego nie dopasowała żadna reguła w tym łańcuchu.

Łańcuch może istnieć również bez tych deklaracji i jest wtedy odpowiednikiem łańcucha użytkownika z iptables, czyli tworzonego przez iptables -N. Nie jest przypięty do żadnego etapu, więc sam z siebie nigdy nie zobaczy pakietu; zadziała dopiero po skierowaniu do niego ruchu instrukcją jump albo goto. Służy do porządkowania rozbudowanych konfiguracji, tak samo jak wcześniej.

Priorytet zastępuje kolejność, która w iptables była zaszyta w kodzie: pakiet szedł przez mangle, potem nat, potem filter, i nie dało się tego zmienić. W nftables kolejność wynika z podanej liczby - im niższa, tym wcześniej łańcuch dostaje pakiet. Zamiast liczb wygodniej używać nazw, gdzie filter odpowiada wartości 0, srcnat wartości 100, a dstnat wartości −100. Wartości dobrano tak, aby konfiguracja pisana w duchu iptables zachowała znaną z niego kolejność, dzięki czemu w typowym pliku wystarczy sama nazwa.

Praktyczne znaczenie ma również rodzina inet, obsługująca IPv4 i IPv6 w jednej tabeli. Reguła otwierająca port HTTPS przestaje wymagać dwóch wpisów w dwóch narzędziach, a przy okazji znika klasyczny błąd konfiguracyjny, w którym port jest zamknięty w IPv4 i otwarty w IPv6. Gdy reguła ma dotyczyć tylko jednego protokołu, nadal można to wyrazić przez ip saddr dla IPv4 albo ip6 saddr dla IPv6.

Dlaczego accept nie gwarantuje przejścia pakietu

Skoro każde narzędzie może założyć własną tabelę i przypiąć ją do dowolnego etapu, na jednym etapie siedzi zwykle kilka niezależnych łańcuchów należących do różnych programów. Tak wygląda typowy serwer: tabela inet filter administratora, obok niej tabela dockera, obok tabela fail2ban, wszystkie zainteresowane tym samym momentem obsługi pakietu. W iptables taka sytuacja nie mogła wystąpić, ponieważ istniał jeden łańcuch INPUT na cały system i wszyscy dopisywali reguły do tego samego miejsca.

Pakiet przechodzi przez wszystkie te łańcuchy po kolei, według priorytetu. Werdykt accept kończy jego wędrówkę wyłącznie przez ten łańcuch, w którym padł, i przekazuje pakiet do łańcucha o kolejnym priorytecie. Ostateczny jest natomiast drop: pakiet znika natychmiast, a dalsze łańcuchy w ogóle go nie zobaczą.

Widać to na dwóch tabelach oglądających ten sam etap. Pierwsza, z priorytetem 0, przepuszcza ruch na loopbacku. Druga, z priorytetem 10, czyli dostająca pakiet później, odrzuca konkretny port:

table inet t1 {
    chain input {
        type filter hook input priority 0; policy drop;
        iif "lo" accept
    }
}

table inet t2 {
    chain input {
        type filter hook input priority 10; policy accept;
        tcp dport 9999 drop
    }
}

Z samą tabelą t1 usługa nasłuchująca na porcie 9999 odpowiada normalnie. Po dołożeniu t2 połączenie przestaje dochodzić do skutku, mimo że pierwsza tabela nadal zawiera accept i nic się w niej nie zmieniło. Rozstrzyga drop z tabeli oglądającej pakiet później.

Wynikają z tego dwie konsekwencje dla codziennej pracy. Reguła accept nie gwarantuje, że pakiet dojdzie do usługi, ponieważ dalszy łańcuch może go jeszcze odrzucić. Reguła drop gwarantuje natomiast, że pakiet nie dojdzie, łącznie z ruchem, który miał obsłużyć docker lub inna usługa. Reguły odrzucające wymagają więc na takim serwerze większej ostrożności niż wpuszczające, a diagnozę sytuacji „reguła accept jest, a połączenie nie przechodzi" zaczyna się od nft list tables.

Kompletna polityka w obu dialektach

Dotychczasowe fragmenty pokazywały pojedyncze reguły. Poniżej pełna polityka filtrowania serwera WWW, w postaci zrzutu iptables-save - z komentarzami przy regułach, ochroną przed podszywaniem się pod loopback i logowaniem do zewnętrznego kolektora:

*filter
:INPUT DROP [0:0]
:FORWARD DROP [0:0]
:OUTPUT ACCEPT [0:0]
-A INPUT -i lo -j ACCEPT
-A INPUT -d 127.0.0.0/8 -i eno1 -j DROP
-A INPUT -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT
-A INPUT -p icmp -m comment --comment "allow ICMP (including ping)" -j ACCEPT
-A INPUT -i eno1 -p tcp -m tcp --dport 22 -m comment --comment "allow SSH" -j ACCEPT
-A INPUT -i eno1 -p tcp -m tcp --dport 80 -m comment --comment "allow HTTP Nginx" -j ACCEPT
-A INPUT -i eno1 -p tcp -m tcp --dport 443 -m comment --comment "allow HTTPS Nginx" -j ACCEPT
-A INPUT -i eno1 -s adres_ipv4_firmy_administrującej -m comment --comment "allow Admin's HQ on eno1" -j ACCEPT
-A INPUT -i eno1 -m state --state INVALID,NEW,RELATED,ESTABLISHED -j NFLOG --nflog-prefix ETH-INPUT
COMMIT

Polityki wszystkich trzech łańcuchów ustawiono na początku pliku. Ruch przychodzący i przekazywany jest domyślnie odrzucany, wychodzący przepuszczany. Reguła druga zasługuje na komentarz: pakiety adresowane do zakresu 127.0.0.0/8 mogą pochodzić wyłącznie z wnętrza maszyny, więc pojawienie się takiego pakietu na karcie eno1 oznacza próbę podszycia się pod ruch lokalny. Stoi ona po regule wpuszczającej cały ruch z interfejsu lo, dzięki czemu prawdziwy loopback pozostaje nietknięty.

Cały plik tłumaczy się jednym przebiegiem:

iptables-restore-translate -f /root/backup-2026-08-10.v4
add table ip filter
add chain ip filter INPUT { type filter hook input priority 0; policy drop; }
add chain ip filter FORWARD { type filter hook forward priority 0; policy drop; }
add chain ip filter OUTPUT { type filter hook output priority 0; policy accept; }
add rule ip filter INPUT iifname "lo" counter accept
add rule ip filter INPUT iifname "eno1" ip daddr 127.0.0.0/8 counter drop
add rule ip filter INPUT ct state related,established counter accept
add rule ip filter INPUT ip protocol icmp counter accept comment "allow ICMP (including ping)"
add rule ip filter INPUT iifname "eno1" tcp dport 22 counter accept comment "allow SSH"
add rule ip filter INPUT iifname "eno1" tcp dport 80 counter accept comment "allow HTTP Nginx"
add rule ip filter INPUT iifname "eno1" tcp dport 443 counter accept comment "allow HTTPS Nginx"
add rule ip filter INPUT iifname "eno1" ip saddr 203.0.113.7 counter accept comment "allow Admin's HQ on eno1"
add rule ip filter INPUT iifname "eno1" ct state invalid,new,related,established counter log prefix "ETH-INPUT" group 0

Komentarze przetrwały tłumaczenie i stały się elementem comment na końcu reguły. Dwa sposoby logowania rozchodzą się natomiast wyraźnie. Cel -j LOG tłumaczy się na samo log prefix i kieruje wpisy do dziennika jądra, skąd trafiają do systemd-journald lub syslog. Cel -j NFLOG, użyty w ostatniej regule, tłumaczy się na log prefix ... group 0 i przekazuje pakiety do gniazda nfnetlink, czyli do programu zbierającego je w przestrzeni użytkownika, na przykład ulogd2. Bez działającego kolektora nasłuchującego na wskazanej grupie takie wpisy po prostu nigdzie nie trafią.

Wynik tłumaczenia jest poprawny i można go użyć od razu. Zapisany w rodzinie inet, z nazwami łańcuchów pisanymi małymi literami i bez powtarzania counter w każdej regule, ta sama polityka wygląda tak:

#!/usr/sbin/nft -f

flush ruleset

define admin_hq = { adres_ipv4_firmy_administrującej }

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

        iif "lo" accept
        iifname "eno1" ip daddr 127.0.0.0/8 drop comment "podszywanie się pod loopback"

        ct state established,related accept

        icmp type echo-request accept comment "ping IPv4"
        icmpv6 type { echo-request, nd-neighbor-solicit, nd-neighbor-advert,
                      nd-router-solicit, nd-router-advert } accept comment "ping i wykrywanie sąsiadów IPv6"

        iifname "eno1" tcp dport 22 accept comment "SSH"
        iifname "eno1" tcp dport 80 accept comment "HTTP Nginx"
        iifname "eno1" tcp dport 443 accept comment "HTTPS Nginx"
        iifname "eno1" ip saddr $admin_hq accept comment "biuro firmy administrującej"

        iifname "eno1" log prefix "ETH-INPUT " group 0 comment "zrzut do kolektora przed odrzuceniem"
    }

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

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

Konstrukcja define pozwala nadać nazwę wartości używanej w regułach i podstawić ją zapisem $admin_hq. Przy jednym adresie zmienia niewiele, natomiast przy kilku biurach albo przy adresie zmieniającym się raz na jakiś czas poprawka dotyczy jednej linii na początku pliku. Gdyby lista miała się zmieniać na działającym serwerze, właściwszym narzędziem jest zbiór opisany w dalszej części.

Przy przepisywaniu tej polityki cztery rzeczy wymagają świadomej decyzji, a nie mechanicznego przeniesienia.

Ostatnia reguła nie loguje wszystkiego, na co wygląda. Jej lista stanów obejmuje RELATED i ESTABLISHED, ale pakiety w tych stanach zostały już wcześniej przepuszczone regułą ct state i nigdy do niej nie dotrą. Do zapisu trafiają więc wyłącznie pakiety w stanie NEW i INVALID, których nie dopasowała żadna z reguł otwierających porty. Sama lista stanów jest w tym miejscu nadmiarowa i można ją skrócić bez zmiany zachowania.

Logowanie nie decyduje o losie pakietu. Zarówno -j NFLOG, jak i log w nftables nie są werdyktami, więc pakiet przechodzi do kolejnej reguły. Ponieważ jest to reguła ostatnia, trafia następnie na politykę drop i zostaje odrzucony. Taki jest tu zamiar: zapisać to, co zaraz przepadnie.

Reguła dla biura administratora nie ogranicza portów. Dopasowuje sam adres źródłowy, więc z tego adresu dostępna jest każda usługa nasłuchująca na serwerze, także ta uruchomiona przypadkiem i niewidoczna w regułach. Bywa to celowe przy zdalnej administracji, ale powinno wynikać z decyzji, a nie z przeoczenia.

Oryginał obejmuje wyłącznie IPv4. Plik *filter ze zrzutu iptables-save dotyczy tylko tego protokołu, a IPv6 podlegał osobnym regułom ip6tables albo nie podlegał żadnym. Przepisanie polityki do rodziny inet sprawia, że te same reguły zaczynają obejmować oba protokoły naraz - i przy polityce drop odcinają IPv6, jeżeli nie dopisze się obsługi ICMPv6. Wykrywanie sąsiadów jest w IPv6 mechanizmem obowiązkowym, odpowiadającym za ustalanie adresów w sieci lokalnej, więc jego zablokowanie unieruchamia cały protokół. Stąd reguła icmpv6 w wersji nftables, której w oryginale nie było i być nie mogło.

Skąd bierze się brak liczników w wyjściu nft

Polecenie iptables -L -n -v zawsze wyświetla liczbę pakietów i bajtów dla każdej reguły, ponieważ iptables liczy je bezwarunkowo. W nftables licznik jest osobnym, opcjonalnym elementem reguły - kosztuje pamięć i czas procesora, więc płaci się za niego tylko tam, gdzie jest potrzebny. Reguła zapisana w ten sposób:

tcp dport 28 accept

nie pokaże żadnych statystyk. Włącza je dopisanie słowa counter przed werdyktem:

tcp dport 28 counter accept

Wyjaśnia to obecność counter w każdej regule zwracanej przez iptables-translate, ponieważ narzędzie odtwarza zachowanie oryginału. Przy pisaniu konfiguracji od zera decyzja należy do administratora. Zwykle zostawiamy liczniki na regule zbiorczo logującej odrzucone pakiety oraz na kilku regułach faktycznie obserwowanych, a nie na wszystkich.

Przydaje się również licznik nazwany, niezwiązany z jedną regułą, oraz podgląd wszystkich liczników naraz:

nft list counters
nft reset counters

Gdy potrzebne są nie statystyki, lecz droga konkretnego pakietu przez reguły, nftables ma wbudowany mechanizm śledzenia. Włącza go reguła oznaczająca interesujący ruch, a podgląd uruchamia osobne polecenie:

nft add rule inet filter input ip saddr 203.0.113.7 meta nftrace set 1
nft monitor trace

Zastępuje to żmudne wstawianie reguł -j LOG w kolejne miejsca łańcucha, bez modyfikowania logiki firewalla i bez zaśmiecania dziennika systemowego. Regułę śledzącą usuwamy po zakończeniu diagnozy.

Zbiory zamiast setek niemal identycznych reguł

Typowa konfiguracja iptables z listą dozwolonych adresów zawiera tyle reguł, ile adresów. Każda zmiana listy oznacza modyfikację reguł, a przegląd konfiguracji sprowadza się do czytania kilkudziesięciu prawie identycznych linii. W nftables lista adresów jest osobnym obiektem, a reguła odwołuje się do niej przez nazwę:

set admin_ipv4 {
    type ipv4_addr
    flags interval
    elements = { 203.0.113.7, 198.51.100.0/24 }
}
ip saddr @admin_ipv4 tcp dport 28 accept

Flaga interval pozwala trzymać w zbiorze całe podsieci, nie tylko pojedyncze adresy. Najważniejsza jest jednak możliwość zmiany listy bez dotykania reguł, również na działającym serwerze:

nft add element inet filter admin_ipv4 { 203.0.113.9 }
nft delete element inet filter admin_ipv4 { 203.0.113.7 }
nft list set inet filter admin_ipv4

Zbiór może też wypełniać się sam, co zastępuje moduł -m recent pomijany przez iptables-translate. Deklarujemy zbiór dynamiczny z czasem wygasania wpisów, a reguła dopisuje do niego adresy przekraczające ustalone tempo połączeń:

set bruteforce {
    type ipv4_addr
    flags dynamic, timeout
    timeout 1h
}
ip saddr @bruteforce drop
tcp dport 28 ct state new \
    add @bruteforce { ip saddr limit rate over 10/minute } drop

Druga reguła czyta się następująco: dla nowych połączeń na port 28 sprawdzane jest tempo dla danego adresu źródłowego, a po przekroczeniu dziesięciu na minutę adres trafia do zbioru bruteforce, natomiast pakiet zostaje odrzucony. Wpis wygasa po godzinie samoczynnie, bez żadnego procesu czyszczącego. Pierwsza reguła, umieszczona wcześniej, odrzuca cały ruch z adresów już znajdujących się w zbiorze.

Nie zastępuje to fail2ban. Zbiór dynamiczny reaguje na tempo połączeń TCP, a nie na treść dziennika, więc nie odróżni nieudanego logowania od udanego. Sprawdza się jako tania pierwsza warstwa przeciw masowemu skanowaniu, natomiast analiza nieudanych uwierzytelnień pozostaje zadaniem fail2ban.

Docelowy plik /etc/nftables.conf

Opisane elementy składają się na kompletną konfigurację serwera z SSH na porcie 28 oraz usługą WWW. Plik zapisano w rodzinie inet, obejmuje więc IPv4 i IPv6:

#!/usr/sbin/nft -f

flush ruleset

table inet filter {
    set admin_ipv4 {
        type ipv4_addr
        flags interval
        elements = { 203.0.113.7, 198.51.100.0/24 }
    }

    set bruteforce {
        type ipv4_addr
        flags dynamic, timeout
        timeout 1h
    }

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

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

        icmp type echo-request limit rate 10/second accept
        icmpv6 type { echo-request, nd-neighbor-solicit, nd-neighbor-advert,
                      nd-router-solicit, nd-router-advert } accept

        ip saddr @bruteforce drop
        ip saddr @admin_ipv4 tcp dport 28 accept
        tcp dport { 80, 443 } accept

        limit rate 5/second log prefix "nft-drop-in " level info
        counter comment "odrzucone pakiety"
    }

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

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

Reguła ct state established,related stoi na początku, ponieważ dotyczy większości pakietów i pozwala reszcie łańcucha zajmować się wyłącznie nowymi połączeniami. Reguły dla icmpv6 nie są ozdobnikiem, bo bez wykrywania sąsiadów IPv6 przestaje działać, a przy polityce drop łatwo to przeoczyć. Dwie ostatnie linie łańcucha input nie zawierają werdyktu, więc pakiet przechodzi przez nie dalej i ostatecznie trafia na politykę drop; pierwsza zapisuje go w dzienniku z ograniczeniem tempa, druga wyłącznie zlicza.

Sam port 28 wymaga jeszcze jednego sprawdzenia, ponieważ reguła firewalla nie przenosi SSH. Przed wdrożeniem pliku potwierdzamy, że demon rzeczywiście nasłuchuje tam, gdzie zakłada konfiguracja:

ss -lntp | grep sshd

Wdrożenie bez utraty zdalnego dostępu

Plik sprawdzamy najpierw bez wprowadzania zmian. Przełącznik -c weryfikuje nie tylko składnię, ale też poprawność odwołań, na przykład użycie zbioru, którego nie zadeklarowano:

nft -c -f /etc/nftables.conf

Samo wczytanie pliku jest operacją atomową, ponieważ nft -f przetwarza całą jego zawartość jako jedną transakcję. Stosuje się albo wszystko, albo - przy błędzie w dowolnym miejscu - nic. Nie występuje stan pośredni, w którym polityka jest już ustawiona na drop, a reguły wpuszczające ruch jeszcze nie zostały dodane.

Nie chroni to jednak przed błędem logicznym. Plik może być poprawny, wczytać się w całości i mimo to odciąć dostęp, jeżeli pominięto port aktualnie używany do połączenia. Przed wczytaniem uruchamiamy więc wyzwalacz przywracający poprzedni stan po dwóch minutach, o ile nie zostanie potwierdzone, że połączenie nadal działa:

nft list ruleset > /root/nft-rollback.nft
rm -f /run/nft-confirmed

setsid bash -c 'sleep 120; [ -f /run/nft-confirmed ] || \
    { nft flush ruleset; nft -f /root/nft-rollback.nft; }' \
    </dev/null >/dev/null 2>&1 &

nft -f /etc/nftables.conf

Test wykonujemy z drugiej, nowo otwartej sesji SSH, a nie z tej, w której trwa praca. Istniejące połączenie przetrwa przeładowanie niezależnie od reguł, ponieważ pasuje do wpisu ct state established, więc sprawdzenie w nim niczego nie dowodzi. Potwierdzenie następuje dopiero po zestawieniu nowej sesji:

touch /run/nft-confirmed

Trwałość konfiguracji wymaga na koniec uporządkowania mechanizmów ładowania, tak aby po restarcie działał dokładnie jeden z nich:

systemctl enable --now nftables
systemctl disable --now netfilter-persistent

Migrację zamyka kontrolowany restart serwera i sprawdzenie, czy reguły wróciły w oczekiwanym kształcie. Bez tego testu praca pozostaje niedokończona, ponieważ poprawny plik nie oznacza jeszcze włączonej usługi.

Zatrzymanie usługi nie jest bezpiecznym sposobem na chwilowe wyłączenie firewalla. Operacja zatrzymania w jednostce nftables.service czyści cały ruleset, a łańcuchy przestają istnieć, przez co serwer zostaje całkowicie otwarty. Tymczasowe odblokowanie realizujemy dopisaniem reguły albo elementu do zbioru, nie zatrzymywaniem usługi.

Serwery, na których reguły pisze więcej niż jedno narzędzie

Wiersz flush ruleset na początku pliku konfiguracyjnego jest wygodny dopóty, dopóki regułami zarządza jedno narzędzie. Kasuje on jednak wszystkie tabele we wszystkich rodzinach, także należące do innych programów. Docker tworzy własne reguły NAT i filtrowania dla kontenerów, a fail2ban z backendem nftables trzyma bany w tabeli inet f2b-table. Po takim przeładowaniu sieć kontenerów przestaje działać do czasu restartu dockera, a aktywne bany znikają.

Sprawdzenie obecności innych narzędzi zajmuje chwilę:

nft list tables

Jeżeli lista zawiera coś poza własną tabelą, flush ruleset zastępujemy wzorcem ograniczonym do jednej tabeli. Trzy linie na początku pliku odtwarzają ją od zera, nie dotykając pozostałych:

table inet filter
delete table inet filter
table inet filter {
    # ... właściwa konfiguracja
}

Pierwsza linia wygląda na zbędną, ale bez niej wzorzec nie działa. Usunięcie nieistniejącej tabeli kończy się błędem, a ponieważ cały plik stanowi jedną transakcję, błąd przerwałby wczytywanie w całości - plik zawiódłby zarówno przy pierwszym uruchomieniu, jak i po wcześniejszym wyczyszczeniu reguł. Pusta deklaracja tworzy tabelę, jeżeli jeszcze nie istnieje, dzięki czemu następna linia zawsze ma co usunąć. Powstaje w ten sposób plik idempotentny, który można wczytywać wielokrotnie z tym samym wynikiem.

Nakładki wymagają osobnej decyzji. ufw i firewalld zarządzają regułami po swojemu i zakładają wyłączność. Na serwerze z aktywną nakładką nie utrzymujemy równolegle własnego /etc/nftables.conf; jeżeli wybór pada na ręczne nftables, nakładkę wyłączamy przed wdrożeniem, a nie po nim.

Checklista migracji

  1. Sprawdź, czy iptables działa już przez warstwę nf_tables, i czy iptables-legacy nie ma własnych, niewidocznych reguł.
  2. Zrób zrzut w obu formatach: iptables-save, ip6tables-save oraz nft list ruleset.
  3. Ustal, kto ładuje reguły po restarcie - netfilter-persistent, nftables.service czy nakładka typu ufw.
  4. Przetłumacz konfigurację przez iptables-restore-translate i potraktuj wynik jako materiał wyjściowy, nie jako plik docelowy.
  5. Wypisz ręcznie reguły, których narzędzie nie przełożyło - przede wszystkim -m recent i -m hashlimit.
  6. Przepisz konfigurację na rodzinę inet, żeby IPv4 i IPv6 opisywała jedna reguła zamiast dwóch.
  7. Zamień powtarzalne listy adresów na zbiory, a tam gdzie to uzasadnione - na zbiory dynamiczne z timeout.
  8. Świadomie zdecyduj, gdzie potrzebujesz counter; w nftables liczniki nie są domyślne.
  9. Sprawdź plik przez nft -c -f przed jakąkolwiek zmianą w systemie.
  10. Wdrażaj za wyzwalaczem wycofania i potwierdzaj połączenie z drugiej, nowo otwartej sesji SSH.
  11. Jeżeli na serwerze działa docker lub fail2ban, zrezygnuj z flush ruleset na rzecz wzorca ograniczonego do własnej tabeli.
  12. Włącz nftables.service, wyłącz mechanizm poprzedni i potwierdź stan reguł po kontrolowanym restarcie.

Największą część pracy przy tej migracji wykonują narzędzia tłumaczące, a samo wczytanie gotowego pliku trwa ułamek sekundy. Czasu wymagają trzy inne rzeczy: ustalenie, co dokładnie działało wcześniej, wychwycenie reguł bez automatycznego odpowiednika oraz takie wdrożenie, które nie kończy się utratą zdalnego dostępu. Od nich zależy, czy migracja będzie jednorazowym zadaniem, czy serią powrotów do serwera.

Migracja firewalla na produkcyjnym serwerze?

Przenosimy reguły z iptables na nftables, porządkujemy dostępy i wdrażamy zmiany z planem wycofania. Bez utraty zdalnego dostępu i bez okna serwisowego, jeśli nie jest konieczne.