From c8924c9b00153c4f1183d93e619654d6fbc147d6 Mon Sep 17 00:00:00 2001
From: Zsombor Welker <fedora@zdeqb.com>
Date: Sun, 11 Dec 2022 11:59:35 +0100
Subject: [PATCH] Add support for resolving IPv6 container hostnames

---
 .../dockerdnsconnector.py                     |  7 +-
 src/systemd_resolved_docker/dockerwatcher.py  | 22 ++++--
 src/systemd_resolved_docker/utils.py          |  7 +-
 test/integration/functions.sh                 |  7 ++
 test/integration/test_ipv6.sh                 | 76 +++++++++++++++++++
 test/integration/test_proxy.sh                | 22 +++---
 6 files changed, 116 insertions(+), 25 deletions(-)
 create mode 100755 test/integration/test_ipv6.sh

diff --git a/src/systemd_resolved_docker/dockerdnsconnector.py b/src/systemd_resolved_docker/dockerdnsconnector.py
index c25530f..3c7b252 100644
--- a/src/systemd_resolved_docker/dockerdnsconnector.py
+++ b/src/systemd_resolved_docker/dockerdnsconnector.py
@@ -2,7 +2,7 @@ import ipaddress
 import threading
 from typing import List
 
-from dnslib import A, CLASS, DNSLabel, QTYPE, RR
+from dnslib import A, AAAA, CLASS, DNSLabel, QTYPE, RR
 from dnslib.proxy import ProxyResolver
 from dnslib.server import DNSServer
 
@@ -72,7 +72,10 @@ class DockerDNSConnector:
                 hn = self.as_allowed_hostname(host_name)
                 mh.host_names.append(hn)
 
-                rr = RR(hn, QTYPE.A, CLASS.IN, 1, A(host.ip))
+                if isinstance(host.ip, ipaddress.IPv4Address):
+                    rr = RR(hn, QTYPE.A, CLASS.IN, 1, A(host.ip.exploded))
+                else:
+                    rr = RR(hn, QTYPE.AAAA, CLASS.IN, 1, AAAA(host.ip.exploded))
                 zone.append(rr)
                 host_names.append(hn)
 
diff --git a/src/systemd_resolved_docker/dockerwatcher.py b/src/systemd_resolved_docker/dockerwatcher.py
index a12691f..5517574 100644
--- a/src/systemd_resolved_docker/dockerwatcher.py
+++ b/src/systemd_resolved_docker/dockerwatcher.py
@@ -1,10 +1,13 @@
+import ipaddress
+from typing import List, Union
+
 import docker
 
 from threading import Thread
 
 
 class DockerHost:
-    def __init__(self, host_names, ip, interface=None):
+    def __init__(self, host_names: List[str], ip: Union[ipaddress.IPv4Address, ipaddress.IPv6Address], interface=None):
         self.host_names = host_names
         self.ip = ip
         self.interface = interface
@@ -85,10 +88,11 @@ class DockerWatcher(Thread):
             name = c.attrs['Name'][1:]
             settings = c.attrs['NetworkSettings']
             for netname, network in settings.get('Networks', {}).items():
-                ip = network.get('IPAddress', False)
-                if not ip or ip == "":
+                ips = [network[field] for field in ['IPAddress', 'GlobalIPv6Address'] if
+                       field in network and network[field] != ""]
+                if not ips:
                     if netname == 'host':
-                        ip = self.default_host_ip
+                        ips = [self.default_host_ip]
                     else:
                         continue
 
@@ -96,11 +100,13 @@ class DockerWatcher(Thread):
                 # eg. container is named "foo", and network is "demo",
                 #     so create "foo.demo" domain name
                 # (avoiding default network named "bridge")
-                record = domain_records.get(ip, [*common_hostnames])
-                if netname != "bridge":
-                    record.append('%s.%s' % (name, netname))
+                for ip in ips:
+                    ipr = ipaddress.ip_address(ip)
+                    record = domain_records.get(ipr, [*common_hostnames])
+                    if netname != "bridge":
+                        record.append('%s.%s' % (name, netname))
 
-                domain_records[ip] = record
+                    domain_records[ipr] = record
 
         for ip, hosts in domain_records.items():
             domain_records[ip] = list(filter(lambda h: h not in duplicate_hostnames, hosts))
diff --git a/src/systemd_resolved_docker/utils.py b/src/systemd_resolved_docker/utils.py
index e9ed3f6..b5f8b1d 100644
--- a/src/systemd_resolved_docker/utils.py
+++ b/src/systemd_resolved_docker/utils.py
@@ -1,14 +1,11 @@
 import ipaddress
 import urllib.parse
 from pyroute2 import NDB
-from typing import List
+from typing import List, Union
 
 
 class IpAndPort:
-    ip: ipaddress.ip_address
-    port: int
-
-    def __init__(self, ip: ipaddress.ip_address, port: int):
+    def __init__(self, ip: Union[ipaddress.IPv4Address, ipaddress.IPv6Address], port: int):
         self.ip = ip
         self.port = port
 
diff --git a/test/integration/functions.sh b/test/integration/functions.sh
index 18dfc84..6d3b1b3 100644
--- a/test/integration/functions.sh
+++ b/test/integration/functions.sh
@@ -44,6 +44,13 @@ docker_ip() {
   docker inspect --format '{{range.NetworkSettings.Networks}}{{.IPAddress}}{{end}}' $container_id
 }
 
+docker_ipv6() {
+  local container_id=$1
+  shift;
+
+  docker inspect --format '{{range.NetworkSettings.Networks}}{{.GlobalIPv6Address}}{{end}}' $container_id
+}
+
 docker_name() {
   local container_id=$1
   shift;
diff --git a/test/integration/test_ipv6.sh b/test/integration/test_ipv6.sh
new file mode 100755
index 0000000..01a4e3c
--- /dev/null
+++ b/test/integration/test_ipv6.sh
@@ -0,0 +1,76 @@
+#!/usr/bin/env bash
+
+. ./functions.sh
+
+exec 10<<EOF
+version: "2.1"
+services:
+  webserver:
+    image: nginx
+    labels:
+     - $TEST_LABEL
+    networks:
+      - network
+  broker:
+    image: redis
+    labels:
+     - $TEST_LABEL
+    networks:
+      - network
+
+networks:
+  network:
+    driver: bridge
+    enable_ipv6: true
+    labels:
+     - $TEST_LABEL
+    ipam:
+      driver: default
+      config:
+        - subnet: 2001:db8:a::/64
+          gateway: 2001:db8:a::1
+EOF
+
+exec 20<<EOF
+version: "2.1"
+services:
+  broker:
+    image: redis
+    labels:
+     - $TEST_LABEL
+    networks:
+      - network
+
+networks:
+  network:
+    driver: bridge
+    enable_ipv6: true
+    labels:
+     - $TEST_LABEL
+    ipam:
+      driver: default
+      config:
+        - subnet: 2001:db8:b::/64
+          gateway: 2001:db8:b::1
+EOF
+
+ALLOWED_DOMAINS=.docker,.$TEST_PREFIX start_systemd_resolved_docker
+
+docker-compose --file /dev/fd/10 --project-name $TEST_PREFIX up --detach --scale webserver=2
+
+broker1_ip=$(docker_ipv6 ${TEST_PREFIX}_broker_1)
+webserver1_ip=$(docker_ipv6 ${TEST_PREFIX}_webserver_1)
+webserver2_ip=$(docker_ipv6 ${TEST_PREFIX}_webserver_2)
+
+query_ok     broker.$TEST_PREFIX $broker1_ip
+query_ok   1.broker.$TEST_PREFIX $broker1_ip
+
+query_ok     webserver.$TEST_PREFIX $webserver1_ip
+query_ok     webserver.$TEST_PREFIX $webserver2_ip
+query_ok   1.webserver.$TEST_PREFIX $webserver1_ip
+query_ok   2.webserver.$TEST_PREFIX $webserver2_ip
+
+query_ok     broker.docker $broker1_ip
+
+docker-compose --file /dev/fd/20 --project-name ${TEST_PREFIX}_2 up --detach
+query_fail   broker.docker
diff --git a/test/integration/test_proxy.sh b/test/integration/test_proxy.sh
index be3baf4..c6405f1 100755
--- a/test/integration/test_proxy.sh
+++ b/test/integration/test_proxy.sh
@@ -10,13 +10,15 @@ docker network create --label $TEST_LABEL $NETWORK > /dev/null
 container_id=$(docker_run resolvetest1 --hostname resolvetest1)
 container_ip=$(docker_ip ${container_id})
 
-dns_ip=$(docker network inspect bridge --format '{{ range .IPAM.Config }}{{ .Gateway }}{{ end }}')
-
-query_ok   resolvetest1.docker $container_ip
-
-# Case 1: generated domains are resolved in containers on the default network
-#         The DNS server is provided explicitly, since it was not provided to the daemon
-docker run --dns $dns_ip       --rm alpine sh -c "apk add bind-tools && host resolvetest1.docker"
-
-# Case 2: generated domains are resolved in containers on other networks
-docker run  --network $NETWORK --rm alpine sh -c "apk add bind-tools && host resolvetest1.docker"
+# The default bridge may have multiple ips/gateways, for example if IPv6 is enabled
+for gateway_ip in $(docker network inspect bridge --format '{{ range .IPAM.Config }}{{ .Gateway }} {{ end }}');
+do
+  query_ok   resolvetest1.docker $container_ip
+
+  # Case 1: generated domains are resolved in containers on the default network
+  #         The DNS server is provided explicitly, since it was not provided to the daemon
+  docker run --dns $gateway_ip   --rm alpine sh -c "apk add bind-tools && host resolvetest1.docker"
+
+  # Case 2: generated domains are resolved in containers on other networks
+  docker run  --network $NETWORK --rm alpine sh -c "apk add bind-tools && host resolvetest1.docker"
+done
\ No newline at end of file
-- 
GitLab