From 97e67f721c8e43e0d652e79e3c35e120a859cd77 Mon Sep 17 00:00:00 2001
From: Zsombor Welker <fedora@zdeqb.com>
Date: Fri, 17 Jun 2022 12:01:25 +0200
Subject: [PATCH] Allow specifying both the listen IPs and ports

The configuration is also split to allow explicitly specifying the
listen address to use with systemd-resolved and the "other" listen
addresses.
---
 README.md                                     | 29 ++++++------
 src/systemd_resolved_docker/cli.py            | 45 ++++++++++---------
 .../dockerdnsconnector.py                     | 25 +++++------
 src/systemd_resolved_docker/dockerwatcher.py  |  3 ++
 .../resolvedconnector.py                      | 30 ++++++++-----
 src/systemd_resolved_docker/utils.py          | 29 +++++++++++-
 systemd-resolved-docker.sysconfig             | 12 ++---
 7 files changed, 106 insertions(+), 67 deletions(-)

diff --git a/README.md b/README.md
index 0c6f328..8aa7d8b 100644
--- a/README.md
+++ b/README.md
@@ -115,6 +115,21 @@ Host test.docker not found: 3(NXDOMAIN)
 
 If there are link-local, VPN or other DNS servers configured then those will also work within containers.
 
+## Configuration
+
+`systemd-resolved-docker` may be configured using environment variables. When installed using the RPM
+`/etc/sysconfig/systemd-resolved-docker` may also be modified to update the environment variables.
+
+| Name                              | Description                                                                                                             | Default Value                                            | Example                           |
+|-----------------------------------|-------------------------------------------------------------------------------------------------------------------------|----------------------------------------------------------|-----------------------------------|
+| DNS_SERVER                        | DNS server to use when resolving queries from docker containers.                                                        | `127.0.0.53` - systemd-resolved DNS server               | `127.0.0.53`                      |
+| DOCKER_INTERFACE                  | Docker interface name                                                                                                   | The first docker network's interface                     | `docker0`                         |
+| SYSTEMD_RESOLVED_LISTEN_ADDRESS   | IPs (+port) to listen on for queries from systemd-resolved.                                                             | `127.0.0.153`                                            | `127.0.0.153:1053`                |
+| DOCKER_LISTEN_ADDRESS             | IPs (+port) to listen on for queries from docker containers in the default network.                                     | _ip of the default docker bridge_, often `172.17.0.1`    | `172.17.0.1` or `172.17.0.1:53`   |
+| ALLOWED_DOMAINS                   | Domain which will be handled by the DNS server. If a domain starts with `.` then all subdomains will also be allowed.   | `.docker`                                                | `.docker,.local`                  |
+| DEFAULT_DOMAIN                    | Domain to append to hostnames which are not allowed by `ALLOWED_DOMAINS`.                                               | `docker`                                                 | `docker`                          |
+| --------------------------------- | ----------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------- | --------------------------------- |
+
 ## Install
 
 ### Fedora / COPR
@@ -155,20 +170,6 @@ For Fedora and RPM based systems [COPR](https://copr.fedorainfracloud.org/coprs/
    unmanaged-devices=interface-name:docker0
    ```
 
-### Configuration
-
-`systemd-resolved-docker` may be configured using environment variables. When installed using the RPM
-`/etc/sysconfig/systemd-resolved-docker` may also be modified to update the environment variables.
-
-| Name             | Description                                                                | Default Value                                          | Example                  |
-|------------------|----------------------------------------------------------------------------|--------------------------------------------------------|--------------------------|
-| DNS_SERVER       | DNS server to use when resolving queries from docker containers.           | `127.0.0.53` - systemd-resolved DNS server             | `127.0.0.53`             |
-| DOCKER_INTERFACE | Docker interface name                                                      | The first docker network's interface                   | `docker0`                |
-| LISTEN_ADDRESS   | IPs to listen on for queries from systemd-resolved and docker containers.  | _ip of the default docker bridge_, often `172.17.0.1`  | `172.17.0.1,127.0.0.153` |
-| LISTEN_PORT      | Port to listen on for queries from systemd-resolved and docker containers. | `53`                                                   | `1053`                   |
-| ALLOWED_DOMAINS  | Domain which will be handled by the DNS server. If a domain starts with `.` then all subdomains will also be allowed.  | `.docker`  | `.docker,.local`         |
-| DEFAULT_DOMAIN   | Domain to append to hostnames which are not allowed by `ALLOWED_DOMAINS`.  | `docker`                                               | `docker`                 |
-
 ## Build
 
 `setup.py` may be used to create a python package.
diff --git a/src/systemd_resolved_docker/cli.py b/src/systemd_resolved_docker/cli.py
index 6076612..d662766 100644
--- a/src/systemd_resolved_docker/cli.py
+++ b/src/systemd_resolved_docker/cli.py
@@ -1,13 +1,14 @@
 #!/usr/bin/env python3
 
 import os
-import docker
 import signal
-from systemd import daemon, journal
 
-from systemd_resolved_docker.resolvedconnector import SystemdResolvedConnector
+import docker
+from systemd import daemon
+
 from .dockerdnsconnector import DockerDNSConnector
-from .utils import find_default_docker_bridge_gateway, find_docker_dns_servers
+from .resolvedconnector import SystemdResolvedConnector
+from .utils import find_default_docker_bridge_gateway, parse_ip_port, parse_listen_address
 
 
 class Handler:
@@ -16,8 +17,10 @@ class Handler:
         self.log("Started daemon")
 
     def on_update(self, hosts):
-        message = "Refreshed - %d items (%s)" % (
-            len(hosts), ' '.join(["%s/%s" % (host.ip, ','.join(host.host_names)) for host in hosts]))
+        if len(hosts) > 0:
+            message = "Refreshed - %d items\n\t%s" % (len(hosts), '\n\t'.join(map(lambda x: str(x), hosts)))
+        else:
+            message = "Refreshed - no running containers"
 
         self.log(message)
 
@@ -29,10 +32,10 @@ class Handler:
 
 
 def main():
-    dns_server = os.environ.get("DNS_SERVER", "127.0.0.53")
+    systemd_resolved_listen_address = os.environ.get("SYSTEMD_RESOLVED_LISTEN_ADDRESS", None)
+    docker_listen_address = os.environ.get("DOCKER_LISTEN_ADDRESS", None)
+    dns_server = parse_ip_port(os.environ.get("UPSTREAM_DNS_SERVER", "127.0.0.53"))
     default_domain = os.environ.get("DEFAULT_DOMAIN", "docker")
-    listen_port = int(os.environ.get("LISTEN_PORT", "53"))
-    listen_address = os.environ.get("LISTEN_ADDRESS", None)
 
     tld = os.environ.get('ALLOWED_DOMAINS', None)
     if tld is None or len(tld.strip()) == 0:
@@ -41,25 +44,25 @@ def main():
         domains = [item.strip() for item in tld.split(',')]
 
     cli = docker.from_env()
-    docker_dns_servers = find_docker_dns_servers(cli)
     docker_gateway = find_default_docker_bridge_gateway(cli)
 
-    if listen_address is None or len(listen_address) < 1:
-        listen_addresses = [entry['gateway'] for entry in docker_gateway]
-    else:
-        listen_addresses = listen_address.split(",")
-
-    interface = os.environ.get('DOCKER_INTERFACE', None)
-    if interface is None or len(interface) < 1:
-        interface = docker_gateway[0]['interface']
+    systemd_resolved_interface = os.environ.get('DOCKER_INTERFACE', None)
+    if systemd_resolved_interface is None or len(systemd_resolved_interface) < 1:
+        systemd_resolved_interface = docker_gateway[0]['interface']
 
     handler = Handler()
     handler.log("Default domain: %s, allowed domains: %s" % (default_domain, ", ".join(domains)))
 
-    resolved = SystemdResolvedConnector(interface, listen_addresses, listen_port, domains)
+    systemd_resolved_listen_addresses = parse_listen_address(systemd_resolved_listen_address,
+                                                             lambda: [parse_ip_port("127.0.0.153:53")])
+    docker_listen_addresses = parse_listen_address(docker_listen_address,
+                                                   lambda: [parse_ip_port(entry['gateway']) for entry in
+                                                            docker_gateway])
+
+    resolved = SystemdResolvedConnector(systemd_resolved_interface, systemd_resolved_listen_addresses, domains, handler)
 
-    dns_connector = DockerDNSConnector(listen_addresses, listen_port, dns_server, domains, default_domain, interface,
-                                       handler, cli)
+    dns_connector = DockerDNSConnector(systemd_resolved_listen_addresses + docker_listen_addresses, dns_server, domains,
+                                       default_domain, handler, cli)
     dns_connector.start()
 
     resolved.register()
diff --git a/src/systemd_resolved_docker/dockerdnsconnector.py b/src/systemd_resolved_docker/dockerdnsconnector.py
index d850384..b40877f 100644
--- a/src/systemd_resolved_docker/dockerdnsconnector.py
+++ b/src/systemd_resolved_docker/dockerdnsconnector.py
@@ -1,4 +1,5 @@
 import threading
+from typing import List
 
 from dnslib import A, CLASS, DNSLabel, QTYPE, RR
 from dnslib.proxy import ProxyResolver
@@ -6,19 +7,16 @@ from dnslib.server import DNSServer
 
 from .dockerwatcher import DockerWatcher, DockerHost
 from .interceptresolver import InterceptResolver
+from .utils import IpAndPort
 from .zoneresolver import ZoneResolver
 
 
 class DockerDNSConnector:
-    def __init__(self, listen_addresses, listen_port, upstream_dns_server, dns_domains, default_domain,
-                 docker_interface, handler, cli):
+    def __init__(self, listen_addresses: List[IpAndPort], upstream_dns_server: IpAndPort, dns_domains, default_domain,
+                 handler, cli):
         super().__init__()
 
-        self.listen_addresses = listen_addresses
-        self.upstream_dns_server = upstream_dns_server
         self.default_domain = default_domain
-        self.dns_domains = dns_domains
-        self.docker_interface = docker_interface
         self.handler = handler
 
         self.dns_domains_globs = ['*%s' % domain if domain.startswith('.') else domain for domain in dns_domains]
@@ -27,14 +25,15 @@ class DockerDNSConnector:
         self.servers = []
 
         resolver = InterceptResolver(self.dns_domains_globs, self.resolver,
-                                     ProxyResolver(upstream_dns_server, port=53, timeout=5))
-        self.handler.log("Unhandled DNS requests will be resolved using %s:53" % upstream_dns_server)
-
-        for address in listen_addresses:
-            server = DNSServer(resolver, address=address, port=listen_port)
-            server.thread_name = "%s:%s" % (address, listen_port)
+                                     ProxyResolver(upstream_dns_server.ip.exploded, port=upstream_dns_server.port,
+                                                   timeout=5))
+        self.handler.log("Unhandled DNS requests will be resolved using %s" % upstream_dns_server)
+        self.handler.log("DNS server listening on %s" % ", ".join(map(lambda x: str(x), listen_addresses)))
+
+        for ip_and_port in listen_addresses:
+            server = DNSServer(resolver, address=ip_and_port.ip.exploded, port=ip_and_port.port)
+            server.thread_name = "%s:%s" % (ip_and_port.ip, ip_and_port.port)
             self.servers.append(server)
-            self.handler.log("DNS server listening on %s:%s" % (address, listen_port))
 
         self.watcher = DockerWatcher(self, cli)
 
diff --git a/src/systemd_resolved_docker/dockerwatcher.py b/src/systemd_resolved_docker/dockerwatcher.py
index 02ee5ef..95f3063 100644
--- a/src/systemd_resolved_docker/dockerwatcher.py
+++ b/src/systemd_resolved_docker/dockerwatcher.py
@@ -9,6 +9,9 @@ class DockerHost:
         self.ip = ip
         self.interface = interface
 
+    def __str__(self):
+        return "%s/%s" % (self.ip, ','.join(self.host_names))
+
 
 class DockerWatcher(Thread):
     """
diff --git a/src/systemd_resolved_docker/resolvedconnector.py b/src/systemd_resolved_docker/resolvedconnector.py
index d3eb11b..96e51ba 100644
--- a/src/systemd_resolved_docker/resolvedconnector.py
+++ b/src/systemd_resolved_docker/resolvedconnector.py
@@ -1,28 +1,31 @@
 import ipaddress
 from socket import AF_INET, AF_INET6
+from typing import List
 
 import dbus
 
 from pyroute2 import IPRoute
 
+from .utils import IpAndPort
+
 
 class SystemdResolvedConnector:
-    def __init__(self, docker_interface, listen_addresses, listen_port, dns_domains):
+    def __init__(self, interface, listen_addresses: List[IpAndPort], dns_domains, handler):
         super().__init__()
 
-        self.docker_interface = docker_interface
+        self.interface = interface
         self.listen_addresses = listen_addresses
-        self.listen_port = listen_port
         self.dns_domains = dns_domains
+        self.handler = handler
 
-        self.ifindex = self.resolve_ifindex(docker_interface)
+        self.ifindex = self.resolve_ifindex(interface)
 
     @staticmethod
-    def resolve_ifindex(docker_interface):
+    def resolve_ifindex(interface):
         with IPRoute() as ipr:
-            ifi = ipr.link_lookup(ifname=docker_interface)
+            ifi = ipr.link_lookup(ifname=interface)
             if not ifi:
-                raise ValueError("Unknown interface '%s'" % docker_interface)
+                raise ValueError("Unknown interface '%s'" % interface)
 
             return ifi[0]
 
@@ -33,15 +36,18 @@ class SystemdResolvedConnector:
         return dbus.Interface(proxy, 'org.freedesktop.resolve1.Manager')
 
     def register(self):
+        self.handler.log("Registering with systemd-resolved - interface: %s, domains: %s, dns server: %s" % (
+            self.interface, self.dns_domains, ", ".join(map(lambda x: str(x), self.listen_addresses))))
+
         domains = [[domain.strip("."), True] for domain in self.dns_domains]
         ips = [
             [
-                AF_INET if isinstance(ip, ipaddress.IPv4Address) else AF_INET6,
-                ip.packed,
-                self.listen_port,
+                AF_INET if isinstance(ip_port.ip, ipaddress.IPv4Address) else AF_INET6,
+                ip_port.ip.packed,
+                ip_port.port,
                 "",
             ]
-            for ip in [ipaddress.ip_address(ip) for ip in self.listen_addresses]
+            for ip_port in self.listen_addresses
         ]
 
         manager = self.if_manager()
@@ -50,5 +56,7 @@ class SystemdResolvedConnector:
         manager.SetLinkDomains(self.ifindex, domains)
 
     def unregister(self):
+        self.handler.log("Unregistering with systemd-resolved: %s" % self.interface)
+
         manager = self.if_manager()
         manager.RevertLink(self.ifindex)
diff --git a/src/systemd_resolved_docker/utils.py b/src/systemd_resolved_docker/utils.py
index 323a6de..2ce72d5 100644
--- a/src/systemd_resolved_docker/utils.py
+++ b/src/systemd_resolved_docker/utils.py
@@ -1,5 +1,30 @@
-def find_docker_dns_servers(cli):
-    return []
+import ipaddress
+import urllib.parse
+from typing import List
+
+
+class IpAndPort:
+    ip: ipaddress.ip_address
+    port: int
+
+    def __init__(self, ip: ipaddress.ip_address, port: int):
+        self.ip = ip
+        self.port = port
+
+    def __str__(self):
+        return "%s:%s" % (self.ip.compressed, self.port)
+
+
+def parse_ip_port(entry, default_port=53) -> IpAndPort:
+    result = urllib.parse.urlsplit('//' + entry)
+    return IpAndPort(ip=ipaddress.ip_address(result.hostname), port=result.port or default_port)
+
+
+def parse_listen_address(listen_addresses, default_value) -> List[IpAndPort]:
+    if listen_addresses is not None and len(listen_addresses) > 1:
+        return [parse_ip_port(item) for item in listen_addresses.split(",")]
+    else:
+        return default_value()
 
 
 def find_default_docker_bridge_gateway(cli):
diff --git a/systemd-resolved-docker.sysconfig b/systemd-resolved-docker.sysconfig
index 51806a8..c6afc32 100644
--- a/systemd-resolved-docker.sysconfig
+++ b/systemd-resolved-docker.sysconfig
@@ -6,13 +6,13 @@
 ## default: first docker network's interface
 # DOCKER_INTERFACE=docker0
 
-## IPs to listen on for queries from systemd-resolved and docker containers
-## default: ip address of each defined docker network
-# LISTEN_ADDRESS=172.17.0.1
+## IPs (+port) to listen on for queries from systemd-resolved.
+## default: 127.0.0.153
+# SYSTEMD_RESOLVED_LISTEN_ADDRESS=127.0.0.153:53
 
-## Port to listen on for queries from systemd-resolved and docker containers.
-## default: 53
-# LISTEN_PORT=53
+## IPs (+port) to listen on for queries from docker containers in the default network.
+## default: ip of the default docker bridge
+# DOCKER_LISTEN_ADDRESS=172.17.0.1:53
 
 ## Domain to append to containers which don't have one set using `--domainname`
 ## or are not part of a network
-- 
GitLab