feature: the web service gives Apple VPN profiles that set up IKEv2 road-warrior access on macOS, iOS and iPadOS.
Release / release (push) Successful in 14s

This commit is contained in:
slawek committed 2026-10-26 20:37:58 +01:00
1 parent 770332728e
commit 74c24d7bc6
5 files changed
+142 -4

No files matched your search

@@ -65,16 +65,22 @@ import fcntl
import grp
import ipaddress
import json
import plistlib
import pwd
import re
import subprocess
import time
import urllib.parse
import uuid
import ldap
import ldap.filter
import ldap.modlist
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_ldap
@@ -807,6 +813,72 @@ def profile(connection, name):
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):
return f'cn={name},{vpnrouter_ldap.GROUPS}'