IPv4, IPv6, SSL und Docker – sehr viel verrückter wird es nicht!
Momentan sieht’s so aus: Die Ports 80 (http) und 443 (https) werden über den sogenannten „Userland Proxy“ von Docker auf allen Netzwerk-Interfaces des Servers veröffentlicht. Die SSL-Terminierung übernimmt Nginx. Für IPv4-Clients ist alles super, für IPv6-Clients wird der jeweilige Container intern aber per IPv4 kontaktiert. Das ist im Log des Reverse Proxy zu sehen:
Im aktuellen Setup ist das unschön, aber nicht schlimm. Wirklich problematisch wird es allerdings, wenn ein Container basierend auf der Client-IP Entscheidungen treffen muss, wie es z.B. bei der Spam-Erkennung mit DNSBL für einen SMTP-Server der Fall ist. Da wäre es natürlich nicht richtig, statt der korrekten IPv6-Adresse des Clients eine interne IPv4-Adresse wie 172.20.0.1 zu prüfen. Und weil ich gerade dabei bin, einen Mailserver für die Customieze aufzusetzen, muss eine Lösung her!
Als erstes deaktiviere ich den Userland Proxy. Dazu trage ich folgendes in /etc/docker/daemon.json
ein:
{ "userland-proxy": false }
Ein anschliendes systemctl restart docker
startet den Docker-Daemon neu. Für IPv4 kümmert sich Docker selbst um die passende Netfilter-Konfiguration für die Container-Ports. Für IPv6: Keine Chance! Damit ist die Customieze über IPv6 erstmal offline. Weiter im Text…
Idee 1: Kein IPv6 anbieten
Niemals!
Idee 2: Docker-Containern öffentliche IPv6-Adressen zuweisen
Mal ausprobieren: Ein Docker-Netzwerk mit einem Teil der öffentlichen IPv6-Adressen ist ja schnell angelegt:
docker network create --ipv6 --subnet 2a01:4f8:160:30a8:c47e::/80 --gateway 2a01:4f8:160:30a8:c47e::1 ipv6public
Reverse Proxy und (noch nicht fertig konfigurierter) SMTP-Server sind testweise auch schnell in das neue Netzwerk eingehängt:
docker network connect ipv6public common_nginx-proxy_1 --ip6 2a01:4f8:160:30a8:c47e::2 docker network connect ipv6public mailcustomiezede_postfix_1 --ip6 2a01:4f8:160:30a8:c47e::10
Damit sind beide Container sofort über die angegeben IPv6-Adressen global erreichbar, weil Docker automatisch Routen und Forwarding-Regeln für das Netzwerk anlegt. Das klingt erstmal nicht schlecht, stellt mich aber vor zwei erhebliche Probleme:
- Der geplante Mail-Hostname
mail.customieze.de
müsste nach2a01:4f8:160:30a8:c47e::10
aufgelöst werden. Dort lauscht aber jetzt kein Reverse Proxy, der sich um das automatische Ausstellen des SSL-Zertifikats kümmert. Ich müsste das also im SMTP-Container selbst implementieren. - Automatische Container-Isolation: Fehlanzeige! Alle so konfigurierten Container stehen komplett offen im Internet. Selbst nicht mit Docker veröffentlichte Ports sind über die IPv6-Adressen zugänglich. Das ist für meine Spielereien mit einem halbgaren SMTP-Server eher schlecht, schließlich soll er ja nicht sofort zur Spamschleuder werden!
Beide Probleme lassen sich zwar lösen, aber zum „mal eben Ausprobieren“ ist mir das zu viel Aufwand. Zurück auf Anfang:
docker network disconnect ipv6public common_nginx-proxy_1 docker network disconnect ipv6public mailcustomiezede_postfix_1 docker network rm ipv6public
Idee 3: Alles durch Nginx
Eine weitere Möglichkeit wäre, nur den Reverse Proxy über eine öffentliche IPv6-Adresse anzubieten, und auch SMTP darüber laufen zu lassen. Das geht wohl mit Nginx auch irgendwie, allerdings braucht der dafür einen Authentifizierungs-Server.
Da der SMTP-Server aber auch Mails von anderen SMTP-Servern empfangen soll, muss er anonym zugänglich sein. Das wird so also nichts.
Idee 4: Alles durch HAProxy
Die vorherige Idee scheitert an einer Eigenart von Nginx. Warum also nicht stattdessen HAProxy verwenden? SSL-Terminierung und Reverse Proxy gehört zum Standardrepertoire, und Proxy Protocol geht damit auch.
Das automatische Konfigurieren der Backend-Server und das anfordern/erneuern der SSL-Zertifikate müsste dann natürlich für HAProxy implementiert werden. Wenn es nichts einfacheres gibt, ist das eine brauchbare Lösung.
Die Suche geht weiter…
Idee 5: NAT für IPv6
Die Kombination aus IPv6 und NAT gehört zu den Dingen, die man eigentlich lassen sollte. Schließlich gibt es genug IPv6-Adressen, so dass man diese Krücke aus IPv4-Zeiten eigentlich nicht mehr brauchen sollte. Solange Docker aber keine automatische Isolation der Container über IPv6 mitbringt, ist NAT allerdings eine ernstzunehmende Option!
Das Docker-Image robbertkl/ipv6nat sieht recht vielversprechend aus. Dafür verlangt es allerdings auch relativ viel: Es soll als privilegierter Docker-Container laufen. Na das wollen wir doch erst mal sehen!
Ein etwas längerer Blick in den Code verrät: Der Container aktualisiert mit ip6tables
diverse Netfilter-Regeln, sobald ein Docker-Container mit einem Docker-Netzwerk verbunden oder von ihm getrennt wird. Bei Bedarf muss er dafür noch Kernel-Module nachladen. Prinzipiell sollte der Container also mit den Linux-Capabilitys NET_ADMIN
und SYS_MODULE
auskommen. Das ist auch noch ziemlich heftig, aber immerhin weniger als ein generelles „privilegiert“.
Da der NAT-Container nur IPv6-Adressen im ULA-Bereich berücksichtigt, lege ich erstmal ein Netzwerk mit passendem IPv6-Addressraum an:
docker network create --ipv6 --subnet fd00:ca7e:d09e::/48 --gateway fd00:ca7e:d09e::1 ipv6ula
Anschließend verbinde ich testweise den Reverse Proxy mit dem neuen Netzwerk:
docker network connect ipv6ula common_nginx-proxy_1
Und siehe da: Es gibt wieder IPv6! Der NAT-Container hat automatisch die benötigten Netfilter-Regeln angelegt, die zwischen den Container-Ports im ULA-Netz und den konfigurierten „öffentlichen“ Ports vermitteln:
Ein paar Filter-Regeln kommen auch noch dazu:
Das Netzwerkinterface br-fe04d54f08a8
in den Screenshots entspricht dabei dem Docker-Netzwerk ipv6ula
.
Soweit funktioniert das alles. Beim Starten und Stoppen von Containern mit Port-Mappings werden die Netfilter-Regeln entsprechend aktualisiert. Auch mit dem SMTP-Server klappt das. Die separate IPv6-Adresse für den SMTP-Server kann ich mir damit aber auch wieder sparen.
Ich konfiguriere den NAT-Container noch im docker-compose.yml
, wo ich auch das ULA-Netzwerk deklariere und den Reverse Proxy damit verbinde:
version: '3.3' services: nginx-proxy: build: nginx-proxy # jwilder/nginx-proxy:0.6.0 + selbstsigniertes Zertifikat für unbekannte Domains + Konfiguration für große Uploads restart: unless-stopped ports: - "80:80" - "443:443" volumes: - certificates:/etc/nginx/certs/ - nginx-vhost.d:/etc/nginx/vhost.d/:ro - nginx-html:/usr/share/nginx/html/:ro - /var/run/docker.sock:/tmp/docker.sock:ro networks: - autoproxy - ipv6ula environment: - ENABLE_IPV6=true labels: com.github.jrcs.letsencrypt_nginx_proxy_companion.nginx_proxy: "true" letsencrypt-nginx-proxy-companion: image: jrcs/letsencrypt-nginx-proxy-companion:v1.7 restart: unless-stopped depends_on: - nginx-proxy volumes: - certificates:/etc/nginx/certs/ - nginx-vhost.d:/etc/nginx/vhost.d/ - nginx-html:/usr/share/nginx/html/ - /var/run/docker.sock:/var/run/docker.sock:ro ipv6nat: image: robbertkl/ipv6nat:v0.3.2 restart: unless-stopped cap_add: - NET_ADMIN - SYS_MODULE network_mode: host volumes: - /var/run/docker.sock:/var/run/docker.sock:ro - /lib/modules:/lib/modules:ro volumes: nginx-vhost.d: nginx-html: certificates: external: true networks: autoproxy: external: true ipv6ula: external: true
Insgesamt ist dieses Konstrukt gar nicht so schlimm, wie ich es mir vorgestellt hatte. Mal abwarten, wie es sich in den nächsten Tagen so entwickelt!