Network Protection
Kill Switch
The Kill Switch controls what happens to network traffic when the VPN connection drops unexpectedly. Configure it on
the KapeVPNManager before connecting:
try vpn.setKillSwitchMode(.standard) // or .advanced
.advanced requires the VpnKillSwitchAdvanced feature on your SDK license; .standard is always
available. Without it the call throws KapeFeatureNotLicensedError and the tunnel keeps using
.standard. See Licensed VPN features.
The kill switch is not available on tvOS: setKillSwitchMode(_:) throws KapeError.
Modes
| Mode | Behaviour | When to use |
|---|---|---|
.standard | The SDK continuously attempts to reconnect. Most internet traffic is blocked while reconnecting. Micro leaks are expected if the tunnel configuration has changed. System services such as CarPlay, AirDrop, and local network communication remain functional. | Recommended default for consumer VPN apps. |
.advanced | All traffic without exception must be routed through the tunnel. Any traffic that cannot be tunnelled — including system services and local network communication — is blocked until the tunnel is restored. | High-security scenarios where any unprotected traffic is unacceptable. |
.advanced mode forces all traffic — including system services like AirDrop, push notifications, and CarPlay —
through the VPN tunnel. However, these services rely on local network communication, which is inherently unreachable
via a VPN tunnel. As a result, they will stop functioning whenever the tunnel is active or recovering. This mode
causes significant restrictions for everyday device usage and should be used only when the security requirement
explicitly demands it.
.advanced mode may introduce VPN connection errors on iOS 17 and older. This setting may prevent the Packet tunnel extension from fetching the information required to establish the VPN connection.
Connect on Demand
Connect on Demand lets iOS start the VPN tunnel automatically as soon as the device generates internet traffic —
without any user interaction. Enable it on the KapeVPNManager before connecting:
try vpn.setConnectOnDemand(true)
Both setConnectOnDemand(_:) and setOnDemandRules(_:) require the VpnAutoConnectRules feature on
your SDK license, and throw KapeFeatureNotLicensedError without it. See
Licensed VPN features.
When enabled, iOS monitors outgoing connections and triggers the VPN tunnel the moment traffic is detected. The VPN stays active as long as Connect on Demand is enabled; disabling it returns to manual connect/disconnect control.
Connect on Demand is implemented via iOS NEOnDemandRule. The VPN profile must be installed (installProfile())
before this setting takes effect.
Combining Connect on Demand with Kill Switch .standard or .advanced means that after an unexpected tunnel drop,
traffic is blocked and iOS will immediately try to re-establish the tunnel automatically. This is the recommended
combination for maximum privacy.
Connect on Demand Rules
For finer control, you can define an ordered list of rules that determine when the VPN connects automatically. Rules are evaluated in order — the first matching rule wins.
try vpn.setOnDemandRules([
// Disconnect on a trusted home network
KapeOnDemandRule(condition: .ssid("Home WiFi"), action: .disconnect),
// Connect on any other WiFi
KapeOnDemandRule(condition: .wifi, action: .connect),
// Connect on cellular
KapeOnDemandRule(condition: .cellular, action: .connect),
])
Each KapeOnDemandRule has a condition and an action:
Conditions
| Condition | Matches |
|---|---|
.ssid("Name") | A specific WiFi network by SSID |
.wifi | Any WiFi network |
.cellular | Any cellular (mobile data) connection. iOS only |
Actions
| Action | Behaviour |
|---|---|
.connect | Start the VPN tunnel automatically when this condition is met |
.disconnect | Prevent automatic connection (or disconnect if currently active) |
When onDemandRules is empty and connectOnDemand is true, iOS uses a single catch-all connect rule — equivalent
to setting connectOnDemand = true without any rules.
Rules are persisted automatically and restored on the next app launch. They take effect immediately whenever
connectOnDemand is true.
Rule order matters. Put more specific .ssid rules before broad .wifi or .cellular rules, otherwise the specific
rules will never be reached.
Split Tunneling
Split Tunneling lets you exclude traffic from the VPN. Rules are IPv4 ranges in CIDR notation; traffic matching
any of them bypasses the VPN, and everything else is tunnelled. nil or an empty list — the default — is a full
tunnel.
Each rule is built through KapeSplitTunnelRule.createWithCIDR(_:), which validates the range and throws
KapeSplitTunnelRuleError.invalidCIDR rather than silently accepting a malformed entry.
// These ranges bypass the VPN; everything else is tunnelled
try vpn.setSplitTunnel([
try KapeSplitTunnelRule.createWithCIDR("10.0.0.0/8"),
try KapeSplitTunnelRule.createWithCIDR("192.168.1.0/24"),
])
// Full tunnel
try vpn.setSplitTunnel(nil)
Requires the VpnSplitTunnel feature on your SDK license; without it the call throws
KapeFeatureNotLicensedError and no rule is persisted. Custom DNS is gated the same way by
VpnCustomDns. See Licensed VPN features.
Protocol support
The setting is applied once, for every protocol, as NEIPv4Settings.excludedRoutes on the tunnel's network
settings — WireGuard, Lightway, OpenVPN and .automatic all behave identically. There is no per-protocol
difference and no protocol-specific configuration to supply.
IPv4 only. The SDK forces an IPv6 default route in every mode so IPv6 is dropped at the server rather than leaking — see IPv6 Leak Prevention. IPv6 CIDRs pass validation but have no effect.
An empty rule list is treated as a full tunnel, not as "block everything".
The packet tunnel reads the mode once at tunnel start. Changing splitTunnel while connected has no effect until
the next connect() — reconnect() does not pick it up.
Custom DNS
By default the tunnel uses the per-protocol default resolvers. You can override these with your own, in priority
order, via KapeVPNManager. The setting applies to all three protocols.
// Dotted-quad IPv4 only. Throws KapeCustomDnsError.invalidAddress on anything else.
try vpn.setCustomDnsServers(["1.1.1.1", "9.9.9.9"])
vpn.customDnsServers() // ["1.1.1.1", "9.9.9.9"]
try vpn.setCustomDnsServers(nil) // restore the per-protocol default
Entries are trimmed and de-duplicated, and the order is preserved — it is the resolver priority order. nil or an
empty array clears the setting. Validation is strict because NEDNSSettings rejects a non-IP address, which makes
setTunnelNetworkSettings throw and fails the connect: "10.9.8" is rejected rather than silently becoming
10.9.0.8, and IPv6 resolvers are not supported.
| Protocol | Empty / unset behaviour |
|---|---|
| Lightway | The synthetic in-tunnel resolver (100.64.100.3) |
| WireGuard | The server-returned resolvers, then the internal-IP gateway heuristic |
| OpenVPN | Server-pushed DNS (dhcp-option DNS) |
Precedence over Advanced Protection
Custom DNS wins over the Advanced Protection filtering resolver
(KapeVPNManager.setAdvancedProtectionCategories). When both are set:
- the custom resolvers are installed on every protocol;
- no filtering resolver is fetched at all — the LL SDK call is skipped, not made-and-ignored;
- on Lightway the legacy
ThreatManagerInfois forced inert, since a live one would make the Rust core rewrite DNS ports and intercept queries meant for your resolvers; - Advanced Protection filtering therefore does nothing while custom DNS is set. The tunnel logs a warning naming the bypass; clear the custom resolvers to re-enable filtering.
Reachability
Each resolver gets an explicit /32 host route, so it stays reachable when a broader range is excluded — for
example a 10.x resolver under a 10.0.0.0/8 split tunnel bypass.
Prefer public resolvers. The host route is best-effort: excludeLocalNetworks (the .standard kill switch) is
an OS-level policy rather than a route and can still outrank a /32 for a private-range address; and for a resolver
on the user's own LAN (e.g. a Pi-hole) the host route pulls queries into the tunnel, where nothing answers.
Local-network resolvers: known failure modes
A resolver on the user's own LAN (192.168.x.x, 10.x.x.x, 172.16–31.x.x) is accepted — a private address is
legitimate when the resolver lives VPN-side — but it behaves badly in two situations, and the SDK does not
detect either. Both are the caller's to guard against.
| Situation | Result |
|---|---|
.advanced kill switch, on the LAN (iOS) | includeAllNetworks = true forces even local traffic into the tunnel, and the /32 route sends the query there too. The VPN server cannot reach an RFC1918 address, so all name resolution fails for the session. |
.advanced kill switch, on the LAN (macOS) | .advanced keeps excludeLocalNetworks = true so the host can still reach local DNS, but the /32 include route works against that. Which one wins is not contractual — treat it as unreliable. |
.standard kill switch, on the LAN | Usually works: local ranges are excluded from the tunnel, so the query reaches the resolver. |
| User leaves that network | Nothing answers at that address, in any mode. DNS fails until the setting is cleared. |
Two consequences worth designing around:
- It looks like a broken connection, not a broken setting. The tunnel still reports
.connected; only resolution is dead. The sole in-product signal is theApplying DNS …log line. - It does not self-heal. The resolvers are read once per
startTunneland persist across reconnects, so moving between networks does not re-evaluate them. There is no probing and no fallback resolver.
If the resolver may be network-specific, clear it (setCustomDnsServers(nil)) when the user is no longer on the
network that hosts it — the SDK will not do this for you.
A private address is not unique to one network. If a query ever leaves the tunnel (.standard, local ranges
excluded, /32 not winning), 192.168.1.10 on a café network is some stranger's device — and the queries sent
to it reveal the hostnames the user is visiting. Treat a LAN resolver as safe only on the LAN that owns it.
The packet tunnel reads the resolvers once at tunnel start. Changing them while connected has no effect until the
next connect() — reconnect() does not pick it up.
Apps that run their own resolver (e.g. a browser with DNS-over-HTTPS enabled) bypass NEDNSSettings entirely and
are unaffected by this setting; their queries still travel through the tunnel as ordinary traffic. .local and
other multicast names are always resolved on the local link, by design.
Verifying
While connected, the tunnel logs one line per applied settings change:
Applying DNS resolvers=["1.1.1.1", "9.9.9.9"] matchDomains=[""] hostRoutes=2 source=custom
source is custom, advancedProtection, or default, so a log capture shows which setting won without having
to recognise the IPs. On the device, scutil --dns should list the resolvers against the utun interface.