Containers & netfilter (iptables / nftables) – Teil 2

Der zweite Teil der Blogpost-Serie ist da. Erfahrt diesmal mehr über Netfilter & Docker Daemon sowie Netfilter & Podman. Im ersten Teil ging es um Netfilter nftablets & iptables.

Netfilter (nftables) & Docker Daemon

In einem anderen angetroffenen Fall zeigte sich die Unverträglichkeit zwischen dem Docker Daemon und nftables auf einem Debian 10 Host (Debian 10 setzt gegenüber Debian 9 neu auf nftables und nicht mehr auf iptables). Der Docker Daemon kann nämlich grundsätzlich mit nftables überhaupt nichts anfangen (stand Juni 2021) und setzt im Hintergrund trotz allfällig vorhandenen nftables Rules, weiterhin stur auf iptables Rules was zu einem undefinierten Verhalten führen kann. Schlussendlich führte das dann im geschilderten Fall zur Situation, dass die published Services innerhalb der Docker Containers vom Host LAN aus nicht erreichbar waren, weil sich iptables und nftables Rules gegenseitig in die Quere kamen.

Da der Docker Daemon leider seit jeher nichts von nftables wissen will (siehe moby/moby issue 26824), muss für Systeme, welche zwingend ausschliesslich nftables verwenden sollen (ohne iptables-nft oder FirewallD), ein Workaround via z.B. Host Network Namespace Networking herhalten.

Host Network Namespace Networking

Für Hosts, welche oftmals nur ein paar wenige Containers eines einzelnen Application-Stacks hosten, könnten die Containers in den Host Network Namespace gesetzt werden, sodass Rules für Container Services über die genau gleiche nftables Base Chain hinter dem input Hook (~= INPUT Chain bei iptables) verwaltet werden können, wie das bei Rules für Host Services der Fall ist. Dies führt jedoch unweigerlich dazu, dass Container Services ganz vom Host LAN her erreichbar werden, sobald sie beginnen auf einem Port nach einkommenden Verbindungen zu hören (z.B. mit einem Binding auf 0.0.0.0:8080). Ein Post Publishing (-p, --publish) ist somit beim docker run Command nicht mehr nötig, ja sogar überflüssig. Sofern in dieser Situation Container Services nur untereinander kommunizieren und nicht vom Host LAN herkommend erreichbar sein müssen (im Beispiel unten Redis & PostgreSQL), so müssen diese Services zwingend auch in den Host Network Namespace gesetzt und zugleich am besten auf 127.0.0.1/::1 gebunden werden. Somit können diese dann Host intern kommunizieren, während sie vom Host LAN her nicht erreichbar sind.

Damit dieses Setup sauber funktioniert und der Docker Daemon nicht mit iptables Rules dazwischen funkt, sollte das Docker Daemon iptables Rule-Handling vollständig deaktiviert werden.

Folgend ein kleines Docker-Compose Beispiel eines Gitlab Stacks (Redis, PostgreSQL, Gitlab), welches aus Platzgründen auf ein Minimum reduziert wurde.

version: '2.4'
services:
  redis:
    image: "redis:6.0.9"
    command:
    - "redis-server"
    - "--bind 127.0.0.1"
    - "--port 6379"
    network_mode: host
  postgresql:
    image: "sameersbn/postgresql:11-20200524"
    command:
    - "-h 127.0.0.1"
    network_mode: host
  gitlab:
    image: "sameersbn/gitlab:13.5.3"
    network_mode: host
    environment:
      # DB
    - "DB_HOST=localhost"
    - "DB_PORT=5432"
      # Redis
    - "REDIS_HOST=localhost"
    - "REDIS_PORT=6379"
      # Gitlab reachability
    - "GITLAB_HOST=gitlab.example.com"
    - "GITLAB_PORT=80"
    - "GITLAB_SSH_LISTEN_PORT=10022"

Schematisch dargestellt, sieht das Networking Setup dann im Hintergrund wie folgt aus:

 

Wie sich nun sicher der Eine oder die Andere denkt, funktioniert dieser Ansatz zwar gut, jedoch verliert man einen grossen Vorteil von Containers – die Netzwerk Isolation. Im Setup oben ist die Situation dann nämlich so, dass die Prozesse innerhalb der Containers vollständig auf alle anderen offenen Ports von anderen Host Prozessen zugreifen können. So könnte z.B. der Gitlab Prozess eine SSH Verbindung auf den Host aufmachen, sofern dies nicht durch weitere, manuelle nftables Rules verhindert werden würde. Dies kann je nach Security-Policy durchaus zum Problem werden.

Wie dem auch sei, diese Architektur kann sich in gewissen Anwendungsfällen durchaus optimal in die Umgebung einfügen, ohne das zusätzliche neue Technologien wie z.B. Podman eingeführt werden müssen. Des Weiteren kann man allerdings gegenüber einer nativen GitLab, PostgreSQL & Redis Installation weiterhin von anderen Container Vorteilen profitieren. So können z.B. diese Contianer Images sehr einfach ausgetauscht werden, um die verschiedenen Komponenten aktualisieren zu können.

Netfilter (nftables) & Podman

In diesem Unterkapitel wird ausschliesslich auf das “rootfull” Podman Networking eingegangen. “Rootless” Podman ist zwar vor allem aus Sicherheitssicht ein super Konzept, jedoch geschieht dort das Networking über eine spezielle “slirp4netns” User Space Komponente und bringt somit weitere Eigenheiten mit sich. Um Genaueres über die Thematik “rootfull” / “rootless” Podman zu erfahren, ist der folgende Blogpost von Red Hat sehr zu empfehlen: https://www.redhat.com/sysadmin/container-networking-podman

Um es bereits vorwegzunehmen, Podman ist auch noch nicht perfekt, wenn es darum geht, auf einem Host komplett auf iptables verzichten zu wollen. Es gibt zwar bereits via FirewallD (nftables Backend) die Möglichkeit, dass gewisse Funktionalitäten direkt via FirewallD gemacht werden können, jedoch verwendet Podman (respektive das bridge Container Network Interface (CNI) Plugin dahinter) für das Filtering und NAT/PAT (IP Masquerading) weiterhin direkt iptables. Übrigens, seitens FirewallD scheinen bereits seit kürzerem alle benötigten Features vorhanden zu sein (Filtering & NAT/PAT), sodass Podman theoretisch keine iptables Rules mehr direkt anlegen müsste. Es gibt zwar seit längerem auch schon andere Bestrebungen für einen vollwertigen Support von nftables in Podman, respektive diese Funktionalität in ein entsprechendes CNI Plugin reinzubringen, jedoch sind diese Bestrebungen bisher noch nicht abgeschlossen. Genauere Details zum Podman nftables Support können in den Github Issues https://github.com/containernetworking/plugins/issues/461 und https://github.com/containernetworking/plugins/issues/519 nachgeschlagen werden. Insgesamt gesehen gilt es also auch mit Podman abzuwarten und zu schauen, ob und wenn ja wann, Podman diese FirewallD Features oder ein CNI mit nftables Support zu nutzen beginnt und damit die iptables Verwendung ganz unterlassen kann.

Im nachfolgenden Szenario wurde ein Centos 8.3 Host mit firewalld (Service aktiviert), nftables, iptables-nft und podman (Version 3.0.1) verwendet:

[root@centos-s-1vcpu-1gb-fra1-01 ~]# cat /etc/centos-release
CentOS Linux release 8.3.2011
[root@centos-s-1vcpu-1gb-fra1-01 ~]# iptables --version
iptables v1.8.4 (nf_tables)
[root@centos-s-1vcpu-1gb-fra1-01 ~]# ls -la /usr/sbin/iptables
lrwxrwxrwx. 1 root root 17 Dec 17 23:27 /usr/sbin/iptables -> xtables-nft-multi
[root@centos-s-1vcpu-1gb-fra1-01 ~]# podman --version
podman version 3.0.1

Sofern firewalld und nftables installiert sind, verwendet Podman für das PREROUTING und POSTROUTING weiterhin iptables-nft und setzt die Contianer IP Adressen in FirewallD einfach in die trusted Zone, welche überall hin kommunizieren darf:

# Ein Dummy Apache2 Webserver wird auf dem Host Port 8080 gestartet:
[root@centos-s-1vcpu-1gb-fra1-01 ~]# podman run -d --rm -p 8080:80 docker.io/library/httpd
e84fd4f258908838d8c33c6be536b1a86d44d286769c1667b75ffd08d973dda9
[root@centos-s-1vcpu-1gb-fra1-01 ~]# podman ps
CONTAINER ID  IMAGE                    COMMAND           CREATED        STATUS            PORTS                 NAMES
e84fd4f25890  docker.io/library/httpd  httpd-foreground  3 seconds ago  Up 3 seconds ago  0.0.0.0:8080->80/tcp  gifted_heyrovsky

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

# Verbindungstest von Extern (Entwickler Nodebook), damit wir anschliessend anhand der Counters sehen, welche Rules matched wurden:
user@notebook:/tmp$ curl http://167.172.101.27:8080
<html><body><h1>It works!</h1></body></html>

# Übersicht über die aktuell vorhandenen iptables "nat" Chains. Wie man sieht, versieht Podman praktischerweise die Rules sogar noch mit der Container ID als Kommentar.
# Es wurden hier aus Platzgründen absichtlich nur die relevanten Chains & Subchains aufgeführt (PREROUTING und POSTROUTING):
[root@centos-s-1vcpu-1gb-fra1-01 ~]# iptables -n -v -L -t nat
Chain PREROUTING (policy ACCEPT 1823 packets, 91768 bytes)
 pkts bytes target     prot opt in     out     source               destination
 1796 90752 CNI-HOSTPORT-DNAT  all  --  *      *       0.0.0.0/0            0.0.0.0/0            ADDRTYPE match dst-type LOCAL

Chain CNI-HOSTPORT-DNAT (2 references)
 pkts bytes target     prot opt in     out     source               destination
    1    52 CNI-DN-a1c960ae3b113c68c3d89  tcp  --  *      *       0.0.0.0/0            0.0.0.0/0            /* dnat name: "podman" id: "e84fd4f258908838d8c33c6be536b1a86d44d286769c1667b75ffd08d973dda9" */ multiport dports 8080

Chain CNI-DN-a1c960ae3b113c68c3d89 (1 references)
 pkts bytes target     prot opt in     out     source               destination
    1    52 DNAT       tcp  --  *      *       0.0.0.0/0            0.0.0.0/0            tcp dpt:8080 to:10.88.0.5:80           # <- "podman run ... -p 8080:80"

Chain POSTROUTING (policy ACCEPT 232 packets, 17163 bytes)
 pkts bytes target     prot opt in     out     source               destination
  207 15272 CNI-HOSTPORT-MASQ  all  --  *      *       0.0.0.0/0            0.0.0.0/0            /* CNI portfwd requiring masquerade */
    0     0 CNI-a1c960ae3b113c68c3d89381  all  --  *      *       10.88.0.5            0.0.0.0/0            /* name: "podman" id: "e84fd4f258908838d8c33c6be536b1a86d44d286769c1667b75ffd08d973dda9" */

Chain CNI-HOSTPORT-MASQ (1 references)
 pkts bytes target     prot opt in     out     source               destination
    0     0 MASQUERADE  all  --  *      *       0.0.0.0/0            0.0.0.0/0            mark match 0x2000/0x2000

Chain CNI-a1c960ae3b113c68c3d89381 (1 references)
 pkts bytes target     prot opt in     out     source               destination
    0     0 ACCEPT     all  --  *      *       0.0.0.0/0            10.88.0.0/16         /* name: "podman" id: "e84fd4f258908838d8c33c6be536b1a86d44d286769c1667b75ffd08d973dda9" */
    0     0 MASQUERADE  all  --  *      *       0.0.0.0/0           !224.0.0.0/4          /* name: "podman" id: "e84fd4f258908838d8c33c6be536b1a86d44d286769c1667b75ffd08d973dda9" */

# Zum Vergleich: In der nftables "ip" Address-Familiy "nat" Table, sehen die NAT Rules genau "gleich" wie im Output von iptables aus (da iptables-nft auf dem CentOS 8.3 Host installiert ist). Es wurden hier aus Platzgründen absichtlich nur die relevanten Chains & Subchains aufgeführt (PREROUTING und POSTROUTING):
[root@centos-s-1vcpu-1gb-fra1-01 ~]# nft list table ip nat
table ip nat {
        chain PREROUTING {
                type nat hook prerouting priority dstnat; policy accept;
                fib daddr type local counter packets 2027 bytes 100587 jump CNI-HOSTPORT-DNAT
        }

        chain CNI-HOSTPORT-DNAT {
                meta l4proto tcp  tcp dport 8080 counter packets 1 bytes 52 jump CNI-DN-a1c960ae3b113c68c3d89
        }

        chain CNI-DN-a1c960ae3b113c68c3d89 {
                meta l4proto tcp tcp dport 8080 counter packets 1 bytes 52 dnat to 10.88.0.5:80           # <- "podman run ... -p 8080:80"
        }

        chain POSTROUTING {
                type nat hook postrouting priority srcnat; policy accept;
                 counter packets 211 bytes 15576 jump CNI-HOSTPORT-MASQ
                ip saddr 10.88.0.5  counter packets 0 bytes 0 jump CNI-a1c960ae3b113c68c3d89381
        }

        chain CNI-HOSTPORT-MASQ {
                mark and 0x2000 == 0x2000 counter packets 0 bytes 0 masquerade
        }

        chain CNI-a1c960ae3b113c68c3d89381 {
                @nh,128,16 2648  counter packets 0 bytes 0 accept
                ip daddr != 224.0.0.0/4  counter packets 0 bytes 0 masquerade
        }
}

# Bezüglich Filtering sind keinerlei Rules vorhanden:
[root@centos-s-1vcpu-1gb-fra1-01 ~]# nft list table ip filter
table ip filter {
        chain INPUT {
                type filter hook input priority filter; policy accept;
        }

        chain FORWARD {
                type filter hook forward priority filter; policy accept;
        }

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

# Podman fügt einfach jeden Container als trusted Source hinzu, was dazu führt, dass Containers von FirewallD Rules nicht beeinträchtigt werden und per Default überall hin komunizieren können.
[root@centos-s-1vcpu-1gb-fra1-01 ~]# firewall-cmd --zone trusted --list-all
trusted (active)
  target: ACCEPT
  icmp-block-inversion: no
  interfaces:
  sources: 10.88.0.5/32
  services:
  ports:
  protocols:
  masquerade: no
  forward-ports:
  source-ports:
  icmp-blocks:
  rich rules:

# In der "inet" Address-Family "firewalld" Table sind lediglich die "accept"-all Rules, welche aufgrund der Contianer Zugehörigkeit zur "trusted" Zone auch für diesen gelten:
[root@centos-s-1vcpu-1gb-fra1-01 ~]# nft list table inet firewalld
table inet firewalld {
        chain filter_INPUT {
                type filter hook input priority filter + 10; policy accept;
                ct state { established, related } accept
                ct status dnat accept
                iifname "lo" accept
                jump filter_INPUT_ZONES_SOURCE
                jump filter_INPUT_ZONES
                ct state { invalid } drop
                reject with icmpx type admin-prohibited
        }

        chain filter_FORWARD {
                type filter hook forward priority filter + 10; policy accept;
                ct state { established, related } accept
                ct status dnat accept
                iifname "lo" accept
                ip6 daddr { ::/96, ::ffff:0.0.0.0/96, 2002::/24, 2002:a00::/24, 2002:7f00::/24, 2002:a9fe::/32, 2002:ac10::/28, 2002:c0a8::/32, 2002:e000::/19 } reject with icmpv6 type addr-unreachable
                jump filter_FORWARD_IN_ZONES_SOURCE
                jump filter_FORWARD_IN_ZONES
                jump filter_FORWARD_OUT_ZONES_SOURCE
                jump filter_FORWARD_OUT_ZONES
                ct state { invalid } drop
                reject with icmpx type admin-prohibited
        }

        chain filter_INPUT_ZONES_SOURCE {
                ip saddr 10.88.0.5 goto filter_IN_trusted           # <- "filter_IN_trusted" is empty with a "accept" at the end...
        }

        chain filter_FORWARD_IN_ZONES_SOURCE {
                ip saddr 10.88.0.5 goto filter_FWDI_trusted           # <- "filter_FWDI_trusted" is empty with a "accept" at the end...
        }

        chain filter_FORWARD_OUT_ZONES_SOURCE {
                ip daddr 10.88.0.5 goto filter_FWDO_trusted           # <- "filter_FWDO_trusted" is empty with a "accept" at the end...
        }
}

Aus diesem Szenario oben lässt sich nun ableiten, dass Podman Containers per Default überall hin kommunizieren und auch von allen anderen Containers aus der trusted Zone (oder vom Host LAN via DNAT) her erreicht werden können. Sofern dieses Verhalten manuell beeinflusst werden soll, könnten theoretisch entsprechende Chains in der gleichen firewalld Table der inet Address-Family auf die gleichen Hooks (also z.B. input, forward oder output) gebunden werden, welche dann jedoch aufgrund ihrer tieferen Priorität bevorzugt werden. Die noch ein bisschen einfachere Alternative dazu wäre, dass das Podman Subnetz (10.88.0.0/16) einfach in eine eigene FirewallD Zone mit komplett eigenständigen Rules ausgelagert wird. Eine Erklärung und mehr Informationen zu dieser Alternative können auf der folgenden Seite nachgelesen werden: https://firewalld.org/2020/09/policy-objects-filtering-container-and-vm-traffic.

Der dritte und letzte Teil dieser Serie folgt nächste Woche.

 

Kommentare sind geschlossen.