From e14bbe2ba1b5bce3937c5bd36801f0be7876c262 Mon Sep 17 00:00:00 2001 From: Slawomir Koszewski Date: Mon, 26 Oct 2026 16:53:42 +0100 Subject: [PATCH] fix: dnsmasq answers the remote access clients' DNS queries, so they no longer get the router's own host name with all its addresses from systemd-resolved. --- ADR.md | 27 ++++++++++++++++--- README.md | 18 +++++++------ debian-package/debian/changelog | 15 +++++++++++ debian-package/debian/control | 1 + debian-package/debian/postrm | 1 + debian-package/debian/rules | 1 + .../vpn-router.vpn-router-resolver.service | 15 +++++++++++ debian-package/src/usr/lib/vpn-router/setup | 18 +++++++++++++ .../src/usr/lib/vpn-router/vpnrouter.py | 18 ++++++++----- .../vpn-router/vpnrouter_platforms/azure.py | 4 +-- debian-package/src/usr/lib/vpn-router/web | 2 +- .../vpn-router/templates/dnsmasq.conf.j2 | 11 ++++++++ .../templates/p2s-forwarder.conf.j2 | 4 --- 13 files changed, 111 insertions(+), 24 deletions(-) create mode 100644 debian-package/debian/vpn-router.vpn-router-resolver.service create mode 100644 debian-package/src/usr/share/vpn-router/templates/dnsmasq.conf.j2 delete mode 100644 debian-package/src/usr/share/vpn-router/templates/p2s-forwarder.conf.j2 diff --git a/ADR.md b/ADR.md index 04e1e84..d7abe18 100644 --- a/ADR.md +++ b/ADR.md @@ -1144,9 +1144,9 @@ Status: Accepted Context: Sites get subdomains of a selected domain, delegated to the router, without a registrar change per site. Ubuntu has BIND, Knot and PowerDNS; only PowerDNS's gpgsql backend reads records from PostgreSQL, so changes are rows, without zone files or reloads. systemd-resolved's stub holds -127.0.0.53 and 127.0.0.54, and its extra stub answered the remote access clients on the external -addresses, the addresses an authoritative server needs. The extra stub answers on the internal -address just as well, since the clients reach it through the tunnel when it is in `local.cidrs`. +127.0.0.53 and 127.0.0.54, and the remote access clients' resolver answered on the external +addresses, the addresses an authoritative server needs. The clients reach the internal address +just as well, through the tunnel when it is in `local.cidrs` (ADR-43). Decision: While `dns.enabled` is true, setup runs `pdns.service` with `/etc/powerdns/pdns.d/vpn-router.conf`, written before the packages are installed, on the @@ -1160,3 +1160,24 @@ shares an interface with it is refused while P2S or OpenVPN is enabled. Consequences: The zone follows the sites at each setup run. One zone only, without DNSSEC or transfers; the delegation is made at the parent zone by hand. + +## ADR-43: The remote access clients' resolver is dnsmasq, not systemd-resolved + +Status: Accepted + +Context: IKEv2 and OpenVPN clients were given the router as their DNS server, answered by +systemd-resolved through a `DNSStubListenerExtra` drop-in. resolved synthesizes records for the +machine's own host name, all its addresses, the Docker bridge and the tunnel addresses included, +and for `_gateway` and `localhost`, on every stub, and systemd 255 has no setting that turns this +off. Its extra stub also took port 53 of the addresses PowerDNS needs. dnsmasq-base is in main, +a single binary without a service of its own, and forwards to one upstream server. + +Decision: `vpn-router-resolver.service` runs dnsmasq, bound to the addresses of `p2s.dns_listen` +only (`bind-interfaces`), forwarding to resolved's stub on 127.0.0.53, without `/etc/hosts` and +without forwarding names that have no dot (`domain-needed`). resolved keeps its loopback stubs +only and stays the router's own resolver, with the upstream servers of the platform. Setup writes +`/etc/vpn-router/dnsmasq.conf` while IKEv2 or OpenVPN runs and removes the earlier drop-in. + +Consequences: Clients resolve the names resolved resolves, Azure's internal names included, but +not the router's synthesized ones. The package depends on dnsmasq-base. PowerDNS and the clients' +resolver share no address. diff --git a/README.md b/README.md index 2366462..3d44abe 100644 --- a/README.md +++ b/README.md @@ -402,7 +402,7 @@ firewall rules. It provisions the user directory in `slapd` while OpenVPN or roa access is configured. It then starts `strongswan`, `wg-quick@wg0` and `openvpn-server@vpn-router` if the corresponding feature is configured, reloading or restarting them only when their inputs changed, and -restarts `systemd-resolved` only when the road-warrior DNS drop-in changed. Turning a +restarts the remote access clients' resolver only when its configuration changed. Turning a feature off in the configuration stops its service and removes its files. Every step is idempotent, so restarting with no changes does nothing. @@ -874,9 +874,11 @@ router should be configured but the platform adds nothing. The shipped modules currently add nothing. Road-warrior and OpenVPN clients are given the router's addresses of `p2s.dns_listen` as their DNS servers, by default the internal address, -where the drop-in `/etc/systemd/resolved.conf.d/p2s-forwarder.conf` makes the router's own -`systemd-resolved` answer and the firewall opens port 53 for the client pools, so no provider -resolver has to be advertised or routed through the tunnel on any platform. The clients reach +where `vpn-router-resolver.service`, dnsmasq with `/etc/vpn-router/dnsmasq.conf`, answers and +the firewall opens port 53 for the client pools, so no provider resolver has to be advertised or +routed through the tunnel on any platform. dnsmasq forwards to `systemd-resolved` on +127.0.0.53, which keeps only its loopback stubs, and forwards no name without a dot, so the +router's own host name, `_gateway` and `localhost` are not answered to the clients. The clients reach the internal address when it is inside `local.cidrs`, which they route through the tunnel. Port 53 of the other addresses stays free for the name server (see Name server). The modules exist as the documented place for platform specifics if that changes. @@ -1111,8 +1113,8 @@ read at each request: | `GET /logs/file?path=&lines=` | `{"lines": [...]}`: the last lines of a file `web.log_files` lists | The units are the package's own (`vpn-router-setup`, `-web`, `-metrics`, `-pki`, `-backup`, -`-caddy`), `vpn-router-manager`, `caddy`, `slapd`, `nslcd`, `postgresql`, `strongswan`, -`wg-quick@wg0`, `openvpn-server@vpn-router` and `ssh`. `lines` is 1 to 1000, 200 without it. A +`-caddy`, `-resolver`, `-webdav`), `vpn-router-manager`, `caddy`, `slapd`, `nslcd`, `postgresql`, +`strongswan`, `wg-quick@wg0`, `openvpn-server@vpn-router`, `ssh` and `pdns`. `lines` is 1 to 1000, 200 without it. A file is read from at most its last MiB, so very long lines give fewer lines. A unit or a file not in the lists is 404, as is a listed file that does not exist. @@ -1574,8 +1576,8 @@ missing, without the recommended bind backend. Records added with `/dns` or the Router Manager (A, AAAA, CNAME, TXT, MX, SRV, CAA) are served too, but not those of a name and type the zone's own records have. Setup writes the zone at each run; the SOA's serial grows only when the records change. While P2S or OpenVPN is enabled, -systemd-resolved answers their clients on port 53 of the addresses of `p2s.dns_listen`, by default -the internal address, so `dns.listen` must not share an interface with it. The parent zone must delegate `dns.zone` to `dns.nameserver`, with glue records for +dnsmasq answers their clients on port 53 of the addresses of `p2s.dns_listen`, by default the +internal address, so `dns.listen` must not share an interface with it. The parent zone must delegate `dns.zone` to `dns.nameserver`, with glue records for a name server inside the zone. ```sh diff --git a/debian-package/debian/changelog b/debian-package/debian/changelog index 17b970a..5dab883 100644 --- a/debian-package/debian/changelog +++ b/debian-package/debian/changelog @@ -1,3 +1,18 @@ +vpn-router (0.31.2-1) unstable; urgency=medium + + fix: dnsmasq answers the remote access clients' DNS queries, so they no longer get the router's own host name with all its addresses from systemd-resolved. + + * vpn-router-resolver.service runs dnsmasq on the addresses of + p2s.dns_listen, forwarding to systemd-resolved's stub on 127.0.0.53; + names without a dot are not forwarded. The package depends on + dnsmasq-base. + * systemd-resolved keeps its loopback stubs only; setup removes the + drop-in that made it listen for the clients. + * /logs shows the journal of vpn-router-resolver, vpn-router-webdav and + pdns. + + -- Slawomir Koszewski Tue, 27 Oct 2026 11:00:00 +0100 + vpn-router (0.31.1-1) unstable; urgency=medium fix: the remote access clients' resolver answers on the internal address by default, chosen by p2s.dns_listen, so the name server can take port 53 of the external addresses. diff --git a/debian-package/debian/control b/debian-package/debian/control index bf45faf..1a6c87e 100644 --- a/debian-package/debian/control +++ b/debian-package/debian/control @@ -38,6 +38,7 @@ Depends: ${misc:Depends}, openvpn-auth-ldap, iproute2, systemd-resolved, + dnsmasq-base, zstd Recommends: bash-completion Suggests: caddy, diff --git a/debian-package/debian/postrm b/debian-package/debian/postrm index 90da607..60cc948 100755 --- a/debian-package/debian/postrm +++ b/debian-package/debian/postrm @@ -46,6 +46,7 @@ case "$1" in rm -f /etc/swanctl/conf.d/site-to-site.conf \ /etc/swanctl/conf.d/road-warrior.conf \ /etc/systemd/resolved.conf.d/p2s-forwarder.conf \ + /etc/vpn-router/dnsmasq.conf \ /etc/wireguard/wg0.conf \ /etc/openvpn/server/vpn-router.conf \ /etc/openvpn/server/vpn-router-ldap.conf \ diff --git a/debian-package/debian/rules b/debian-package/debian/rules index 04057a9..a60aaef 100755 --- a/debian-package/debian/rules +++ b/debian-package/debian/rules @@ -12,4 +12,5 @@ override_dh_installsystemd: dh_installsystemd --name=vpn-router-pki --no-enable --no-start dh_installsystemd --name=vpn-router-caddy --no-enable --no-start dh_installsystemd --name=vpn-router-webdav --no-enable --no-start + dh_installsystemd --name=vpn-router-resolver --no-enable --no-start dh_installsystemd --name=vpn-router-install@ --no-enable --no-start diff --git a/debian-package/debian/vpn-router.vpn-router-resolver.service b/debian-package/debian/vpn-router.vpn-router-resolver.service new file mode 100644 index 0000000..926116d --- /dev/null +++ b/debian-package/debian/vpn-router.vpn-router-resolver.service @@ -0,0 +1,15 @@ +[Unit] +Description=VPN Router DNS resolver of the remote access clients +Documentation=file:/usr/share/doc/vpn-router/README.md +After=network-online.target systemd-resolved.service +Wants=network-online.target +ConditionPathExists=/etc/vpn-router/dnsmasq.conf + +[Service] +Type=simple +ExecStart=/usr/sbin/dnsmasq --keep-in-foreground --pid-file= --log-facility=- --conf-file=/etc/vpn-router/dnsmasq.conf +Restart=on-failure +RestartSec=5 + +[Install] +WantedBy=multi-user.target diff --git a/debian-package/src/usr/lib/vpn-router/setup b/debian-package/src/usr/lib/vpn-router/setup index 7b69170..0f3ec4f 100755 --- a/debian-package/src/usr/lib/vpn-router/setup +++ b/debian-package/src/usr/lib/vpn-router/setup @@ -81,6 +81,9 @@ CADDY_PKI_DIR = pathlib.Path('/etc/caddy/vpn-router-pki') #: The certificates and keys of the sites of tls file, /cert.pem and key.pem. CADDY_SITES_DIR = pathlib.Path('/etc/caddy/vpn-router-sites') +#: The unit of dnsmasq, the resolver of the remote access clients. +RESOLVER_UNIT = 'vpn-router-resolver.service' + #: The unit of rclone's WebDAV server of the sites' folders. WEBDAV_UNIT = 'vpn-router-webdav.service' @@ -676,6 +679,19 @@ def caddy_address(host, port, tls): return f'{"http://" if tls == "none" else ""}{host}:{port}' +def manage_resolver(changed): + """Run dnsmasq, the resolver of the remote access clients, while configure wrote its configuration, restarting it when + that changed, and stop it when not. It starts after systemd-resolved has left the addresses it takes.""" + if not vpnrouter.RESOLVER_CONF.exists(): + if unit_is_active(RESOLVER_UNIT): + run(['systemctl', 'disable', '--now', RESOLVER_UNIT], check=False) + return + if not unit_is_active(RESOLVER_UNIT): + run(['systemctl', 'enable', '--now', RESOLVER_UNIT], check=False) + elif 'resolver' in changed: + run(['systemctl', 'restart', RESOLVER_UNIT], check=False) + + def manage_caddy(cfg, ctx): """Run Caddy, the HTTPS proxy of the router's services and the server of the web sites, while either is wanted, and stop it when not. @@ -1062,6 +1078,8 @@ def main(): if 'resolved' in changed: progress('running', 'resolver') run(['systemctl', 'restart', 'systemd-resolved'], check=False) + progress('running', 'resolver of the remote access clients') + manage_resolver(changed) progress('running', 'platform module') run_platform_setup(cfg, ctx) diff --git a/debian-package/src/usr/lib/vpn-router/vpnrouter.py b/debian-package/src/usr/lib/vpn-router/vpnrouter.py index 1f630e7..750d092 100644 --- a/debian-package/src/usr/lib/vpn-router/vpnrouter.py +++ b/debian-package/src/usr/lib/vpn-router/vpnrouter.py @@ -36,7 +36,10 @@ TEMPLATE_DIR = pathlib.Path('/usr/share/vpn-router/templates') SWANCTL_CONF_D = pathlib.Path('/etc/swanctl/conf.d') SITE_TO_SITE = SWANCTL_CONF_D / 'site-to-site.conf' ROAD_WARRIOR = SWANCTL_CONF_D / 'road-warrior.conf' +#: The drop-in of systemd-resolved that earlier versions wrote for the remote access clients; removed when found. P2S_FORWARDER = pathlib.Path('/etc/systemd/resolved.conf.d/p2s-forwarder.conf') +#: The configuration of dnsmasq, the resolver of the remote access clients (vpn-router-resolver.service). +RESOLVER_CONF = pathlib.Path('/etc/vpn-router/dnsmasq.conf') WG_CONF = pathlib.Path('/etc/wireguard/wg0.conf') #: The router's WireGuard public key, which setup writes with its private key. WG_PUBLIC_KEY = pathlib.Path('/etc/wireguard/wg0.pub') @@ -1172,7 +1175,7 @@ def configure(cfg=None, public_ip=None): """Render configuration files and provision PKI. Returns (ctx, changed) where changed is a set naming what needs reloading: - 'swanctl', 'resolved', 'wireguard', 'openvpn'. + 'swanctl', 'resolved', 'resolver', 'wireguard', 'openvpn'. """ if cfg is None: cfg = load_config() @@ -1229,12 +1232,15 @@ def configure(cfg=None, public_ip=None): elif remove(ROAD_WARRIOR): changed.add('swanctl') - # The resolver of the remote access clients answers on the addresses of p2s.dns_listen only. - if (p2s_ready(ctx) or openvpn_ready(ctx)) and ctx['p2s_resolver_addrs']: - if render(env, ctx, 'p2s-forwarder.conf.j2', P2S_FORWARDER, 0o644): - changed.add('resolved') - elif remove(P2S_FORWARDER): + # The resolver of the remote access clients is dnsmasq on the addresses of p2s.dns_listen; systemd-resolved keeps + # its loopback stub only, so its records of the router's own name stay on the router. + if remove(P2S_FORWARDER): changed.add('resolved') + if (p2s_ready(ctx) or openvpn_ready(ctx)) and ctx['p2s_resolver_addrs']: + if render(env, ctx, 'dnsmasq.conf.j2', RESOLVER_CONF, 0o644): + changed.add('resolver') + elif remove(RESOLVER_CONF): + changed.add('resolver') if wg_ready(ctx): if render_wg_conf(env, ctx): diff --git a/debian-package/src/usr/lib/vpn-router/vpnrouter_platforms/azure.py b/debian-package/src/usr/lib/vpn-router/vpnrouter_platforms/azure.py index 05a7bfa..755b360 100644 --- a/debian-package/src/usr/lib/vpn-router/vpnrouter_platforms/azure.py +++ b/debian-package/src/usr/lib/vpn-router/vpnrouter_platforms/azure.py @@ -4,8 +4,8 @@ No platform-specific configuration is currently required. The Azure platform resolver 168.63.129.16 is deliberately *not* advertised to road-warrior clients and *not* added to the traffic selectors. Clients are -given the router's addresses of p2s.dns_listen as their DNS servers, and the router -forwards through its own systemd-resolved via the p2s-forwarder drop-in. That resolves +given the router's addresses of p2s.dns_listen as their DNS servers, where dnsmasq +forwards to the router's own systemd-resolved. That resolves Azure-internal names without routing the wireserver address through the tunnel and without a second resolver in the pool. """ diff --git a/debian-package/src/usr/lib/vpn-router/web b/debian-package/src/usr/lib/vpn-router/web index 294f5b0..eaca11c 100644 --- a/debian-package/src/usr/lib/vpn-router/web +++ b/debian-package/src/usr/lib/vpn-router/web @@ -171,7 +171,7 @@ POSTGRESQL_SETTINGS = ('server_version', 'listen_addresses', 'port', 'max_connec #: The units whose journal /logs/journal shows, besides the whole journal: the package's own and the services it runs. JOURNAL_UNITS = ('vpn-router-setup', 'vpn-router-web', 'vpn-router-metrics', 'vpn-router-pki', 'vpn-router-backup', 'vpn-router-caddy', 'vpn-router-manager', 'caddy', 'slapd', 'nslcd', 'postgresql', 'strongswan', - 'wg-quick@wg0', 'openvpn-server@vpn-router', 'ssh') + 'wg-quick@wg0', 'openvpn-server@vpn-router', 'ssh', 'vpn-router-resolver', 'vpn-router-webdav', 'pdns') #: The most lines /logs answers, the number it answers without lines, and the most bytes it reads from the end of a file. LOG_LINES_MAX = 1000 diff --git a/debian-package/src/usr/share/vpn-router/templates/dnsmasq.conf.j2 b/debian-package/src/usr/share/vpn-router/templates/dnsmasq.conf.j2 new file mode 100644 index 0000000..d697fb5 --- /dev/null +++ b/debian-package/src/usr/share/vpn-router/templates/dnsmasq.conf.j2 @@ -0,0 +1,11 @@ +# Written by vpn-router-setup from /etc/vpn-router/vpn-router.json; edits are overwritten. +# The resolver of the remote access clients, on the addresses of p2s.dns_listen, forwarding to systemd-resolved's stub. +{% for address in p2s_resolver_addrs -%} +listen-address={{ address }} +{% endfor -%} +bind-interfaces +no-resolv +server=127.0.0.53 +domain-needed +no-hosts +cache-size=1000 diff --git a/debian-package/src/usr/share/vpn-router/templates/p2s-forwarder.conf.j2 b/debian-package/src/usr/share/vpn-router/templates/p2s-forwarder.conf.j2 deleted file mode 100644 index 51e8c6f..0000000 --- a/debian-package/src/usr/share/vpn-router/templates/p2s-forwarder.conf.j2 +++ /dev/null @@ -1,4 +0,0 @@ -[Resolve] -{% for address in p2s_resolver_addrs -%} -DNSStubListenerExtra={{ address }} -{% endfor -%}