feature: road-warrior clients resolve the domains of the new setting p2s.dns_domains with the router's resolver.
Release / release (push) Successful in 14s

This commit is contained in:
slawek committed 2026-10-26 20:58:33 +01:00
1 parent 9ca8fce14e
commit 57e8bbcffe
7 files changed
+63 -4

No files matched your search

+18
View File
@@ -1201,3 +1201,21 @@ 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. 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 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. profile too. WireGuard is not part of it.
## ADR-45: Road-warrior clients get split DNS domains, p2s.dns_domains
Status: Accepted
Context: The road-warrior access is a split tunnel. macOS and iOS use the DNS server an IKEv2 VPN
pushes only for queries scoped to the tunnel, so names only the router resolves, such as Azure's
internal names, fail. RFC 8598 defines INTERNAL_DNS_DOMAIN (attribute 25): the client resolves the
listed domains and their subdomains with the VPN's servers. strongSwan's pools take numeric
attributes with string values; Apple profiles name such domains in `SupplementalMatchDomains`.
Decision: The setting `p2s.dns_domains` lists the domains; setup pushes each as attribute 25 of the
road-warrior pool, and the Apple VPN profile gives them as match domains of the router's resolver,
the addresses setup records for the web service.
Consequences: Clients resolve the listed domains through the tunnel and everything else with their
own resolvers. Clients that ignore attribute 25 keep the scoped behaviour; the Apple profile
covers Apple devices. A changed list reaches an Apple device with a newly installed profile.
+5 -1
View File
@@ -115,6 +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_domains` | list | | The domains road-warrior clients resolve with the router's resolver (INTERNAL_DNS_DOMAIN, RFC 8598, and the match domains of the Apple VPN profile), at most 16 |
| `p2s` | `dns_listen` | string | `internal` | The addresses of the resolver the road-warrior, OpenVPN and WireGuard 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 |
@@ -788,7 +789,10 @@ that sets up road-warrior access in the built-in VPN client of macOS, iOS and iP
the profile is installed, and the router does not keep it; 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 - 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 EAP-TLS, and AES-256, SHA2-256 and group 14 for IKE and ESP, as the router's road-warrior
connection takes them. connection takes them;
- with `p2s.dns_domains`, the router's resolver for those domains and their subdomains
(`SupplementalMatchDomains`). macOS and iOS use the DNS server of a VPN that does not carry all
traffic only for its match domains.
The password is 8 to 128 characters. A user asking for the own profile also gives the current The password is 8 to 128 characters. A user asking for the own profile also gives the current
password and, when enrolled, a code. password and, when enrolled, a code.
+12
View File
@@ -1,3 +1,15 @@
vpn-router (0.33.0-1) unstable; urgency=medium
feature: road-warrior clients resolve the domains of the new setting p2s.dns_domains with the router's resolver.
* p2s.dns_domains lists up to 16 domains; setup pushes them to IKEv2
clients as INTERNAL_DNS_DOMAIN (RFC 8598, attribute 25) of the
road-warrior pool.
* The Apple VPN profile names them as match domains of the router's
resolver, which macOS and iOS use for those domains.
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Tue, 27 Oct 2026 18:00:00 +0100
vpn-router (0.32.1-1) unstable; urgency=medium 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. fix: a VPN user downloads the own OpenVPN client profile through /account, with the current password and code.
+1 -1
View File
@@ -44,7 +44,7 @@ from vpnrouter import fail, warn
BEFORE_RULES = pathlib.Path('/etc/ufw/before.rules') BEFORE_RULES = pathlib.Path('/etc/ufw/before.rules')
UFW_DEFAULTS = pathlib.Path('/etc/default/ufw') UFW_DEFAULTS = pathlib.Path('/etc/default/ufw')
WG_DIR = pathlib.Path('/etc/wireguard') WG_DIR = pathlib.Path('/etc/wireguard')
WEB_STATE = pathlib.Path('/run/vpn-router/web.json') WEB_STATE = vpnrouter.WEB_STATE
#: The state, the current step and the failure of the last run, which the bootstrap services show. #: The state, the current step and the failure of the last run, which the bootstrap services show.
SETUP_STATE = pathlib.Path('/run/vpn-router/setup.json') SETUP_STATE = pathlib.Path('/run/vpn-router/setup.json')
@@ -31,6 +31,8 @@ from vpnrouter_net import DEFAULT_LISTEN, LISTEN_CHOICES, LOOPBACK, ConfigError,
CONFIG_FILE = pathlib.Path('/etc/vpn-router/vpn-router.json') CONFIG_FILE = pathlib.Path('/etc/vpn-router/vpn-router.json')
CONFIG_LOCK = pathlib.Path('/run/vpn-router/config.lock') CONFIG_LOCK = pathlib.Path('/run/vpn-router/config.lock')
#: What setup resolved and runs, for the web service (setup's refresh_web).
WEB_STATE = pathlib.Path('/run/vpn-router/web.json')
TEMPLATE_DIR = pathlib.Path('/usr/share/vpn-router/templates') 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')
@@ -56,7 +58,7 @@ SCHEMA = {
'wan': {'local_fqdn': ''}, 'wan': {'local_fqdn': ''},
'local': {'cidrs': [], 'int_addr': '', 'int_gateway_ip': ''}, 'local': {'cidrs': [], 'int_addr': '', 'int_gateway_ip': ''},
'site_to_site': {}, 'site_to_site': {},
'p2s': {'enabled': False, 'address_pool': '', 'dns_listen': 'internal'}, 'p2s': {'enabled': False, 'address_pool': '', 'dns_listen': 'internal', 'dns_domains': []},
'wireguard': {'enabled': False, 'address': '', 'listen_port': 51820}, 'wireguard': {'enabled': False, 'address': '', 'listen_port': 51820},
'openvpn': {'enabled': False, 'address_pool': '', 'port': 1194, 'protocol': 'udp'}, 'openvpn': {'enabled': False, 'address_pool': '', 'port': 1194, 'protocol': 'udp'},
'docker': {'mode': 'auto', 'exposed_ports': []}, 'docker': {'mode': 'auto', 'exposed_ports': []},
@@ -147,6 +149,10 @@ DNS_TTL_RANGE = (60, 86400)
#: A DNS name without a wildcard, in lowercase, of dns.zone and dns.nameserver. #: A DNS name without a wildcard, in lowercase, of dns.zone and dns.nameserver.
ZONE_NAME = re.compile(r'^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?(\.[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)+$') ZONE_NAME = re.compile(r'^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?(\.[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)+$')
#: A domain of p2s.dns_domains, one label or more, and the most domains.
DNS_DOMAIN = re.compile(r'^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?(\.[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)*$')
DNS_DOMAINS_MAX = 16
#: The ports of the bootstrap services: the Router Manager's and the router's. #: The ports of the bootstrap services: the Router Manager's and the router's.
BOOTSTRAP_PORTS = (17080, 17081) BOOTSTRAP_PORTS = (17080, 17081)
@@ -584,6 +590,12 @@ def validate(cfg):
raise ConfigError(f'dns.listen: {dns["listen"]!r} is not one of {", ".join(LISTEN_CHOICES)}') raise ConfigError(f'dns.listen: {dns["listen"]!r} is not one of {", ".join(LISTEN_CHOICES)}')
if not DNS_TTL_RANGE[0] <= dns['ttl'] <= DNS_TTL_RANGE[1]: if not DNS_TTL_RANGE[0] <= dns['ttl'] <= DNS_TTL_RANGE[1]:
raise ConfigError(f'dns.ttl: {dns["ttl"]} is not between {DNS_TTL_RANGE[0]} and {DNS_TTL_RANGE[1]} seconds') raise ConfigError(f'dns.ttl: {dns["ttl"]} is not between {DNS_TTL_RANGE[0]} and {DNS_TTL_RANGE[1]} seconds')
dns_domains = _get_list(cfg, 'p2s', 'dns_domains')
if len(dns_domains) > DNS_DOMAINS_MAX or len(set(dns_domains)) != len(dns_domains):
raise ConfigError(f'p2s.dns_domains: at most {DNS_DOMAINS_MAX} different domains')
for item in dns_domains:
if not DNS_DOMAIN.match(item):
raise ConfigError(f'p2s.dns_domains: {item!r} is not a DNS name in lowercase')
resolver_listen = _get(cfg, 'p2s', 'dns_listen') resolver_listen = _get(cfg, 'p2s', 'dns_listen')
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)}')
@@ -990,6 +1002,7 @@ def build_context(cfg, public_ip=None):
'p2s_address_pool': _get(cfg, 'p2s', 'address_pool'), 'p2s_address_pool': _get(cfg, 'p2s', 'address_pool'),
'public_ip': public_ip or '', 'public_ip': public_ip or '',
'p2s_resolver_addrs': resolver_addrs, 'p2s_resolver_addrs': resolver_addrs,
'p2s_dns_domains': _get_list(cfg, 'p2s', 'dns_domains'),
'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'),
@@ -819,7 +819,7 @@ def apple_profile(connection, name, password):
The connection matches road-warrior.conf: the router's wan.local_fqdn as address and identity, the user name, a 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 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 for IKE and ESP. With p2s.dns_domains, the device resolves those domains with the router's resolver. 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. password when the profile is installed.
""" """
entry(connection, name) entry(connection, name)
@@ -858,7 +858,15 @@ def apple_profile(connection, name, password):
payloads.append({'PayloadType': 'com.apple.security.pkcs12', 'PayloadVersion': 1, payloads.append({'PayloadType': 'com.apple.security.pkcs12', 'PayloadVersion': 1,
'PayloadIdentifier': f'{identifier}.pkcs12', 'PayloadUUID': ids['pkcs12'], 'PayloadIdentifier': f'{identifier}.pkcs12', 'PayloadUUID': ids['pkcs12'],
'PayloadDisplayName': f'{name} ({fqdn})', 'PayloadContent': bundle}) 'PayloadDisplayName': f'{name} ({fqdn})', 'PayloadContent': bundle})
# The router's resolver, which setup records, answers the names of p2s.dns_domains; the others stay with the device's.
try:
servers = json.loads(vpnrouter.WEB_STATE.read_text(encoding='utf-8'))['network']['client_dns']
except (OSError, ValueError, KeyError, TypeError):
servers = []
domains = [item.strip() for item in cfg['p2s']['dns_domains'] if item.strip()]
dns = {'DNS': {'ServerAddresses': servers, 'SupplementalMatchDomains': domains}} if servers and domains else {}
payloads.append({ payloads.append({
**dns,
'PayloadType': 'com.apple.vpn.managed', 'PayloadVersion': 1, 'PayloadIdentifier': f'{identifier}.vpn', 'PayloadType': 'com.apple.vpn.managed', 'PayloadVersion': 1, 'PayloadIdentifier': f'{identifier}.vpn',
'PayloadUUID': ids['vpn'], 'PayloadDisplayName': f'VPN Router {fqdn}', 'UserDefinedName': f'VPN Router {fqdn}', 'PayloadUUID': ids['vpn'], 'PayloadDisplayName': f'VPN Router {fqdn}', 'UserDefinedName': f'VPN Router {fqdn}',
'VPNType': 'IKEv2', 'VPNType': 'IKEv2',
@@ -39,5 +39,9 @@ pools {
rw-pool { rw-pool {
addrs = {{ p2s_address_pool }} addrs = {{ p2s_address_pool }}
dns = {{ p2s_dns }} dns = {{ p2s_dns }}
{%- if p2s_dns_domains %}
# INTERNAL_DNS_DOMAIN (RFC 8598): the domains the clients resolve with the DNS servers above.
25 = {{ p2s_dns_domains | join(', ') }}
{%- endif %}
} }
} }