StrongSwan IKEv2 split/full tunnel VPN for Apple devices
The built-in IKEv2 client on iOS and macOS supports VPN On Demand: it connects and disconnects automatically by network rules, without any third-party apps. Here we set up a StrongSwan server at home with two tunnels: an On-Demand split tunnel with split DNS, and a manual full tunnel for untrusted networks. Clients are configured with a single .mobileconfig file.
Originally this ran in an Alpine Linux VM on Synology; now it runs on a Raspberry Pi with Debian. Any small Linux host will do.
Background
I needed to have a VPN server at home that I would connect to from anywhere to enjoy:
- Security of an encrypted tunnel
- Access to Web content filter deployed at home
- Access to machines in my home network as needed
Requirements
Nothing unreasonable here:
- Built-in client on iOS and macOS. No third-party apps to install, update, and tolerate.
- Configuration on a client should be simple. Ideally – one-click.
- Connect automatically away from home, disconnect at home, and be resilient to network changes.
- VPN devices shall reach the LAN.
- DNS resolution shall work.
- Support for a full tunnel and split tunnel, including split-DNS mode, and quick switching between the two.
- Certificate-based authentication is ideal, with per-device revocation.
Why not WireGuard or OpenVPN
Both work; I use WireGuard elsewhere. But on Apple devices both require an app. The OpenVPN app has atrocious UI and quality issues. The WireGuard app is fine and even supports on-demand rules, but it is still an app, and there is no PKI: keys are static, and revoking a device means editing the server configuration. IKEv2 is the only one of the three that the OS supports natively, and the only one Apple allows for enforced Always-On VPN on supervised devices.
Assumptions
LAN: 10.0.17.0/24
DNS: 10.0.17.1
DNS domain: home.example.com
VPN server: 10.0.17.250, reserved in DHCP
Interface: eth0, IPv6 address via SLAAC
Public FQDN: vpn.example.com, DDNS to the gateway's WAN address
Split pool: 10.0.26.128/25
Full pool: 10.0.26.0/25, fd00:0:26::/120
Users: greg, emili
The client selects the tunnel by the Remote ID: split-tunnel.vpn.example.com or full-tunnel.vpn.example.com. These names don’t need to resolve, but must be listed in the server certificate.
Installing StrongSwan
Install the charon-systemd + swanctl stack; the legacy ipsec/starter is deprecated:
apt install charon-systemd strongswan-swanctl strongswan-pki nftables
PKI
We’ll make our own CA. Keep its key in /root/ikev2-pki and only copy what the daemon needs into /etc/swanctl. The names used in certificates go into vars.sh, which the other scripts below source as well.
#!/bin/bash
set -eu
mkdir -p /root/ikev2-pki && cd /root/ikev2-pki
umask 077
cat > vars.sh <<'EOF'
C="ZU"
O="Home Sweet Home"
DOMAIN="example.com"
SERVER="vpn.$DOMAIN"
EOF
source ./vars.sh
# CA, 10 years
pki --gen --outform pem > ca_key.pem
pki --self --ca --lifetime 3650 \
--in ca_key.pem \
--dn "C=$C, O=$O, CN=Greg and Emili Household Root CA" \
--outform pem > ca_cert.pem
# Server, 2 years. Note the multiple --san: the Remote IDs must be there.
pki --gen --outform pem > server_key.pem
pki --issue --lifetime 730 \
--in server_key.pem --type priv \
--cacert ca_cert.pem --cakey ca_key.pem \
--dn "C=$C, O=$O, CN=$SERVER" \
--san "$SERVER" \
--san "full-tunnel.$SERVER" \
--san "split-tunnel.$SERVER" \
--flag serverAuth --flag ikeIntermediate \
--outform pem > server_cert.pem
# Empty CRL, 10 years. Needed for revocation = strict, see below.
pki --signcrl --lifetime 3650 \
--cacert ca_cert.pem --cakey ca_key.pem \
--outform pem > crl.pem
cp ca_cert.pem /etc/swanctl/x509ca/
cp server_cert.pem /etc/swanctl/x509/
cp server_key.pem /etc/swanctl/private/
cp crl.pem /etc/swanctl/x509crl/
Notes:
pkigenerates RSA keys by default. Keep it that way – Apple profiles assume RSA unless told otherwise.- We set lifetimes explicitly:
pkidefaults to 3 years for certificates and 15 days for the CRL. When the server certificate expires, all clients stop connecting at once. Put the renewal date on a calendar, see Renewing certificates. ikeIntermediateis required: macOS refuses server certificates without it.
Issue a client certificate per device, so that a lost phone can be revoked without affecting the laptop. The CN is used as the client identity.
cd /root/ikev2-pki
source ./vars.sh
function generate_client(){
name="$1"
id="$name@$DOMAIN"
pki --gen --outform pem > "${name}_key.pem"
pki --issue --lifetime 730 \
--in "${name}_key.pem" --type priv \
--cacert ca_cert.pem --cakey ca_key.pem \
--dn "C=$C, O=$O, CN=$id" \
--san "$id" \
--outform pem > "${name}_cert.pem"
}
generate_client "greg-iphone"
generate_client "greg-macbook"
generate_client "emili-iphone"
Client keys don’t need to be in /etc/swanctl – the server only needs the CA to validate them.
Server configuration
/etc/swanctl/conf.d/home-vpn.conf
connections {
home-split {
version = 2
proposals = aes256gcm16-prfsha256-ecp256,aes256-sha256-modp2048
send_cert = always
pools = vpn-split
encap = yes
mobike = yes
fragmentation = yes
dpd_delay = 30s
reauth_time = 0s
rekey_time = 3h
unique = never
local {
auth = pubkey
certs = server_cert.pem
id = split-tunnel.vpn.example.com
}
remote {
auth = pubkey
cacerts = ca_cert.pem
revocation = strict
}
children {
home-split {
local_ts = 10.0.17.0/24
esp_proposals = aes256gcm16,aes256-sha256
dpd_action = clear
rekey_time = 1h
}
}
}
home-full : connections.home-split {
pools = vpn-full,vpn-full-v6
local {
id = full-tunnel.vpn.example.com
}
children {
home-split {
local_ts = 0.0.0.0/0,::/0
}
}
}
}
pools {
vpn-split {
addrs = 10.0.26.128/25
dns = 10.0.17.1
# INTERNAL_DNS_DOMAIN, RFC 8598: split DNS on iOS
25 = home.example.com
}
vpn-full {
addrs = 10.0.26.0/25
dns = 10.0.17.1
}
vpn-full-v6 {
addrs = fd00:0:26::/120
}
}
Notes:
send_cert = alwaysis required. Apple’s client does not ask for the server certificate, and by default StrongSwan does not send it. The phone then retries forever.- The proposals include Apple’s defaults (AES-256, SHA2-256, DH group 14) that the generated profiles below use.
fragmentation = yes: IKE messages carrying certificates are large, and some networks drop them otherwise.local_tsdefines the split or full tunnel. It can be narrowed down further to ports and protocols if needed.- The full tunnel supports IPv6: the IPv6 pool and NAT66 give clients IPv6 through the server. If your home network has no IPv6, remove
::/0, the IPv6 pool, and the NAT66 rule; the full tunnel is then IPv4-only. unique = neverallows several devices to use the same identity at once, if you decide to share one profile. Note,noworks differently: a device connecting withINITIAL_CONTACTwould kick the other one off.revocation = strictrefuses clients when revocation status is unknown, hence the empty CRL above. Remove it if you don’t want to maintain a CRL.
Forwarding and NAT
VPN clients are masqueraded behind the server’s LAN address, so they can reach the LAN without routes on the gateway. The firewall lets split clients reach the LAN, and full clients reach the LAN and the internet. Other private networks are blocked.
/etc/ikev2-vpn/ikev2.nft:
# Create, then delete: reload works whether the table exists or not.
# The rest of the ruleset is left alone.
table inet ikev2_vpn
delete table inet ikev2_vpn
table inet ikev2_vpn {
chain forward {
type filter hook forward priority filter; policy drop;
ct state invalid drop
# Split tunnel: LAN only
ip saddr 10.0.26.128/25 ipsec in reqid != 0 ip daddr 10.0.17.0/24 accept
ip daddr 10.0.26.128/25 ipsec out reqid != 0 ct state established,related accept
# Full tunnel: LAN and internet, but no other private networks
ip saddr 10.0.26.0/25 ipsec in reqid != 0 ip daddr 10.0.17.0/24 accept
ip saddr 10.0.26.0/25 ipsec in reqid != 0 ip daddr { 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, 169.254.0.0/16, 100.64.0.0/10, 224.0.0.0/4 } reject
ip saddr 10.0.26.0/25 ipsec in reqid != 0 accept
ip daddr 10.0.26.0/25 ipsec out reqid != 0 ct state established,related accept
ip6 saddr fd00:0:26::/120 ipsec in reqid != 0 ip6 daddr { fc00::/7, fe80::/10, ff00::/8 } reject with icmpv6 type admin-prohibited
ip6 saddr fd00:0:26::/120 ipsec in reqid != 0 accept
ip6 daddr fd00:0:26::/120 ipsec out reqid != 0 ct state established,related accept
}
# Clamp MSS to account for encapsulation overhead, otherwise some sites hang.
chain mss {
type filter hook forward priority mangle; policy accept;
ipsec in reqid != 0 tcp flags syn tcp option maxseg size > 1360 tcp option maxseg size set 1360
ipsec out reqid != 0 tcp flags syn tcp option maxseg size > 1360 tcp option maxseg size set 1360
}
chain postrouting {
type nat hook postrouting priority srcnat; policy accept;
ipsec out reqid != 0 return
ip saddr 10.0.26.0/24 oifname "eth0" masquerade
ip6 saddr fd00:0:26::/120 oifname "eth0" masquerade
}
}
/etc/ikev2-vpn/forwarding.conf:
net.ipv4.ip_forward=1
# Single NIC: don't send ICMP redirects to LAN hosts
net.ipv4.conf.all.send_redirects=0
net.ipv4.conf.default.send_redirects=0
net.ipv4.conf.eth0.send_redirects=0
# Keep accepting router advertisements with forwarding on
net.ipv6.conf.eth0.accept_ra=2
net.ipv6.conf.all.forwarding=1
forwarding.conf is not in /etc/sysctl.d/ on purpose: forwarding shall only be turned on after the firewall table is loaded. The oneshot unit /etc/systemd/system/ikev2-firewall.service does both, in that order:
[Unit]
Description=IKEv2 forwarding and NAT
# nftables.service, if enabled, flushes the whole ruleset: reload our table after it.
# Restarting nftables.service then also restarts StrongSwan; reloading does not.
After=nftables.service
PartOf=nftables.service
ReloadPropagatedFrom=nftables.service
Before=strongswan.service
[Service]
Type=oneshot
ExecStart=/usr/sbin/nft -f /etc/ikev2-vpn/ikev2.nft
ExecStartPost=/usr/sbin/sysctl -p /etc/ikev2-vpn/forwarding.conf
ExecReload=/usr/sbin/nft -f /etc/ikev2-vpn/ikev2.nft
RemainAfterExit=yes
[Install]
WantedBy=multi-user.target
Make StrongSwan depend on it in /etc/systemd/system/strongswan.service.d/firewall.conf:
[Unit]
Requires=ikev2-firewall.service
After=ikev2-firewall.service
Starting
systemctl daemon-reload
systemctl enable --now ikev2-firewall.service
systemctl enable strongswan.service
systemctl restart strongswan.service
swanctl --list-conns
After editing the configuration, swanctl --load-all reloads it without dropping connected clients.
Gateway
- Reserve the server’s address in DHCP.
- Point
vpn.example.comto the WAN address, via DDNS if it’s dynamic. - Forward
udp/500andudp/4500to the server. No need to forward ESP, as it is encapsulated in UDP 4500.
Reaching other networks
If the home gateway already reaches another network, VPN clients can reach it too. For example, a remote LAN 10.0.18.0/24 may be connected over a site-to-site VPN such as UniFi Site Magic. The server masquerades VPN clients behind its LAN address, which the remote side already routes back, so neither gateway needs changes.
Add the subnet to the split tunnel’s local_ts:
local_ts = 10.0.17.0/24,10.0.18.0/24
and allow it in the forward chain, above the full tunnel’s private-network reject rule:
ip saddr 10.0.26.0/24 ipsec in reqid != 0 ip daddr 10.0.18.0/24 accept
Notes:
- The full tunnel already sends everything to the server, so only the firewall rule is needed for it.
- If the remote network has its own DNS domain and the home DNS server resolves it, add another
--dns-domainto the profile. - A remote subnet that overlaps with the network the device is currently on may not be reachable. Avoid popular ones like
192.168.0.0/24and192.168.1.0/24.
Client profiles
A handy dandy generate-vpn-profile.py here builds a .mobileconfig for iOS and macOS with the CA certificate and two connections, “Home VPN - Full Tunnel” and “Home VPN - Split Tunnel”. Save it to /root/ikev2-pki, next to the CA and client files. Use --name and --organization to change the labels.
The client identity goes into the profile as PKCS#12. iOS requires a non-empty import password, so we generate a random one and embed it in the profile. Nobody has to type it.
cd /root/ikev2-pki
source ./vars.sh
name="greg-iphone"
import_value=$(openssl rand -base64 32)
# -legacy -macalg sha1 for macOS, see note below
printf '%s\n' "$import_value" | openssl pkcs12 -export -legacy -macalg sha1 \
-inkey "${name}_key.pem" -in "${name}_cert.pem" -certfile ca_cert.pem \
-name "$name@$DOMAIN" \
-out "${name}.p12" -passout stdin
printf '%s\n' "$import_value" | python3 generate-vpn-profile.py \
--server "$SERVER" \
--ca ca_cert.pem \
--p12 "${name}.p12" --p12-password-file /dev/stdin \
--local-id "$name@$DOMAIN" \
--on-demand \
--home-dns-domain home.example.com \
--home-dns-server 10.0.17.1 \
--dns-server 10.0.17.1 \
--dns-domain home.example.com \
--output "${name}.mobileconfig"
unset import_value
rm "${name}.p12"
Notes:
-macalg sha1is required for macOS. With a SHA-256 MAC, iOS installs the profile fine, but macOS fails with “The certificate could not be verified (authentication error)”. Depending on the OpenSSL version,-legacyalone may still produce a SHA-256 MAC, so specify it explicitly. The MAC only protects the PKCS#12 container; certificate signatures and VPN crypto are not affected.--dns-serverand--dns-domain(both repeatable) configure split DNS: in the split tunnel only queries for those domains go to the VPN DNS server. Which networks are routed through the tunnel is still defined bylocal_tson the server.
AirDrop or iMessage the file to the device and install it in Settings. Avoid email: the profile contains the key and its import password, so the file alone is enough to connect.
Connecting automatically away from home
--on-demand turns on VPN On Demand for the split tunnel and leaves the full tunnel manual: use it on untrusted networks, or to go through home filtering. The device connects when away from home and disconnects when back.
The home network is detected by the primary network’s DNS search domain and DNS server (--home-dns-domain, --home-dns-server), which works for both Wi-Fi and Ethernet. Alternatively, specify --home-ssid "My Home WiFi", once for each home network. The rules trigger on network changes, so connections to bare IP addresses also work.
Connecting the full tunnel disconnects the split one: only one VPN connection can be active at a time.
Signing the profile
Unsigned profiles show as “Unsigned” or “Not Verified” during installation. To show “Signed by” instead, sign the profile with a publicly trusted TLS certificate, e.g. from Let’s Encrypt. This way no extra root CA needs to be trusted:
python3 generate-vpn-profile.py ... \
--signing-cert cert.pem --signing-key privkey.pem --signing-chain chain.pem
Or sign an existing file:
openssl cms -sign -binary -nodetach -outform DER -md sha256 \
-signer cert.pem -inkey privkey.pem -certfile chain.pem \
-in greg-iphone.mobileconfig -out greg-iphone-signed.mobileconfig
The signature only verifies while the signing certificate is valid. Timestamping does not help: on my iPhone a profile with an expired signer shows “Not Verified” either way. Re-sign old files before handing them out.
Renewing certificates
Server
Reissue the server certificate with the same key and SANs, and reload it. Clients trust the CA, so nothing changes on devices. Connected clients are not affected; the new certificate is used on their next connection.
cd /root/ikev2-pki
source ./vars.sh
pki --issue --lifetime 730 \
--in server_key.pem --type priv \
--cacert ca_cert.pem --cakey ca_key.pem \
--dn "C=$C, O=$O, CN=$SERVER" \
--san "$SERVER" \
--san "full-tunnel.$SERVER" \
--san "split-tunnel.$SERVER" \
--flag serverAuth --flag ikeIntermediate \
--outform pem > server_cert.pem
cp server_cert.pem /etc/swanctl/x509/
swanctl --load-creds --noprompt
Clients
Run generate_client again for the device, and build a new profile as in Client profiles. The profile identifier is derived from the client identity, so installing the new profile replaces the old one. The old certificate remains valid until it expires; revoke it if that’s a concern.
CA
The CA certificate is valid for 10 years. When it expires, create a new CA, reissue the server certificate, and install a new profile on every device.
Revoking a client
cd /root/ikev2-pki
pki --signcrl --lifetime 3650 \
--cacert ca_cert.pem --cakey ca_key.pem \
--lastcrl crl.pem --cert greg-iphone_cert.pem --reason key-compromise \
--outform pem > crl.new && mv crl.new crl.pem
cp crl.pem /etc/swanctl/x509crl/
# Flush first, otherwise the old cached CRL may stay in use
swanctl --flush-certs --type x509_crl
swanctl --load-creds --noprompt
This only blocks new connections. To disconnect the device now, find its session in swanctl --list-sas and terminate it with swanctl --terminate --ike-id <N>.
Debugging
journalctl -u strongswan -f
swanctl --list-sas
swanctl --list-pools --leases
The default log level is enough to debug most connectivity, certificate, and authentication issues. Don’t paste ip xfrm state output anywhere – it includes session keys. ip xfrm policy shows the selectors without them.
References
- swanctl.conf
- StrongSwan: Apple IKEv2 profiles
- StrongSwan: iOS
- Apple: VPN payload settings
- Split DNS Configuration for IKEv2, RFC 8598
History
| March 25, 2018 | initial publication |
| April 25, 2018 | Added Qemu guest agent configuration |
| March 14, 2019 | Clarified split-tunnel and split-DNS support |
| October 26, 2026 | Updated Alpine installation, corrected configuration paths and EAP settings, clarified certificate requirements, added startup commands, RFC 8598 split-DNS attributes, a swanctl alternative, authentication toggles, and Apple configuration profiles. |
| October 27, 2026 | Added native VPN On Demand, home-network matching, split DNS, shared-profile concurrent connections, password-free installation, and separate connection exports to the profile generator. |
| November 1, 2026 | Moved from Alpine VM on Synology to Debian on Raspberry Pi; switched to swanctl configuration; updated encryption and connection settings; added IPv6 to the full tunnel; added a firewall; per-device certificates, renewal, and revocation; added access to other networks; removed the legacy ipsec.conf setup and password authentication. |