Skip to main content

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.

info

The kill switch is not available on tvOS: setKillSwitchMode(_:) throws KapeError.

Modes​

ModeBehaviourWhen to use
.standardThe 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.
.advancedAll 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.
warning

.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.

warning

.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.

info

Connect on Demand is implemented via iOS NEOnDemandRule. The VPN profile must be installed (installProfile()) before this setting takes effect.

warning

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

ConditionMatches
.ssid("Name")A specific WiFi network by SSID
.wifiAny WiFi network
.cellularAny cellular (mobile data) connection. iOS only

Actions

ActionBehaviour
.connectStart the VPN tunnel automatically when this condition is met
.disconnectPrevent 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.

info

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.

warning

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.

info

An empty rule list is treated as a full tunnel, not as "block everything".

warning

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.

ProtocolEmpty / unset behaviour
LightwayThe synthetic in-tunnel resolver (100.64.100.3)
WireGuardThe server-returned resolvers, then the internal-IP gateway heuristic
OpenVPNServer-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 ThreatManagerInfo is 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.

warning

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.

SituationResult
.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 LANUsually works: local ranges are excluded from the tunnel, so the query reaches the resolver.
User leaves that networkNothing 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 the Applying DNS … log line.
  • It does not self-heal. The resolvers are read once per startTunnel and 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.

danger

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.

warning

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.

info

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.