In einem aktuellen Projekt hatte ich eine kleine Herausforderung: Ich muss in zwei verschiedenen Netzen die gleichen IP-Adressen verwenden. Bisher habe ich da mit zwei kleinen Linux Iptables VMs gearbeitet, die beide NAT gemacht haben.

Das muss auch mit einer VM gehen, so mein Anspruch. Und siehe da, da gibt’s eine Technik, die schon eine Weile am Markt ist und von mir ungerechterweise ignoriert wurde: Network Namespaces.

Das Prinzip ist, dass man mit Network Namespaces auf einem Hostsystem mehrere Netzwerkstacks haben kann, und zwar nicht nur verschiedene Routing-Tabellen wie mit iproute2 sondern tatsächlich komplett isolierte Netze bis runter auf Layer 2 (ARP). Eine Technik, die auch bei den diversen Container-Lösungen verwendet wird.

Für mein Projekt habe ich nur ein Teilmenge der Namespaces-Funktionen gebraucht, nämlich für eine Firewall, die Verbindungen aus dem Netz 192.168.1.0 auf dem externen Interface entgegen nimmt und an das interne Netz 192.168.1.0 weiterleitet.

Auf der Zeichnung: Der Testaufbau. Der “Router” ist tatsächlich nur ein Router. Da könnte man zwar auch natten (OpenBSD), aber darum geht’s hier ja nicht. Die Maschine, auf der alles passiert, ist die “Firewall”.

POC Skizze

Die Umsetzung war tatsächlich erstaunlich einfach:

Basis-System

Als Basis dient eine Debian 11 VM mit zwei Netzwerk-Interfaces. Nach der Grundinstallation kommt da noch das iptables Frontend ferm und tcpdump drauf, sonst nichts. Und auch ferm ist nur nice-to-have, aber ein sehr, sehr praktisches. 😉

Konfiguration externes Interface

Die Konfiguration des externen, also in Richtung Router zeigenden Interfaces (ens224) in /etc/network/interfaces:

auto ens224
iface ens224 inet static
	address 192.168.16.11/24
	gateway 192.168.16.1

auto ens224:0
iface ens224:0 inet static
	address 192.168.16.101/24

Die Alias-IP bauchen wir dann später für NAT.

Dann noch das IP Forwarding aktivieren. Hierfür den Kommentar vor der Zeile net.ipv4.ip_forward=1 in /etc/sysctl.conf entfernen mit sysctl -p aktivieren.

Namespace anlegen

Im nächsten Schritt wird der Namespace für das interne Interface (ens192) angelegt, das Loopback-Interface aktiviert, das physikalische Interface in diesen Namespace gesteckt und die Interface-Adresse konfiguriert.

ip netns add int
ip -n int addr add 127.0.0.1/8 dev lo
ip -n int link set lo up

ip link set ens192 netns int
ip -n int addr add 192.168.1.1/24 dev ens192
ip -n int link set ens192 up

Damit ist das Interface ens192 im globalen Kontext unsichtbar:

root@firewall:~# ip -4 a
1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue state UNKNOWN group default qlen 1000
    inet 127.0.0.1/8 scope host lo
       valid_lft forever preferred_lft forever
3: ens224: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc mq state UP group default qlen 1000
    altname enp19s0
    inet 192.168.16.11/24 brd 192.168.16.255 scope global ens224
       valid_lft forever preferred_lft forever
    inet 192.168.16.101/24 brd 192.168.16.255 scope global secondary ens224:0
       valid_lft forever preferred_lft forever
root@firewall:~#

Der ip Befehl, mit dem die Namespace-Konfiguration durchgeführt wird, kennt im Übrigen zwei Varianten (die ich bisher kenne), um auf Namespaces zuzugreifen: Mit “ip netns” führt man Kommandos im Namespace Kontext aus (siehe verlinkte Man Page). Man kann aber auch normale “ip” Kommandos ausführen und den Ziel-Namespace mit -n mitgeben.

Virtuelle Ethernet Interfaces anlegen

Als nächstes legt man einen virtuellen Ethernet-Adapter an. Dieser stellt einen Tunnel bereit, der Namespaces miteinander verbindet. Man kann sich das wie ein Crossover-Kabel vorstellen, mit dem man zwei Netzwerkgeräte verbindet.

ip link add veth0 type veth peer name veth1

ip addr add 10.0.0.1/24 dev veth0
ip link set dev veth0 up

ip link set veth1 netns int
ip -n int addr add 10.0.0.2/24 dev veth1
ip -n int link set dev veth1 up

Der Workflow ist einfach: Mit der “type veth” Zeile werden beide Interfaces auf einmal angelegt. Das veth0 bleibt im globalen Namespace, das veth1 wandert in den “int” Namespace. Das Netz 10.0.0.0/24 dient hier als reines Transfernetz. Das kann natürlich auch ein beliebig anderes nicht geroutetes Netz sein und natürlich muss das kein /24 sein.

Routing aktivieren

Um das Routing in dem “int” Namespace zu aktivieren, braucht man zwei Befehle:

ip -n int route add default via 10.0.0.1
ip netns exec int sysctl -w net.ipv4.ip_forward=1

Die “ip netns exec” Sequenz merken. Sehr praktisch. Ein “ip netns exec int /bin/bash” macht zum Beispiel eine Shell in diesem Namespace auf. Gut zum troubleshooten.

NAT-IP und Regeln konfigurieren

Im letzten Schritt fügt man dem int Namespace eine NAT-Adresse für das System mit der IP 192.168.1.11 hinzu und legt die entsprechenden NAT-Regeln im Namespace-Kontext an:

ip -n int add add 10.0.0.3/24 dev veth1
ip netns exec int iptables -t nat -A POSTROUTING -s 192.168.1.11 -j SNAT --to 10.0.0.3
ip netns exec int iptables -t nat -A PREROUTING -i veth1 -d 10.0.0.3 -j DNAT --to-destination 192.168.1.11

Ah sorry, das war noch nicht der allerletzte Schritt. Man braucht auch noch NAT im globalen Kontext. Da wir ferm haben, wird da einfach der Datei /etc/ferm/ferm.conf eine NAT-Sektion hinzu gefügt:

table nat {
        chain PREROUTING {
            daddr 192.168.16.101 DNAT to 10.0.0.3;
        }
        chain POSTROUTING {
            saddr 10.0.0.3 outerface ens224 SNAT to 192.168.16.101;
            daddr 10.0.0.3 outerface veth0 SNAT to 192.168.16.101;
        }
}

Dann noch zwecks Test die Policy in der Forward-Chain auf ACCEPT stellen (oder gleich richtige Regeln bauen) und ferm mit systemctl reload ferm neu starten.

Tada:

root@firewall:~# ip netns list
int (id: 0)
root@firewall:~# ip netns exec int /usr/bin/bash
root@firewall:~# ip -4 a
1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue state UNKNOWN group default qlen 1000
    inet 127.0.0.1/8 scope host lo
       valid_lft forever preferred_lft forever
2: ens192: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc mq state UP group default qlen 1000
    altname enp11s0
    inet 192.168.1.1/24 scope global ens192
       valid_lft forever preferred_lft forever
4: veth1@if5: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue state UP group default qlen 1000 link-netnsid 0
    inet 10.0.0.2/24 scope global veth1
       valid_lft forever preferred_lft forever
    inet 10.0.0.3/24 scope global secondary veth1
       valid_lft forever preferred_lft forever

Es gibt übrigens noch einen “lsns” Befehl. Was der jetzt wirklich nützt, erschließt sich mir noch nicht wirklich aber das find ich noch raus. Auf jeden Fall kann man sich hübsch anzeigen lassen, was in seinem Namespace so passiert:

root@firewall:~# lsns -l -t net -J --output-all | jq -r .
{
  "namespaces": [
    {
      "ns": 4026531992,
      "type": "net",
      "path": "/proc/1/ns/net",
      "nprocs": 195,
      "pid": 1,
      "ppid": 0,
      "command": "/sbin/init",
      "uid": 0,
      "user": "root",
      "netnsid": "unassigned",
      "nsfs": null
    },
    {
      "ns": 4026532661,
      "type": "net",
      "path": "/proc/774/ns/net",
      "nprocs": 1,
      "pid": 774,
      "ppid": 677,
      "command": "/usr/bin/bash",
      "uid": 0,
      "user": "root",
      "netnsid": "0",
      "nsfs": "/run/netns/int"
    }
  ]
}