Containers & netfilter (iptables / nftables) – Teil 1

In diesem Blogpost soll aufgezeigt werden, inwiefern Container iptables/nftables benötigen und in welchen Anwendungsfällen diese Technologien wiederum ein Problem für Containers darstellen können. In den nächsten beiden Blogposts erfahrt ihr mehr über netfilter & Docker Daemon und Podman sowie netfilter & Kubernetes.  

 

Netfilter: nftables vs. iptables

Bevor in die eigentliche Thematik zu Containern und iptables/nftables eingegangen werden kann, würde ich gerne erst ganz grob die auf die Unterschiede dieser beiden “netfiltering” Technologien eingehen.

Grundsätzlich kann gesagt werden, dass nftables die Ablösung vom legacy iptables ist und das beide auf dem gleichen Linux Kernel-Framework – netfilter – aufbauen. Nftables probiert dabei viele Unschönheiten von iptables wie z.B. fehlende Skalierbarkeit, schlechte Performance und Komplexität bei der Konfiguration zu verbessern respektive zu vereinfachen. Netfilter selbst bietet diverse Funktionen wie Packet Filtering, Network Address Translation (NAT), Port Address Translation (PAT), Routing von Netzwerk-Paketen und Isolierung von einzelnen Netzwerk-Segmenten an. Diese Funktionalitäten können via Kernel Hooks von Linux Kernel Modulen wie z.B. eben ip_tables oder nf_tables verwendet werden, indem sie Callback Funktionen bei diesen Hooks registrieren. Sobald nun ein Netzwerk-Paket den Netzwerk-Stack des Kernels durchlaufen will, werden die registrierten Funktionen aufgerufen und diese haben somit nun die Chance, das Paket nach ihrer vordefinierten Konfiguration zu validieren, zu bearbeiten oder gar zu verwerfen.

Obwohl der Übergang von iptables zu nftables bei den netfilter Entwicklern schon seit längerem vorgesehen ist, befinden wir uns noch mittendrin. Aufgrund diverser Abhängigkeiten und damit verbundenen Schwierigkeiten wird die Umstellung bei diversen Linux Distibutionen erst noch geplant oder wurde erst im letzten Major Release durchgeführt. Beispiele hierfür sind z.B. Red Hat mit dem Upgrade von der Version 7 auf 8 oder Debian mit dem neuen Release 10 (Buster). Genau aus diesem Grund wurde auch eine Art von Zwischenstufe mit iptables-nft entwickelt. iptables-nft erlaubt es nämlich, dass die bestehende legacy iptables Konfigurationssyntax via nftables API angewendet werden kann. Für das Packet Matching selbst wird dann zwar weiterhin das xtables Kernel-Konstrukt verwendet (diagonaler Pfeil in der Grafik), jedoch werden die legacy iptables Rules dann auch in die neue nftables Konfigurationssyntax umgewandelt und sie ist anschliessend auch über die Ausgabe der nftables Rules sichtbar.

Wer einfach nur eine Host Firewall auf seinen Linux Systemen haben will, ohne sich um iptables oder nftables Details zu kümmern, ist mit FirewallD gut bedient. FirewallD ist im Grunde nichts anderes als ein Frontend, welches verschiedene Backends haben kann. Je nach FirewallD Version kann zwischen einem iptables oder nftables Backend frei gewählt werden. Seit der FirewallD Version 0.7.0 wird nftables als vollwertiges Backend unterstützt. In der FirewallD Version 0.8.0 und neuer wurde dieser nftables Support sogar noch weiter ausgebaut und vor allem verbessert. Neu spricht FirewallD dann das nft Binary selbst nicht mehr an, sondern schreibt die Rules direkt via libnftnl in den Kernel Space.

Auf eine genauere Gegenüberstellungen von iptables und nftables wird ab dieser Stelle verzichtet, da dies nicht das Hauptziel dieses Blogposts sein soll. Wer gerne mehr über die Unterschiede erfahren will, kann diese gerne bei z.B. den folgenden Blogposts/Artikeln nachlesen:

Netfilter (iptables) & Docker Daemon

In den letzten Jahren bin ich immer wieder in die Problematik reingelaufen, dass sich manuelle iptables / nftables Rules nicht mit auf dem Host laufenden Docker Containers verstanden haben. D.h. in konkreten, einfacheren Fällen war es z.B. dann so, dass der Docker Daemon mit seinem (per Default aktiviertem) iptables Rule-Handling die manuellen iptables Rules umging, sodass ein Service innerhalb eines Docker Containers vom Host LAN aus erreichbar war, obwohl das laut manuellen Host (INPUT Chain) iptables Rules eigentlich nicht hätte der Fall sein dürfen. Der Docker Daemon arbeitet mit einer eigenen DOCKER und DOCKER-USER iptables Chain, welche in der FORWARD Chain an unterschiedlichen Stellen angezogen werden. Hier ein kleines Beispiel, welches nur die für diesen Anwendungsfall relevanten iptables Rules beinhaltet:

# Wir erlauben einkommend auf den Host ausschliesslich SSH (22/TCP):
root@ubuntu-s-1vcpu-1gb-fra1-01:~# iptables -A INPUT -p tcp --dport 22 -m conntrack --ctstate NEW,ESTABLISHED -j ACCEPT
root@ubuntu-s-1vcpu-1gb-fra1-01:~# iptables -A INPUT -j DROP

# Unser Node soll per Design eigentlich keine Pakete weiterleiten dürfen:
root@ubuntu-s-1vcpu-1gb-fra1-01:~# iptables -A FORWARD -j DROP

# Ausgehend werden keine neue Verbindungen erlaubt. Ausschliesslich Responses auf bestehende Verbindungen dürfen verschickt werden:
root@ubuntu-s-1vcpu-1gb-fra1-01:~# iptables -A OUTPUT -m conntrack --ctstate ESTABLISHED -j ACCEPT
root@ubuntu-s-1vcpu-1gb-fra1-01:~# iptables -A OUTPUT -j DROP

# Ein Dummy Apache2 Webserver wird auf dem Host Port 8080 gestartet:
root@ubuntu-s-1vcpu-1gb-fra1-01:~# docker run -d --rm -p 8080:80 httpd
root@ubuntu-s-1vcpu-1gb-fra1-01:~# docker ps
CONTAINER ID   IMAGE     COMMAND              CREATED         STATUS         PORTS                  NAMES
57e2364b9f07   httpd     "httpd-foreground"   5 seconds ago   Up 3 seconds   0.0.0.0:8080->80/tcp   gallant_carver

# Übersicht über die aktuell vorhandenen iptables "filter" Rules:
root@ubuntu-s-1vcpu-1gb-fra1-01:~# iptables -v -L -n -t filter
Chain INPUT (policy ACCEPT 0 packets, 0 bytes)
 pkts bytes target     prot opt in     out     source               destination
   42  2499 ACCEPT     tcp  --  *      *       0.0.0.0/0            0.0.0.0/0            tcp dpt:22 ctstate NEW,ESTABLISHED
   44  9495 DROP       all  --  *      *       0.0.0.0/0            0.0.0.0/0

Chain FORWARD (policy DROP 0 packets, 0 bytes)
 pkts bytes target     prot opt in     out     source               destination
    0     0 DOCKER-USER  all  --  *      *       0.0.0.0/0            0.0.0.0/0
    0     0 ACCEPT     all  --  *      docker0  0.0.0.0/0            0.0.0.0/0            ctstate RELATED,ESTABLISHED
    0     0 DOCKER     all  --  *      docker0  0.0.0.0/0            0.0.0.0/0
    0     0 ACCEPT     all  --  docker0 !docker0  0.0.0.0/0            0.0.0.0/0
    0     0 ACCEPT     all  --  docker0 docker0  0.0.0.0/0            0.0.0.0/0
    0     0 DROP       all  --  *      *       0.0.0.0/0            0.0.0.0/0

Chain OUTPUT (policy ACCEPT 0 packets, 0 bytes)
 pkts bytes target     prot opt in     out     source               destination
   24  3396 ACCEPT     all  --  *      *       0.0.0.0/0            0.0.0.0/0            ctstate ESTABLISHED
    0     0 DROP       all  --  *      *       0.0.0.0/0            0.0.0.0/0

Chain DOCKER (1 references)
 pkts bytes target     prot opt in     out     source               destination
    0     0 ACCEPT     tcp  --  !docker0 docker0  0.0.0.0/0            172.17.0.2           tcp dpt:80

Chain DOCKER-USER (1 references)
 pkts bytes target     prot opt in     out     source               destination
    0     0 RETURN     all  --  *      *       0.0.0.0/0            0.0.0.0/0

# Node IP raussuchen:
root@ubuntu-s-1vcpu-1gb-fra1-01:~# ip -br -f inet address show dev eth0
eth0             UP             167.71.43.74/20 10.19.0.5/16

# Zugriff von Extern testen (Entwickler Notebook):
user@notebook:/tmp$ curl 
<html><body><h1>It works!</h1></body></html>

# Übersicht über die "pkts"/"bytes" Counters der Rules nach dem Zugriff:
root@ubuntu-s-1vcpu-1gb-fra1-01:~# iptables -v -L -n -t filter
Chain INPUT (policy ACCEPT 0 packets, 0 bytes)
 pkts bytes target     prot opt in     out     source               destination
   62  3470 ACCEPT     tcp  --  *      *       0.0.0.0/0            0.0.0.0/0            tcp dpt:22 ctstate NEW,ESTABLISHED
   90 17157 DROP       all  --  *      *       0.0.0.0/0            0.0.0.0/0

Chain FORWARD (policy DROP 0 packets, 0 bytes)
 pkts bytes target     prot opt in     out     source               destination
    9   735 DOCKER-USER  all  --  *      *       0.0.0.0/0            0.0.0.0/0
    4   241 ACCEPT     all  --  *      docker0  0.0.0.0/0            0.0.0.0/0            ctstate RELATED,ESTABLISHED
    1    52 DOCKER     all  --  *      docker0  0.0.0.0/0            0.0.0.0/0
    4   442 ACCEPT     all  --  docker0 !docker0  0.0.0.0/0            0.0.0.0/0
    0     0 ACCEPT     all  --  docker0 docker0  0.0.0.0/0            0.0.0.0/0
    0     0 DROP       all  --  *      *       0.0.0.0/0            0.0.0.0/0

Chain OUTPUT (policy ACCEPT 0 packets, 0 bytes)
 pkts bytes target     prot opt in     out     source               destination
   42 10509 ACCEPT     all  --  *      *       0.0.0.0/0            0.0.0.0/0            ctstate ESTABLISHED
    0     0 DROP       all  --  *      *       0.0.0.0/0            0.0.0.0/0

Chain DOCKER (1 references)
 pkts bytes target     prot opt in     out     source               destination
    1    52 ACCEPT     tcp  --  !docker0 docker0  0.0.0.0/0            172.17.0.2           tcp dpt:80           # <- "docker run ... -p 8080:80"

Chain DOCKER-USER (1 references)
 pkts bytes target     prot opt in     out     source               destination
    9   735 RETURN     all  --  *      *       0.0.0.0/0 

Wie anhand dieses Beispieles nachvollzogen werden kann, scheint die INPUT Chain für Docker Services selbst überhaupt keine Relevanz zu haben, da unser Verbindungsversuch vom Entwickler Notebook aus ohne Probleme funktionierte. Dies lässt sich dadurch schlüssig erklären, indem genauer betrachtet wird, wie das Default Docker Container Networking abläuft. In  der Realität ist nämlich jeder Docker Container für den Host lediglich ein weiterer virtueller “Host”, welcher über die virtuelle docker0 Linux Bridge (Default) mit der Host NIC verbunden ist:

Aus diesem Kontext lässt sich nun entnehmen, wieso die Host INPUT iptables Chain keinen Einfluss auf die Erreichbarkeit der published Docker Container Services hat: Der Traffic wird ausschliesslich von der FOWARD Chain behandelt, da er für den Container und nicht den Host selbst gedacht ist. Es handelt sich hierbei also um ein “FORWARDing” (Routing) – deshalb auch das Router Symbol in der Grafik oben.

Der Eine oder die Andere fragt sich nun bestimmt, wieso die automatisch vom Docker Daemon angelegte DOCKER Chain Rule für tcp dpt:80 und nicht für 8080 gilt – schliesslich haben wir im Beispiel ja den Port 8080 auf Host Ebene gewählt und entsprechend freigegeben. Nun, dies kommt daher, da vor dem Filtering selbst via PREROUTING noch ein Destination NAT (DNAT) stattfindet, welches den Destination Port (8080) vom Request Paket auf den Destination Port (80) des Containers umbiegt. Das Gleiche wird auch für die Destination IP Adresse gemacht, da die private IPv4 Adresse vom Container (im Beispiel unten 172.17.0.2) dem Entwickler-Notebook nicht bekannt sein kann und auch die Antwort vom Container zurück, könnte im Internet so nicht gerouted werden, da dann die Source IP Adresse aus dem privaten IPv4 Adressbereich kommen würde (RFC1918):

root@ubuntu-s-1vcpu-1gb-fra1-01:~# iptables -v -L -n -t nat
Chain PREROUTING (policy ACCEPT 1118 packets, 106K bytes)
 pkts bytes target     prot opt in     out     source               destination
 1142  107K DOCKER     all  --  *      *       0.0.0.0/0            0.0.0.0/0            ADDRTYPE match dst-type LOCAL

Chain INPUT (policy ACCEPT 30 packets, 1800 bytes)
 pkts bytes target     prot opt in     out     source               destination

Chain OUTPUT (policy ACCEPT 4 packets, 304 bytes)
 pkts bytes target     prot opt in     out     source               destination
    0     0 DOCKER     all  --  *      *       0.0.0.0/0           !127.0.0.0/8          ADDRTYPE match dst-type LOCAL

Chain POSTROUTING (policy ACCEPT 18 packets, 964 bytes)
 pkts bytes target     prot opt in     out     source               destination
    0     0 MASQUERADE  all  --  *      !docker0  172.17.0.0/16        0.0.0.0/0
    0     0 MASQUERADE  tcp  --  *      *       172.17.0.2           172.17.0.2           tcp dpt:80

Chain DOCKER (2 references)
 pkts bytes target     prot opt in     out     source               destination
    0     0 RETURN     all  --  docker0 *       0.0.0.0/0            0.0.0.0/0
   18   964 DNAT       tcp  --  !docker0 *       0.0.0.0/0            0.0.0.0/0            tcp dpt:8080 to:172.17.0.2:80           # <- "docker run ... -p 8080:80"

Da der Docker Daemon seine Rules allesamt in der DOCKER Chain verwaltet und zugleich schaut, dass seine benötigten Einträge innerhalb der FORWARD Chain immer zuoberst sind, hätte man mit manuellen FORWARD Chain Einträgen keine Chance was Sinniges hinzubekommen. Genau für diesen Zweck verlinkt der Docker Daemon jedoch zum Glück noch eine andere Chain zuoberst in die FORWARD Chain, die DOCKER-USER Chain. Will man nun effektiv den Zugriff auf Docker Container Services via Host iptables Rules beeinflussen, so muss dies in der DOCKER-USER Chain mittels manuellen Einträgen gemacht werden. Diese Chain bearbeitet der Docker Daemon nämlich nie und somit werden die manuellen iptables Rules des Systemadministrators dort auch nie überschrieben oder gar umgangen.

Rules added to the FORWARD chain – either manually, or by another iptables-based firewall – are evaluated after these chains. This means that if you expose a port through Docker, this port gets exposed no matter what rules your firewall has configured. If you want those rules to apply even when a port gets exposed through Docker, you must add these rules to the DOCKER-USER chain. –

https://docs.docker.com/network/iptables/#add-iptables-policies-before-dockers-rules.

Nächste Woche folgt Teil 2 dieser Blogpost-Serie.

Kommentare sind geschlossen.