diff --git a/README.md b/README.md index ee65a392b047457063d5481d3828c99c6d37746b..742c49b95b3fbde4b0d08f88df8e7fd70754fc8f 100644 --- a/README.md +++ b/README.md @@ -3,11 +3,99 @@ Provides systemd-resolved and docker DNS integration. A DNS server is configured to listen on each docker interface's IP address. This is used to: - 1. expose the systemd-resolved DNS service (`127.0.0.53`) to docker containers by proxying DNS requests, since the - systems loopback IPs can't be accessed from containers. - 2. adds the created DNS servers to the docker interface using systemd-resolved so that docker containers may - be referenced by hostname. This uses `--hostname` and `--domainname`, `--network` or a default of `.docker` to - create the domains. +1. allows containers to be referenced by hostname by adding the created DNS servers to the docker interface using + the systemd-resolved D-Bus API. +1. expose the systemd-resolved DNS service (`127.0.0.53`) to docker containers by proxying DNS requests, which doesn't + work by default due to the differing network namespaces. + +## Features + +### Container domain addresses + +Based on the container's properties multiple domain names may be generated. For this the `default_domain` +(`DEFAULT_DOMAIN`) and _allowed domains_ (`ALLOWED_DOMAINS`) options are used. The list of _allowed domains_ specifies +which domains may be handled. An entry starting with `.` (example: `.docker`) allows all matching subdomains, otherwise +an exact match is required. If a generated domain address doesn't match the list of _allowed domains_, then the +`default_domain` is appended. + +1. `<container_id>.<default_domain>` + + All containers will be reachable by their `container_id`: + ```sh + docker run --rm -it alpine # d6d51528ac46.docker + docker ps + CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES + d6d51528ac46 alpine "/bin/sh" 8 seconds ago Up 6 seconds relaxed_cartwright + ``` + +1. `<container_hostname>.<default_domain>`, `<container_hostname>.<container_domain>.<default_domain>`, `<container_hostname>.<container_domain>` + + If an explicit `--hostname` is provided then that may also be used: + ```sh + docker run --rm -it --hostname test alpine # test.docker + ``` + When the hostname is in the list of _allowed domains_ (`ALLOWED_DOMAINS=.docker,some-host`), then the `default_domain` + will not be appended: + ```sh + docker run --rm -it --hostname some-host alpine # some-host + ``` + If an explicit `--domainname` is provided then that may also be used: + ```sh + docker run --rm -it --hostname test --domainname mydomain alpine # test.mydomain.docker + ``` + When the domain name is in the list of _allowed domains_ (`ALLOWED_DOMAINS=.docker,.local`), then the `default_domain` + will not be appended: + ```sh + docker run --rm -it --hostname test --domainname local alpine # test.local + ``` + +1. `<container_name>.<container_network>.<default_domain>`, `<container_name>.<container_network>` + + If a non-default network is used (not `bridge` or `host`) then a name will be generated based on the network's name: + ```sh + docker run --rm -it --network testnet alpine # zealous_jones.testnet.docker + docker run --rm -it --name db --network testnet alpine # db.testnet.docker + ``` + When the network's name is in the list of _allowed domains_ (`ALLOWED_DOMAINS=.docker,.somenet`), then the + `default_domain` will not be appended: + ```sh + docker run --rm -it --network somenet alpine # zealous_jones.somenet + docker run --rm -it --name db --network somenet alpine # db.somenet.docker + ``` + +If configured correctly then `resolvectl status` should show the configured link-specific DNS server: + + $ resolvectl status + ... + Link 7 (docker0) + Current Scopes: DNS LLMNR/IPv4 LLMNR/IPv6 + Protocols: -DefaultRoute +LLMNR -mDNS -DNSOverTLS DNSSEC=no/unsupported + DNS Servers: 172.17.0.1 + DNS Domain: ~docker + ... + +### 127.0.0.53 / systemd-resolved within containers + +If docker is configured to use the provided DNS server then the container domain names may also be resolved within containers: + +``` +$ docker run --dns 1.1.1.1 --rm -it alpine +/ # apk add bind +/ # host test.docker +Host test.docker not found: 3(NXDOMAIN) +``` + +``` +$ docker run --dns 172.17.0.1 --rm -it alpine +/ # apk add bind +/ # host test.docker +/ # host test.docker +test.docker has address 172.17.0.3 +Host test.docker not found: 3(NXDOMAIN) +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. ## Install @@ -49,45 +137,8 @@ For Fedora and RPM based systems [COPR](https://copr.fedorainfracloud.org/coprs/ | 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` | -| DEFAULT_DOMAIN | Domain to append to containers which don't have one set using `--domainname` or are not part of a network `--network`. | `.docker` | `.docker` | -| ALLOWED_DOMAINS | Domain globs which will be handled by the DNS server. | `.docker` | `.docker,local` | - - -## Usage - -Start a container with a specified hostname: -`docker run --hostname test python:3.9 python -m http.server 3000` - -If configured correctly then `resolvectl status` should show the configured link-specific DNS server, while the url -should load: http://test.docker:3000/ - - $ resolvectl status - ... - Link 7 (docker0) - Current Scopes: DNS LLMNR/IPv4 LLMNR/IPv6 - Protocols: -DefaultRoute +LLMNR -mDNS -DNSOverTLS DNSSEC=no/unsupported - DNS Servers: 172.17.0.1 - DNS Domain: ~docker - ... - -If docker is configured to use the provided DNS server then the container domain names may also be resolved within containers: - - $ docker run --dns 1.1.1.1 --rm -it alpine - / # apk add bind - / # host test.docker - Host test.docker not found: 3(NXDOMAIN) - -``` -$ docker run --dns 172.17.0.1 --rm -it alpine -/ # apk add bind -/ # host test.docker -/ # host test.docker -test.docker has address 172.17.0.3 -Host test.docker not found: 3(NXDOMAIN) -Host test.docker not found: 3(NXDOMAIN) -``` - -If there are link-local, VPN or other DNS servers configured than those will also work within containers. +| 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 @@ -95,7 +146,6 @@ If there are link-local, VPN or other DNS servers configured than those will als `tito` may be used to create RPMs. - ## Links Portions are based on [docker-auto-dnsmasq](https://github.com/metal3d/docker-auto-dnsmasq). diff --git a/src/systemd_resolved_docker/cli.py b/src/systemd_resolved_docker/cli.py index b92b28d439bc23119708593c9b627a6567072da7..278259c6b164cf77bc87127b0370aa84f34c2493 100644 --- a/src/systemd_resolved_docker/cli.py +++ b/src/systemd_resolved_docker/cli.py @@ -18,7 +18,7 @@ class Handler: os.system('resolvectl flush-caches') message = "Refreshed - %d items (%s)" % ( - len(hosts), ', '.join(["%s/%s" % (host.ip, ','.join(host.host_names)) for host in hosts])) + len(hosts), ' '.join(["%s/%s" % (host.ip, ','.join(host.host_names)) for host in hosts])) self.log(message) def on_stop(self): @@ -30,15 +30,15 @@ class Handler: def main(): dns_server = os.environ.get("DNS_SERVER", "127.0.0.53") - default_domain = os.environ.get("DEFAULT_DOMAIN", ".docker") + 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: + if tld is None or len(tld.strip()) == 0: domains = [".docker"] else: - domains = tld.split(',') if tld and len(tld) > 0 else [] + domains = [item.strip() for item in tld.split(',')] cli = docker.from_env() docker_dns_servers = find_docker_dns_servers(cli) @@ -54,6 +54,7 @@ def main(): interface = docker_gateway[0]['interface'] handler = Handler() + handler.log("Default domain: %s, allowed domains: %s" % (default_domain, ", ".join(domains))) connector = DockerDNSConnector(listen_addresses, listen_port, dns_server, domains, default_domain, interface, handler, cli) diff --git a/src/systemd_resolved_docker/dockerdnsconnector.py b/src/systemd_resolved_docker/dockerdnsconnector.py index 28c4441936ed759fd712e4035d55e1ce893587e2..f4eb408376c3965f40d2a2f80a99ab75a64ce0cd 100644 --- a/src/systemd_resolved_docker/dockerdnsconnector.py +++ b/src/systemd_resolved_docker/dockerdnsconnector.py @@ -1,15 +1,14 @@ import ipaddress -import os import threading from socket import AF_INET, AF_INET6 import dbus -from dnslib import A, CLASS, QTYPE, RR +from dnslib import A, CLASS, DNSLabel, QTYPE, RR from dnslib.proxy import ProxyResolver from dnslib.server import DNSServer from pyroute2 import IPRoute -from .dockerwatcher import DockerWatcher +from .dockerwatcher import DockerWatcher, DockerHost from .interceptresolver import InterceptResolver from .zoneresolver import ZoneResolver @@ -21,6 +20,7 @@ class DockerDNSConnector: 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 @@ -39,7 +39,7 @@ class DockerDNSConnector: self.servers.append(server) self.handler.log("DNS server listening on %s:%s" % (address, listen_port)) - self.watcher = DockerWatcher(self, self.dns_domains_globs, default_domain, cli) + self.watcher = DockerWatcher(self, cli) def start(self): self.watcher.start() @@ -91,14 +91,28 @@ class DockerDNSConnector: def handle_hosts(self, hosts): zone = [] host_names = [] + mapped_hosts = [] for host in hosts: + mh = DockerHost([], host.ip) + mapped_hosts.append(mh) + for host_name in host.host_names: - rr = RR(host_name, QTYPE.A, CLASS.IN, 1, A(host.ip)) + hn = self.as_allowed_hostname(host_name) + mh.host_names.append(hn) + + rr = RR(hn, QTYPE.A, CLASS.IN, 1, A(host.ip)) zone.append(rr) - host_names.append(host_name) + host_names.append(hn) self.resolver.update(zone) self.update_resolved(enabled=len(host_names) > 0) - self.handler.on_update(hosts) + self.handler.on_update(mapped_hosts) + + def as_allowed_hostname(self, hostname): + for domain in self.dns_domains_globs: + if DNSLabel(hostname).matchGlob(domain): + return hostname + + return "%s.%s" % (hostname, self.default_domain) diff --git a/src/systemd_resolved_docker/dockerwatcher.py b/src/systemd_resolved_docker/dockerwatcher.py index 9dd76da2acb4638ea959dd402f8c03b56a0dd9e0..ae9937fa0cafe26843db8fc75e07f484867e37f2 100644 --- a/src/systemd_resolved_docker/dockerwatcher.py +++ b/src/systemd_resolved_docker/dockerwatcher.py @@ -2,8 +2,6 @@ import docker from threading import Thread -from dnslib import DNSLabel - class DockerHost: def __init__(self, host_names, ip, interface=None): @@ -14,25 +12,17 @@ class DockerHost: class DockerWatcher(Thread): """ - Thread based module for wathing for docker container changes. + Thread based module for watching for docker container changes. """ - def __init__(self, handler, domain_globs=None, default_domain=None, cli=None): + def __init__(self, handler, cli=None): super().__init__() if cli is None: cli = docker.from_env() - if domain_globs is None: - domain_globs = ["*.docker"] - - if default_domain is None: - default_domain = ".docker" - self.daemon = True self.handler = handler - self.domain_globs = domain_globs - self.default_domain = default_domain self.cli = cli def run(self) -> None: @@ -46,51 +36,45 @@ class DockerWatcher(Thread): return def collect_from_containers(self): - hostnames = [] - domain_records = {} for c in self.cli.containers.list(): - # the records + common_hostnames = [] + # container_id (.docker), only the first 12 characters need to be used + container_id = c.attrs['Id'] + common_hostnames.append(container_id[0:12]) + + # hostname (.docker), hostname.domainname (.docker) hostname = c.attrs['Config']['Hostname'] domain = c.attrs['Config'].get('Domainname') - if len(domain) > 0: - hostname = '%s.%s' % (hostname, domain) - - if '.' not in hostname: - hostname += self.default_domain + # if no explicit --hostname is provided, than it will be the first 12 characters of the container_id. + # In that case, the hostname can be ignored + if hostname not in container_id: + if len(domain) > 0: + common_hostnames.append('%s.%s' % (hostname, domain)) + else: + common_hostnames.append(hostname) - # get container name name = c.attrs['Name'][1:] - - # now read network settings settings = c.attrs['NetworkSettings'] for netname, network in settings.get('Networks', {}).items(): ip = network.get('IPAddress', False) if not ip or ip == "": continue - record = domain_records.get(ip, []) # record the container name DOT network # 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)) - - # check if the hostname is allowed - for domain in self.domain_globs: - if DNSLabel(hostname).matchGlob(domain): - record.append(hostname) + record.append('%s.%s' % (name, netname)) - # do not append record if it's empty - if len(record) > 0: - domain_records[ip] = record + domain_records[ip] = record - for ip, hosts in domain_records.items(): - hostnames.append(DockerHost(hosts, ip)) + hostnames = [DockerHost(hosts, ip) for ip, hosts in domain_records.items()] self.handler.handle_hosts(hostnames) diff --git a/systemd-resolved-docker.sysconfig b/systemd-resolved-docker.sysconfig index fcaddc8ffc9e397a369be9467803b8a8105b9da4..51806a8a0e4ee17e6da5b3df3da95b7869455a05 100644 --- a/systemd-resolved-docker.sysconfig +++ b/systemd-resolved-docker.sysconfig @@ -17,7 +17,7 @@ ## Domain to append to containers which don't have one set using `--domainname` ## or are not part of a network ## default: .docker -# DEFAULT_DOMAIN=.docker +# DEFAULT_DOMAIN=docker ## Domain globs of domains which will be handled by the DNS server. ## A container must be within one of these domains, while all non-matching requests