diff --git a/ADR.md b/ADR.md index e1e6ec9..d31a493 100644 --- a/ADR.md +++ b/ADR.md @@ -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. 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. + +## 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. diff --git a/README.md b/README.md index d5276a2..17c8344 100644 --- a/README.md +++ b/README.md @@ -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 | | `p2s` | `enabled` | boolean | `false` | Road-warrior access | | `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` | | `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 | @@ -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; - 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. + 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 password and, when enrolled, a code. diff --git a/debian-package/debian/changelog b/debian-package/debian/changelog index 557ae4f..d86b266 100644 --- a/debian-package/debian/changelog +++ b/debian-package/debian/changelog @@ -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 Tue, 27 Oct 2026 18:00:00 +0100 + 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. diff --git a/debian-package/src/usr/lib/vpn-router/setup b/debian-package/src/usr/lib/vpn-router/setup index dae83f9..ffaa2b5 100755 --- a/debian-package/src/usr/lib/vpn-router/setup +++ b/debian-package/src/usr/lib/vpn-router/setup @@ -44,7 +44,7 @@ from vpnrouter import fail, warn BEFORE_RULES = pathlib.Path('/etc/ufw/before.rules') UFW_DEFAULTS = pathlib.Path('/etc/default/ufw') 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. SETUP_STATE = pathlib.Path('/run/vpn-router/setup.json') diff --git a/debian-package/src/usr/lib/vpn-router/vpnrouter.py b/debian-package/src/usr/lib/vpn-router/vpnrouter.py index 4da13c8..984051a 100644 --- a/debian-package/src/usr/lib/vpn-router/vpnrouter.py +++ b/debian-package/src/usr/lib/vpn-router/vpnrouter.py @@ -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_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') SWANCTL_CONF_D = pathlib.Path('/etc/swanctl/conf.d') @@ -56,7 +58,7 @@ SCHEMA = { 'wan': {'local_fqdn': ''}, 'local': {'cidrs': [], 'int_addr': '', 'int_gateway_ip': ''}, '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}, 'openvpn': {'enabled': False, 'address_pool': '', 'port': 1194, 'protocol': 'udp'}, '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. 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. 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)}') 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') + 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') if resolver_listen not in 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'), 'public_ip': public_ip or '', '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'])), 'wg_enabled': _get(cfg, 'wireguard', 'enabled'), 'wg_address': _get(cfg, 'wireguard', 'address'), diff --git a/debian-package/src/usr/lib/vpn-router/vpnrouter_users.py b/debian-package/src/usr/lib/vpn-router/vpnrouter_users.py index 68375ee..dae05d3 100644 --- a/debian-package/src/usr/lib/vpn-router/vpnrouter_users.py +++ b/debian-package/src/usr/lib/vpn-router/vpnrouter_users.py @@ -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 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. """ entry(connection, name) @@ -858,7 +858,15 @@ def apple_profile(connection, name, password): payloads.append({'PayloadType': 'com.apple.security.pkcs12', 'PayloadVersion': 1, 'PayloadIdentifier': f'{identifier}.pkcs12', 'PayloadUUID': ids['pkcs12'], '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({ + **dns, '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', diff --git a/debian-package/src/usr/share/vpn-router/templates/road-warrior.conf.j2 b/debian-package/src/usr/share/vpn-router/templates/road-warrior.conf.j2 index 25b1f18..4520cf0 100644 --- a/debian-package/src/usr/share/vpn-router/templates/road-warrior.conf.j2 +++ b/debian-package/src/usr/share/vpn-router/templates/road-warrior.conf.j2 @@ -39,5 +39,9 @@ pools { rw-pool { addrs = {{ p2s_address_pool }} 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 %} } }