Compare commits

..
4 Commits
14 changed files with 302 additions and 40 deletions

No files matched your search

+44 -3
View File
@@ -1144,9 +1144,9 @@ Status: Accepted
Context: Sites get subdomains of a selected domain, delegated to the router, without a registrar 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 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 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 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 extra stub answers on the internal addresses, the addresses an authoritative server needs. The clients reach the internal address
address just as well, since the clients reach it through the tunnel when it is in `local.cidrs`. 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 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 `/etc/powerdns/pdns.d/vpn-router.conf`, written before the packages are installed, on the
@@ -1160,3 +1160,44 @@ 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 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. 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, OpenVPN or WireGuard runs, opens port 53 there for
their client networks, 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.
## ADR-44: Apple VPN profiles are unsigned IKEv2 profiles with a PKCS#12 encrypted by a password given per download
Status: Accepted
Context: macOS, iOS and iPadOS connect to the road-warrior access with their built-in IKEv2 client,
configured by a configuration profile. The router keeps each user's certificate and key. Apple's
clients read a PKCS#12 only when it is encrypted with 3DES and SHA-1, send one proposal per SA,
and show an unsigned profile as unverified.
Decision: The web service builds the profile with `plistlib` and python3-cryptography: the P2S CA
and the root, the user's PKCS#12 encrypted with a password the caller gives with each request,
and an IKEv2 payload matching `road-warrior.conf` (EAP-TLS with the certificate, AES-256,
SHA2-256, group 14). The profile is not signed. Admins get any user's profile through `/users`,
users their own through `/account` with their current password and code.
Consequences: The password is never stored; the device asks for it when the profile is installed.
A profile shows as unverified. A changed proposal of `road-warrior.conf` must be changed in the
profile too. WireGuard is not part of it.
+35 -12
View File
@@ -115,7 +115,7 @@ must have the type shown below; an unknown section or setting is an error.
| `site_to_site` | | object | | The site-to-site connections, each a name and an object of the settings below | | `site_to_site` | | object | | The site-to-site connections, each a name and an object of the settings below |
| `p2s` | `enabled` | boolean | `false` | Road-warrior access | | `p2s` | `enabled` | boolean | `false` | Road-warrior access |
| `p2s` | `address_pool` | string | | CIDR assigned to road-warrior clients | | `p2s` | `address_pool` | string | | CIDR assigned to road-warrior clients |
| `p2s` | `dns_listen` | string | `internal` | The addresses of the resolver the road-warrior and OpenVPN clients are given: `internal`, `external` or `both` | | `p2s` | `dns_listen` | string | `internal` | The addresses of the resolver the road-warrior, OpenVPN and WireGuard clients are given: `internal`, `external` or `both` |
| `wireguard` | `enabled` | boolean | `false` | WireGuard endpoint | | `wireguard` | `enabled` | boolean | `false` | WireGuard endpoint |
| `wireguard` | `address` | string | | The router's own address on `wg0` with the prefix of the peers' network, for example `192.168.200.1/24`; the network and the broadcast address of a network larger than /31 are refused | | `wireguard` | `address` | string | | The router's own address on `wg0` with the prefix of the peers' network, for example `192.168.200.1/24`; the network and the broadcast address of a network larger than /31 are refused |
| `wireguard` | `listen_port` | integer | `51820` | UDP port | | `wireguard` | `listen_port` | integer | `51820` | UDP port |
@@ -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 access is configured. It then starts `strongswan`, `wg-quick@wg0` and
`openvpn-server@vpn-router` if the corresponding feature is configured, reloading or `openvpn-server@vpn-router` if the corresponding feature is configured, reloading or
restarting them only when their inputs changed, and 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 feature off in the configuration stops its service and removes its files. Every step is
idempotent, so restarting with no changes does nothing. idempotent, so restarting with no changes does nothing.
@@ -776,6 +776,23 @@ the file.
The certificate is also accepted by road-warrior (IKEv2 EAP-TLS) access, which checks The certificate is also accepted by road-warrior (IKEv2 EAP-TLS) access, which checks
certificates only, so `disable` does not affect it; `remove` revokes the certificate for both. certificates only, so `disable` does not affect it; `remove` revokes the certificate for both.
### Apple VPN profile
While `p2s.enabled` is `true` and `wan.local_fqdn` is set, `/users/<name>/apple-profile` and a
user's own `/account/apple-profile` give an unsigned Apple configuration profile (`.mobileconfig`)
that sets up road-warrior access in the built-in VPN client of macOS, iOS and iPadOS. It holds:
- the P2S CA and the root CA, the root installed as a trusted root;
- the user's certificate and key as a PKCS#12 encrypted with the password of the request, with
3DES and SHA-1, the only encryption Apple's clients read; the device asks for the password when
the profile is installed, and the router does not keep it;
- an IKEv2 VPN to `wan.local_fqdn`, with the user name as the local identity, the certificate over
EAP-TLS, and AES-256, SHA2-256 and group 14 for IKE and ESP, as the router's road-warrior
connection takes them.
The password is 8 to 128 characters. A user asking for the own profile also gives the current
password and, when enrolled, a code.
### Console login and SSH ### Console login and SSH
A member of `admins` enrolled for TOTP may have console login: the Router Manager's Shell page A member of `admins` enrolled for TOTP may have console login: the Router Manager's Shell page
@@ -872,11 +889,14 @@ deploying.
which is how a deferred install is expressed. `generic` is the module to choose when the which is how a deferred install is expressed. `generic` is the module to choose when the
router should be configured but the platform adds nothing. router should be configured but the platform adds nothing.
The shipped modules currently add nothing. Road-warrior and OpenVPN clients are given the The shipped modules currently add nothing. Road-warrior, OpenVPN and WireGuard clients are given the
router's addresses of `p2s.dns_listen` as their DNS servers, by default the internal address, 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 the WireGuard clients in the configuration the Router Manager makes for a device (`DNS =`),
`systemd-resolved` answer and the firewall opens port 53 for the client pools, so no provider where `vpn-router-resolver.service`, dnsmasq with `/etc/vpn-router/dnsmasq.conf`, answers and
resolver has to be advertised or routed through the tunnel on any platform. The clients reach the firewall opens port 53 for the client pools and the WireGuard network, 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 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). 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. The modules exist as the documented place for platform specifics if that changes.
@@ -1111,8 +1131,8 @@ read at each request:
| `GET /logs/file?path=<path>&lines=<n>` | `{"lines": [...]}`: the last lines of a file `web.log_files` lists | | `GET /logs/file?path=<path>&lines=<n>` | `{"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`, The units are the package's own (`vpn-router-setup`, `-web`, `-metrics`, `-pki`, `-backup`,
`-caddy`), `vpn-router-manager`, `caddy`, `slapd`, `nslcd`, `postgresql`, `strongswan`, `-caddy`, `-resolver`, `-webdav`), `vpn-router-manager`, `caddy`, `slapd`, `nslcd`, `postgresql`,
`wg-quick@wg0`, `openvpn-server@vpn-router` and `ssh`. `lines` is 1 to 1000, 200 without it. A `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 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. in the lists is 404, as is a listed file that does not exist.
@@ -1178,6 +1198,7 @@ and answer bodies are JSON; a request body is at most 65536 bytes.
| `POST /users/<name>/unlock` | | the user | | `POST /users/<name>/unlock` | | the user |
| `DELETE /users/<name>` | | `{"name", "removed": true}` | | `DELETE /users/<name>` | | `{"name", "removed": true}` |
| `GET /users/<name>/profile` | | `{"name", "profile"}`, the OpenVPN client profile | | `GET /users/<name>/profile` | | `{"name", "profile"}`, the OpenVPN client profile |
| `POST /users/<name>/apple-profile` | `{"password"}` | `{"name", "profile"}`, the Apple configuration profile of IKEv2 road-warrior access (see Apple VPN profile) |
| `POST /users/<name>/otp` | | `{"name", "secret", "uri"}`, a new TOTP secret; nothing is stored | | `POST /users/<name>/otp` | | `{"name", "secret", "uri"}`, a new TOTP secret; nothing is stored |
| `PUT /users/<name>/otp` | `{"secret", "code"}` | the user, once the code matches the secret | | `PUT /users/<name>/otp` | `{"secret", "code"}` | the user, once the code matches the secret |
| `DELETE /users/<name>/otp` | | the user | | `DELETE /users/<name>/otp` | | the user |
@@ -1234,6 +1255,8 @@ router manages them, their display name and description included. A failure is o
| `POST /account/passkeys` | `{"current_password", "current_code"}` | `{"id", "options"}`: a pending registration, valid 2 minutes, and the options of `navigator.credentials.create` in their JSON form | | `POST /account/passkeys` | `{"current_password", "current_code"}` | `{"id", "options"}`: a pending registration, valid 2 minutes, and the options of `navigator.credentials.create` in their JSON form |
| `PUT /account/passkeys` | `{"id", "credential", "label"?}`, `credential` the browser's answer as its `toJSON()` gives it; `label` at most 64 characters, `Passkey` without one | the user | | `PUT /account/passkeys` | `{"id", "credential", "label"?}`, `credential` the browser's answer as its `toJSON()` gives it; `label` at most 64 characters, `Passkey` without one | the user |
| `DELETE /account/passkeys/<id>` | | the user | | `DELETE /account/passkeys/<id>` | | the user |
| `POST /account/profile` | `{"current_password", "current_code"}` | `{"name", "profile"}`, the user's OpenVPN client profile |
| `POST /account/apple-profile` | `{"current_password", "current_code", "password"}` | `{"name", "profile"}`, the user's Apple configuration profile of IKEv2 road-warrior access (see Apple VPN profile) |
`current_code` is needed only for a user enrolled for TOTP; removing a passkey needs neither. A wrong current password or code gets `current_code` is needed only for a user enrolled for TOTP; removing a passkey needs neither. A wrong current password or code gets
403 and counts as a failed login; slapd accepts each code once, so a code used to sign in is not 403 and counts as a failed login; slapd accepts each code once, so a code used to sign in is not
@@ -1308,7 +1331,7 @@ OpenVPN (`openvpn`) and WireGuard (`wireguard`), with the same token as `/users`
| Request | Body | Answer | | Request | Body | Answer |
| --- | --- | --- | | --- | --- | --- |
| `GET /remote-access` | | `{"p2s", "openvpn", "wireguard", "running", "local_fqdn", "wireguard_server"}`; `wireguard_server` is `{"public_key", "endpoint", "allowed_ips"}`: the router's WireGuard public key (`null` before setup made it), `<wan.local_fqdn>:<wireguard.listen_port>` (`null` without a name) and the networks a peer reaches through the tunnel, the router's local CIDRs and the WireGuard network | | `GET /remote-access` | | `{"p2s", "openvpn", "wireguard", "running", "local_fqdn", "wireguard_server"}`; `wireguard_server` is `{"public_key", "endpoint", "allowed_ips", "dns"}`: the router's WireGuard public key (`null` before setup made it), `<wan.local_fqdn>:<wireguard.listen_port>` (`null` without a name), the networks a peer reaches through the tunnel, the router's local CIDRs and the WireGuard network, and the addresses of the remote access clients' resolver (`[]` while it does not run) |
| `PUT /remote-access/<system>` | any of the settings of the section `p2s`, `openvpn` or `wireguard` | the section | | `PUT /remote-access/<system>` | any of the settings of the section `p2s`, `openvpn` or `wireguard` | the section |
`running` tells for each system whether setup runs it. IKEv2 and OpenVPN run once enabled with an `running` tells for each system whether setup runs it. IKEv2 and OpenVPN run once enabled with an
@@ -1573,9 +1596,9 @@ missing, without the recommended bind backend.
Records added with `/dns` or the Router Manager (A, AAAA, CNAME, TXT, MX, SRV, CAA) are served 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 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, run; the SOA's serial grows only when the records change. While P2S, OpenVPN or WireGuard is enabled,
systemd-resolved answers their clients on port 53 of the addresses of `p2s.dns_listen`, by default dnsmasq answers their clients on port 53 of the addresses of `p2s.dns_listen`, by default the
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 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. a name server inside the zone.
```sh ```sh
+52
View File
@@ -1,3 +1,55 @@
vpn-router (0.32.1-1) unstable; urgency=medium
fix: a VPN user downloads the own OpenVPN client profile through /account, with the current password and code.
* POST /account/profile answers the user's OpenVPN client profile; it
needs the current password and, when enrolled, a code.
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Tue, 27 Oct 2026 16:00:00 +0100
vpn-router (0.32.0-1) unstable; urgency=medium
feature: the web service gives Apple VPN profiles that set up IKEv2 road-warrior access on macOS, iOS and iPadOS.
* POST /users/<name>/apple-profile and POST /account/apple-profile answer
an unsigned .mobileconfig with the P2S CA and the root CA, the user's
certificate and key as a PKCS#12 encrypted with the password of the
request, and an IKEv2 VPN to wan.local_fqdn with EAP-TLS and the
proposals of the road-warrior connection.
* A user's own profile needs the current password and, when enrolled, a
code.
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Tue, 27 Oct 2026 14:00:00 +0100
vpn-router (0.31.3-1) unstable; urgency=medium
fix: WireGuard clients may use the router's DNS resolver, which runs while WireGuard does, and /remote-access names its addresses for their configuration.
* The firewall opens port 53 of the remote access clients' resolver for
the WireGuard network, as it does for the IKEv2 and OpenVPN pools.
* The resolver runs while WireGuard is the only remote access enabled.
* GET /remote-access answers the resolver's addresses as
wireguard_server.dns.
* dns.listen may not share an interface with p2s.dns_listen while
WireGuard is enabled either.
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Tue, 27 Oct 2026 12:00:00 +0100
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 <slawek@koszewscy.waw.pl> Tue, 27 Oct 2026 11:00:00 +0100
vpn-router (0.31.1-1) unstable; urgency=medium 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. 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.
+1
View File
@@ -38,6 +38,7 @@ Depends: ${misc:Depends},
openvpn-auth-ldap, openvpn-auth-ldap,
iproute2, iproute2,
systemd-resolved, systemd-resolved,
dnsmasq-base,
zstd zstd
Recommends: bash-completion Recommends: bash-completion
Suggests: caddy, Suggests: caddy,
+1
View File
@@ -46,6 +46,7 @@ case "$1" in
rm -f /etc/swanctl/conf.d/site-to-site.conf \ rm -f /etc/swanctl/conf.d/site-to-site.conf \
/etc/swanctl/conf.d/road-warrior.conf \ /etc/swanctl/conf.d/road-warrior.conf \
/etc/systemd/resolved.conf.d/p2s-forwarder.conf \ /etc/systemd/resolved.conf.d/p2s-forwarder.conf \
/etc/vpn-router/dnsmasq.conf \
/etc/wireguard/wg0.conf \ /etc/wireguard/wg0.conf \
/etc/openvpn/server/vpn-router.conf \ /etc/openvpn/server/vpn-router.conf \
/etc/openvpn/server/vpn-router-ldap.conf \ /etc/openvpn/server/vpn-router-ldap.conf \
+1
View File
@@ -12,4 +12,5 @@ override_dh_installsystemd:
dh_installsystemd --name=vpn-router-pki --no-enable --no-start 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-caddy --no-enable --no-start
dh_installsystemd --name=vpn-router-webdav --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 dh_installsystemd --name=vpn-router-install@ --no-enable --no-start
@@ -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
+21 -1
View File
@@ -81,6 +81,9 @@ CADDY_PKI_DIR = pathlib.Path('/etc/caddy/vpn-router-pki')
#: The certificates and keys of the sites of tls file, <site>/cert.pem and key.pem. #: The certificates and keys of the sites of tls file, <site>/cert.pem and key.pem.
CADDY_SITES_DIR = pathlib.Path('/etc/caddy/vpn-router-sites') 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. #: The unit of rclone's WebDAV server of the sites' folders.
WEBDAV_UNIT = 'vpn-router-webdav.service' WEBDAV_UNIT = 'vpn-router-webdav.service'
@@ -240,7 +243,8 @@ def filter_block(ctx):
lines += ['# OPENVPN RULES END'] lines += ['# OPENVPN RULES END']
pools = [pool for ready, pool in ((vpnrouter.p2s_ready(ctx), ctx['p2s_address_pool']), pools = [pool for ready, pool in ((vpnrouter.p2s_ready(ctx), ctx['p2s_address_pool']),
(vpnrouter.openvpn_ready(ctx), ctx['openvpn_pool'])) if ready] (vpnrouter.openvpn_ready(ctx), ctx['openvpn_pool']),
(vpnrouter.wg_ready(ctx), ctx['wg_network'])) if ready]
# The remote access clients' resolver answers on the addresses of p2s.dns_listen. # The remote access clients' resolver answers on the addresses of p2s.dns_listen.
if pools and ctx['p2s_resolver_addrs']: if pools and ctx['p2s_resolver_addrs']:
lines += ['', '# P2S DNS RULES START'] lines += ['', '# P2S DNS RULES START']
@@ -676,6 +680,19 @@ def caddy_address(host, port, tls):
return f'{"http://" if tls == "none" else ""}{host}:{port}' 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): 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 """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. stop it when not.
@@ -937,6 +954,7 @@ def refresh_web(cfg, ctx):
'internal_address': ctx['int_addr'], 'internal_address': ctx['int_addr'],
'internal_gateway': ctx['int_gateway_ip'], 'internal_gateway': ctx['int_gateway_ip'],
'local_cidrs': ctx['local_cidr_list'], 'local_cidrs': ctx['local_cidr_list'],
'client_dns': ctx['p2s_resolver_addrs'] if vpnrouter.RESOLVER_CONF.exists() else [],
'site_to_site': [{ 'site_to_site': [{
'name': connection['name'], 'name': connection['name'],
'local_identity': connection['local_id'], 'local_identity': connection['local_id'],
@@ -1062,6 +1080,8 @@ def main():
if 'resolved' in changed: if 'resolved' in changed:
progress('running', 'resolver') progress('running', 'resolver')
run(['systemctl', 'restart', 'systemd-resolved'], check=False) run(['systemctl', 'restart', 'systemd-resolved'], check=False)
progress('running', 'resolver of the remote access clients')
manage_resolver(changed)
progress('running', 'platform module') progress('running', 'platform module')
run_platform_setup(cfg, ctx) run_platform_setup(cfg, ctx)
@@ -36,7 +36,10 @@ TEMPLATE_DIR = pathlib.Path('/usr/share/vpn-router/templates')
SWANCTL_CONF_D = pathlib.Path('/etc/swanctl/conf.d') SWANCTL_CONF_D = pathlib.Path('/etc/swanctl/conf.d')
SITE_TO_SITE = SWANCTL_CONF_D / 'site-to-site.conf' SITE_TO_SITE = SWANCTL_CONF_D / 'site-to-site.conf'
ROAD_WARRIOR = SWANCTL_CONF_D / 'road-warrior.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') 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') WG_CONF = pathlib.Path('/etc/wireguard/wg0.conf')
#: The router's WireGuard public key, which setup writes with its private key. #: The router's WireGuard public key, which setup writes with its private key.
WG_PUBLIC_KEY = pathlib.Path('/etc/wireguard/wg0.pub') WG_PUBLIC_KEY = pathlib.Path('/etc/wireguard/wg0.pub')
@@ -585,11 +588,11 @@ def validate(cfg):
if resolver_listen not in LISTEN_CHOICES: if resolver_listen not in LISTEN_CHOICES:
raise ConfigError(f'p2s.dns_listen: {resolver_listen!r} is not one of {", ".join(LISTEN_CHOICES)}') raise ConfigError(f'p2s.dns_listen: {resolver_listen!r} is not one of {", ".join(LISTEN_CHOICES)}')
sides = {'internal': {'internal'}, 'external': {'external'}, 'both': {'internal', 'external'}} sides = {'internal': {'internal'}, 'external': {'external'}, 'both': {'internal', 'external'}}
if dns['enabled'] and (_get(cfg, 'p2s', 'enabled') or _get(cfg, 'openvpn', 'enabled')) and \ remote_access = any(_get(cfg, system, 'enabled') for system in ('p2s', 'openvpn', 'wireguard'))
sides[dns['listen']] & sides[resolver_listen]: if dns['enabled'] and remote_access and sides[dns['listen']] & sides[resolver_listen]:
raise ConfigError(f'dns.listen: port 53 of the {" and ".join(sorted(sides[dns["listen"]] & sides[resolver_listen]))} ' raise ConfigError(f'dns.listen: port 53 of the {" and ".join(sorted(sides[dns["listen"]] & sides[resolver_listen]))} '
f'addresses is the resolver of the remote access clients (p2s.dns_listen {resolver_listen}) ' f'addresses is the resolver of the remote access clients (p2s.dns_listen {resolver_listen}) '
'while p2s.enabled or openvpn.enabled is true') 'while p2s.enabled, openvpn.enabled or wireguard.enabled is true')
for option in ('root_name', 'p2s_name', 's2s_name'): for option in ('root_name', 'p2s_name', 's2s_name'):
value = _get(cfg, 'pki', option) value = _get(cfg, 'pki', option)
@@ -990,6 +993,8 @@ def build_context(cfg, public_ip=None):
'p2s_dns': _join(resolver_addrs, _split_list(extras['EXTRA_P2S_DNS'])), 'p2s_dns': _join(resolver_addrs, _split_list(extras['EXTRA_P2S_DNS'])),
'wg_enabled': _get(cfg, 'wireguard', 'enabled'), 'wg_enabled': _get(cfg, 'wireguard', 'enabled'),
'wg_address': _get(cfg, 'wireguard', 'address'), 'wg_address': _get(cfg, 'wireguard', 'address'),
'wg_network': str(ipaddress.ip_interface(_get(cfg, 'wireguard', 'address')).network)
if _get(cfg, 'wireguard', 'address') else '',
'wg_listen_port': _get(cfg, 'wireguard', 'listen_port'), 'wg_listen_port': _get(cfg, 'wireguard', 'listen_port'),
'wg_peers_begin': WG_PEERS_BEGIN, 'wg_peers_begin': WG_PEERS_BEGIN,
'wg_peers_end': WG_PEERS_END, 'wg_peers_end': WG_PEERS_END,
@@ -1172,7 +1177,7 @@ def configure(cfg=None, public_ip=None):
"""Render configuration files and provision PKI. """Render configuration files and provision PKI.
Returns (ctx, changed) where changed is a set naming what needs reloading: 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: if cfg is None:
cfg = load_config() cfg = load_config()
@@ -1229,12 +1234,15 @@ def configure(cfg=None, public_ip=None):
elif remove(ROAD_WARRIOR): elif remove(ROAD_WARRIOR):
changed.add('swanctl') changed.add('swanctl')
# The resolver of the remote access clients answers on the addresses of p2s.dns_listen only. # The resolver of the remote access clients is dnsmasq on the addresses of p2s.dns_listen; systemd-resolved keeps
if (p2s_ready(ctx) or openvpn_ready(ctx)) and ctx['p2s_resolver_addrs']: # its loopback stub only, so its records of the router's own name stay on the router.
if render(env, ctx, 'p2s-forwarder.conf.j2', P2S_FORWARDER, 0o644): if remove(P2S_FORWARDER):
changed.add('resolved')
elif remove(P2S_FORWARDER):
changed.add('resolved') changed.add('resolved')
if (p2s_ready(ctx) or openvpn_ready(ctx) or wg_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 wg_ready(ctx):
if render_wg_conf(env, ctx): if render_wg_conf(env, ctx):
@@ -4,8 +4,8 @@ No platform-specific configuration is currently required.
The Azure platform resolver 168.63.129.16 is deliberately *not* advertised to 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 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 given the router's addresses of p2s.dns_listen as their DNS servers, where dnsmasq
forwards through its own systemd-resolved via the p2s-forwarder drop-in. That resolves forwards to the router's own systemd-resolved. That resolves
Azure-internal names without routing the wireserver address through the tunnel Azure-internal names without routing the wireserver address through the tunnel
and without a second resolver in the pool. and without a second resolver in the pool.
""" """
@@ -65,16 +65,22 @@ import fcntl
import grp import grp
import ipaddress import ipaddress
import json import json
import plistlib
import pwd import pwd
import re import re
import subprocess import subprocess
import time import time
import urllib.parse import urllib.parse
import uuid
import ldap import ldap
import ldap.filter import ldap.filter
import ldap.modlist import ldap.modlist
import pyotp import pyotp
from cryptography import x509
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import ec, rsa
from cryptography.hazmat.primitives.serialization import pkcs12
import vpnrouter import vpnrouter
import vpnrouter_ldap import vpnrouter_ldap
@@ -807,6 +813,72 @@ def profile(connection, name):
return {'name': name, 'profile': text} return {'name': name, 'profile': text}
def apple_profile(connection, name, password):
"""The Apple configuration profile (.mobileconfig, unsigned) of the user's IKEv2 road-warrior access: the root CA and
the P2S CA, the user's certificate and key as a PKCS#12 encrypted with the password, and the VPN.
The connection matches road-warrior.conf: the router's wan.local_fqdn as address and identity, the user name, a
DNS name of the certificate, as the local identity, the certificate over EAP-TLS, and AES-256, SHA2-256 and group 14
for IKE and ESP. The PKCS#12 uses 3DES and SHA-1, the only encryption Apple's clients read; they ask for the
password when the profile is installed.
"""
entry(connection, name)
if not isinstance(password, str) or not PASSWORD_MIN <= len(password) <= PASSWORD_MAX:
raise Failure(f'the password of the profile must have {PASSWORD_MIN} to {PASSWORD_MAX} characters', 2)
cfg = vpnrouter.load_config()
fqdn = cfg['wan']['local_fqdn'].strip()
if not cfg['p2s']['enabled'] or not fqdn:
raise Failure('IKEv2 road-warrior access is not enabled', 2)
credentials = vpnrouter_pki.user_credentials(name)
if credentials is None:
raise Failure(f'user {name} has no certificate')
try:
chain = x509.load_pem_x509_certificates(vpnrouter_pki.bundle('p2s')['pem'].encode())
except vpnrouter_pki.Failure as failure:
raise Failure(str(failure), 4)
cert = x509.load_pem_x509_certificate(credentials[0])
key = serialization.load_pem_private_key(credentials[1], password=None)
encryption = (serialization.PrivateFormat.PKCS12.encryption_builder().kdf_rounds(2048)
.key_cert_algorithm(pkcs12.PBES.PBESv1SHA1And3KeyTripleDESCBC).hmac_hash(hashes.SHA1())
.build(password.encode()))
bundle = pkcs12.serialize_key_and_certificates(name.encode(), key, cert, None, encryption)
certificate_type = ('RSA' if isinstance(key, rsa.RSAPrivateKey)
else 'ECDSA384' if isinstance(key, ec.EllipticCurvePrivateKey) and key.curve.key_size == 384
else 'ECDSA256')
identifier = f'{".".join(reversed(fqdn.split(".")))}.vpn-router.{name}'
ids = {kind: str(uuid.uuid5(uuid.NAMESPACE_URL, f'{identifier}.{kind}')) for kind in ('profile', 'pkcs12', 'vpn')}
proposal = {'EncryptionAlgorithm': 'AES-256', 'IntegrityAlgorithm': 'SHA2-256', 'DiffieHellmanGroup': 14,
'LifeTimeInMinutes': 60}
payloads = [{'PayloadType': 'com.apple.security.root' if index == len(chain) - 1 else 'com.apple.security.pkcs1',
'PayloadVersion': 1, 'PayloadIdentifier': f'{identifier}.ca{index}',
'PayloadUUID': str(uuid.uuid5(uuid.NAMESPACE_URL, f'{identifier}.ca{index}')),
'PayloadDisplayName': vpnrouter_pki.common_name(item),
'PayloadContent': item.public_bytes(serialization.Encoding.DER)}
for index, item in enumerate(chain)]
payloads.append({'PayloadType': 'com.apple.security.pkcs12', 'PayloadVersion': 1,
'PayloadIdentifier': f'{identifier}.pkcs12', 'PayloadUUID': ids['pkcs12'],
'PayloadDisplayName': f'{name} ({fqdn})', 'PayloadContent': bundle})
payloads.append({
'PayloadType': 'com.apple.vpn.managed', 'PayloadVersion': 1, 'PayloadIdentifier': f'{identifier}.vpn',
'PayloadUUID': ids['vpn'], 'PayloadDisplayName': f'VPN Router {fqdn}', 'UserDefinedName': f'VPN Router {fqdn}',
'VPNType': 'IKEv2',
'IKEv2': {
'RemoteAddress': fqdn, 'RemoteIdentifier': fqdn, 'LocalIdentifier': name,
'AuthenticationMethod': 'Certificate', 'ExtendedAuthEnabled': 1, 'PayloadCertificateUUID': ids['pkcs12'],
'CertificateType': certificate_type,
'ServerCertificateIssuerCommonName': vpnrouter_pki.common_name(chain[0]), 'ServerCertificateCommonName': fqdn,
'IKESecurityAssociationParameters': {**proposal, 'LifeTimeInMinutes': 480},
'ChildSecurityAssociationParameters': proposal,
'EnablePFS': 0, 'DeadPeerDetectionRate': 'Medium', 'DisableMOBIKE': 0, 'DisableRedirect': 0,
},
})
document = {'PayloadType': 'Configuration', 'PayloadVersion': 1, 'PayloadIdentifier': identifier,
'PayloadUUID': ids['profile'], 'PayloadDisplayName': f'VPN Router {fqdn} ({name})',
'PayloadDescription': f'IKEv2 access of {name} to {fqdn}', 'PayloadRemovalDisallowed': False,
'PayloadContent': payloads}
return {'name': name, 'profile': plistlib.dumps(document).decode()}
def group_dn(name): def group_dn(name):
return f'cn={name},{vpnrouter_ldap.GROUPS}' return f'cn={name},{vpnrouter_ldap.GROUPS}'
+30 -9
View File
@@ -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. #: 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', 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', '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. #: 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 LOG_LINES_MAX = 1000
@@ -565,7 +565,10 @@ def users(state, method, path, body):
POST /users/<name>/unlock end a lockout after failed logins POST /users/<name>/unlock end a lockout after failed logins
DELETE /users/<name> remove the user and revoke the certificate DELETE /users/<name> remove the user and revoke the certificate
GET /users/<name>/profile the OpenVPN client profile GET /users/<name>/profile the OpenVPN client profile
POST /users/<name>/otp a new TOTP secret and its otpauth URI; nothing is stored POST /users/<name>/apple-profile
the Apple configuration profile of IKEv2 road-warrior access: {"password"}, which
encrypts the user's key in it; {"name", "profile"}
POST /users/<name>/otp a new TOTP secret and its otpauth URI; nothing is stored
PUT /users/<name>/otp store a TOTP secret: {"secret", "code"}, code a code of the secret PUT /users/<name>/otp store a TOTP secret: {"secret", "code"}, code a code of the secret
DELETE /users/<name>/otp remove the TOTP token DELETE /users/<name>/otp remove the TOTP token
DELETE /users/<name>/passkeys remove all the user's passkeys DELETE /users/<name>/passkeys remove all the user's passkeys
@@ -592,6 +595,8 @@ def users(state, method, path, body):
('POST', 3, 'unlock'): lambda connection: vpnrouter_users.unlock(connection, name), ('POST', 3, 'unlock'): lambda connection: vpnrouter_users.unlock(connection, name),
('DELETE', 2, None): lambda connection: vpnrouter_users.remove(connection, name), ('DELETE', 2, None): lambda connection: vpnrouter_users.remove(connection, name),
('GET', 3, 'profile'): lambda connection: vpnrouter_users.profile(connection, name), ('GET', 3, 'profile'): lambda connection: vpnrouter_users.profile(connection, name),
('POST', 3, 'apple-profile'): lambda connection: vpnrouter_users.apple_profile(connection, name,
body.get('password')),
('POST', 3, 'otp'): lambda connection: vpnrouter_users.otp_new(connection, name), ('POST', 3, 'otp'): lambda connection: vpnrouter_users.otp_new(connection, name),
('PUT', 3, 'otp'): lambda connection: vpnrouter_users.otp_enrol(connection, name, body.get('secret'), body.get('code')), ('PUT', 3, 'otp'): lambda connection: vpnrouter_users.otp_enrol(connection, name, body.get('secret'), body.get('code')),
('DELETE', 3, 'otp'): lambda connection: vpnrouter_users.otp_remove(connection, name), ('DELETE', 3, 'otp'): lambda connection: vpnrouter_users.otp_remove(connection, name),
@@ -888,9 +893,10 @@ def backup(method, path, body):
def wireguard_server(cfg, state): def wireguard_server(cfg, state):
"""What a WireGuard peer's configuration needs of the router: {public_key, endpoint, allowed_ips}; public_key is """What a WireGuard peer's configuration needs of the router: {public_key, endpoint, allowed_ips, dns}; public_key is
null before setup made the key pair, endpoint, <wan.local_fqdn>:<wireguard.listen_port>, null without a name, and null before setup made the key pair, endpoint, <wan.local_fqdn>:<wireguard.listen_port>, null without a name,
allowed_ips the networks reached through the tunnel, the router's local CIDRs and the WireGuard network.""" allowed_ips the networks reached through the tunnel, the router's local CIDRs and the WireGuard network, and dns the
addresses of the remote access clients' resolver, [] while it does not run."""
try: try:
public_key = vpnrouter.WG_PUBLIC_KEY.read_text(encoding='utf-8').strip() or None public_key = vpnrouter.WG_PUBLIC_KEY.read_text(encoding='utf-8').strip() or None
except OSError: except OSError:
@@ -901,8 +907,10 @@ def wireguard_server(cfg, state):
allowed = [cidr for cidr in networks if isinstance(cidr, str)] if isinstance(networks, list) else [] allowed = [cidr for cidr in networks if isinstance(cidr, str)] if isinstance(networks, list) else []
if address: if address:
allowed.append(str(ipaddress.ip_interface(address).network)) allowed.append(str(ipaddress.ip_interface(address).network))
dns = section(state, 'network').get('client_dns')
return {'public_key': public_key, 'endpoint': f'{fqdn}:{cfg["wireguard"]["listen_port"]}' if fqdn else None, return {'public_key': public_key, 'endpoint': f'{fqdn}:{cfg["wireguard"]["listen_port"]}' if fqdn else None,
'allowed_ips': list(dict.fromkeys(allowed))} 'allowed_ips': list(dict.fromkeys(allowed)),
'dns': [address for address in dns if isinstance(address, str)] if isinstance(dns, list) else []}
def remote_access(state, method, path, body): def remote_access(state, method, path, body):
@@ -1040,8 +1048,15 @@ def account(name, method, path, body):
PUT /account/passkeys store the passkey: {"id", "credential", "label"?}, credential the browser's answer PUT /account/passkeys store the passkey: {"id", "credential", "label"?}, credential the browser's answer
DELETE /account/passkeys/<id> DELETE /account/passkeys/<id>
remove a passkey remove a passkey
POST /account/profile the user's OpenVPN client profile: {"current_password", "current_code"?}; {"name",
"profile"}
POST /account/apple-profile
the user's Apple configuration profile of IKEv2 road-warrior access:
{"current_password", "current_code"?, "password"}, password encrypting the user's key in
it; {"name", "profile"}
A change but the removal of a passkey needs the current password and, for an enrolled user, a current code; a wrong A change but the removal of a passkey, and the profiles, need the current password and, for an enrolled user, a
current code; a wrong
one is 403 and counts as a failed login. A password change revokes the user's refresh tokens. one is 403 and counts as a failed login. A password change revokes the user's refresh tokens.
""" """
if body == 'invalid' or (body is not None and not isinstance(body, dict)): if body == 'invalid' or (body is not None and not isinstance(body, dict)):
@@ -1050,12 +1065,14 @@ def account(name, method, path, body):
route = (method, path.rstrip('/')) route = (method, path.rstrip('/'))
removing = method == 'DELETE' and path.startswith('/account/passkeys/') and len(path.strip('/').split('/')) == 3 removing = method == 'DELETE' and path.startswith('/account/passkeys/') and len(path.strip('/').split('/')) == 3
if not removing and route not in (('GET', '/account'), ('PUT', '/account/password'), ('POST', '/account/otp'), if not removing and route not in (('GET', '/account'), ('PUT', '/account/password'), ('POST', '/account/otp'),
('PUT', '/account/otp'), ('POST', '/account/passkeys'), ('PUT', '/account/passkeys')): ('PUT', '/account/otp'), ('POST', '/account/passkeys'), ('PUT', '/account/passkeys'),
('POST', '/account/profile'), ('POST', '/account/apple-profile')):
return 404, {'error': f'no route {method} {path}'} return 404, {'error': f'no route {method} {path}'}
try: try:
if route == ('PUT', '/account/password'): if route == ('PUT', '/account/password'):
vpnrouter_users.check_password(body.get('password')) vpnrouter_users.check_password(body.get('password'))
if route in (('PUT', '/account/password'), ('PUT', '/account/otp'), ('POST', '/account/passkeys')): if route in (('PUT', '/account/password'), ('PUT', '/account/otp'), ('POST', '/account/passkeys'),
('POST', '/account/profile'), ('POST', '/account/apple-profile')):
vpnrouter_users.login(name, body.get('current_password'), body.get('current_code')) vpnrouter_users.login(name, body.get('current_password'), body.get('current_code'))
with vpnrouter_users.session() as connection: with vpnrouter_users.session() as connection:
if route == ('GET', '/account'): if route == ('GET', '/account'):
@@ -1065,6 +1082,10 @@ def account(name, method, path, body):
document = vpnrouter_users.passwd(connection, name, body['password']) document = vpnrouter_users.passwd(connection, name, body['password'])
elif route == ('POST', '/account/otp'): elif route == ('POST', '/account/otp'):
document = vpnrouter_users.otp_new(connection, name) document = vpnrouter_users.otp_new(connection, name)
elif route == ('POST', '/account/profile'):
document = vpnrouter_users.profile(connection, name)
elif route == ('POST', '/account/apple-profile'):
document = vpnrouter_users.apple_profile(connection, name, body.get('password'))
elif route == ('POST', '/account/passkeys'): elif route == ('POST', '/account/passkeys'):
document = vpnrouter_passkeys.register_begin(connection, name) document = vpnrouter_passkeys.register_begin(connection, name)
elif route == ('PUT', '/account/passkeys'): elif route == ('PUT', '/account/passkeys'):
@@ -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
@@ -1,4 +0,0 @@
[Resolve]
{% for address in p2s_resolver_addrs -%}
DNSStubListenerExtra={{ address }}
{% endfor -%}