Skip to main content

Network Extension

Your app needs a Packet Tunnel Provider extension. See Installation for the full setup, but the minimal implementation is:

// PacketTunnelProvider.swift in your extension target
import KapeVPN
import KapeVPN_Lightway_Bridge
import KapeVPN_OpenVPN_Bridge
import KapeVPN_WireGuard_Bridge

class PacketTunnelProvider: KapePacketTunnelProvider {
override var vpnProtocols: [KapeVPNProtocolSupport] { [.wireGuard, .lightway, .openVPN] }
}

vpnProtocols lists the protocols the tunnel can connect with — one per KapeVPN-<Protocol>-Bridge product linked into the extension. It is empty by default, and an empty list fails the start with PacketTunnelError.protocolNotBundled.

The base class reads KapeSDK.plist from the extension bundle and handles:

  • SDK initialization with shared keychain credentials
  • Endpoint discovery and authentication
  • Tunnel setup for each listed protocol
  • Connection status notifications back to the app

Custom Authentication​

By default the tunnel authenticates every protocol through the Kape backend. To authenticate with your own backend instead, implement the protocol's authenticator and pass it in place of the default KapeVPNProtocolSupport value:

import KapeVPN
import KapeVPN_Lightway
import KapeVPN_Lightway_Bridge
import KapeVPN_OpenVPN
import KapeVPN_OpenVPN_Bridge
import KapeVPN_WireGuard
import KapeVPN_WireGuard_Bridge

struct PartnerOpenVPNAuthenticator: PacketTunnelOpenVPNAuthenticator {
func credentials(for configuration: OpenVPNConfiguration) async throws -> OpenVPNCredentials {
let (username, password) = try await PartnerAPI.openVPNCredentials() // your own backend
return OpenVPNCredentials(username: username, password: password)
}
}

struct PartnerLightwayAuthenticator: PacketTunnelLightwayAuthenticator {
func credentials(for configuration: LightwayConfiguration) async throws -> LightwayCredentials {
.token(try await PartnerAPI.lightwayToken()) // or .usernamePassword(username:password:)
}
}

class PacketTunnelProvider: KapePacketTunnelProvider {
override var vpnProtocols: [KapeVPNProtocolSupport] {
[
.openVPN(authenticator: PartnerOpenVPNAuthenticator()),
.wireGuard(authenticator: PartnerWireGuardAuthenticator()), // a PacketTunnelWireguardAuthenticator
.lightway(authenticator: PartnerLightwayAuthenticator())
]
}
}
ProtocolDefault (.lightway / .openVPN / .wireGuard)Custom
Lightwaythe Kape connection token as the auth tokenPacketTunnelLightwayAuthenticator.credentials(for:) returns a token or a username and password
OpenVPNusername jwt-auth, password = the Kape connection tokenPacketTunnelOpenVPNAuthenticator.credentials(for:) returns the auth-user-pass pair
WireGuardkey exchange through the Kape token servicePacketTunnelWireguardAuthenticator.authenticate(config:) returns the endpoint with its keys and internal IP filled in

The authenticator runs once per connection attempt, just before that endpoint is connected, so a short-lived credential is always fetched fresh. Lightway attempts a group of endpoints in parallel, so its authenticator runs once per group and the credentials apply to all of them. Throwing skips that endpoint for the next one; if every endpoint fails, the error is reported to the app like any other connection failure. Returning empty credentials counts as a failure, rather than a connection the server would reject after a full timeout.

A Lightway authenticator does not apply to an Anonymous Dedicated IP, which always connects with its own access token, or to a .tokenAuth HTTPS proxy, which always uses the Kape connection token.

vpnProtocols is read when the tunnel starts, after sdkHandle is set, so an authenticator can be built from it there if it needs the SDK.

Reading Other Settings​

To start the tunnel from settings other than KapeSDK.plist — a different plist, or settings adjusted at runtime to switch environment — override sdkSettings. The base class builds the KapeSdkHandle from it and starts the tunnel as usual:

import KapeCore
import KapeVPN

class PacketTunnelProvider: KapePacketTunnelProvider, @unchecked Sendable {
override var sdkSettings: KapeSDKSettings {
get throws {
var settings = try KapeSDKSettings(bundle: .main, plistName: "KapeSDK-staging")
// Adjust settings here, before the handle is built.
return settings
}
}
}
  • The settings must carry the same AppGroupIdentifier and keychain access group as your app, or the extension cannot read the selected location or the signed-in session.
  • sdkSettings is read once per startTunnel.

Providing Your Own SDK Handle​

If the handle itself must be built differently — with a configure closure, say — override startTunnel(options:completionHandler:) and call start(handle:) instead of super. To change only the settings, prefer sdkSettings above.

import KapeCore
import KapeVPN

class PacketTunnelProvider: KapePacketTunnelProvider, @unchecked Sendable {
override func startTunnel(
options: [String: NSObject]? = nil,
completionHandler: @escaping @Sendable ((any Error)?) -> Void
) {
Task {
do {
let settings = try self.sdkSettings
let handle = try await KapeSdkHandle(
settings: settings,
deviceModel: Self.deviceModel(for: settings),
configure: { configuration in
// Register callbacks here, before the SDK manager is built.
}
)
try await start(handle: handle)
completionHandler(nil)
} catch {
completionHandler(error)
}
}
}
}
  • Build the handle from settings that carry the same AppGroupIdentifier and keychain access group as your app, or the extension cannot read the selected location or the signed-in session.
  • Call start(handle:) as soon as the handle exists. On macOS it registers the callback that lets the SDK reach the network under the advanced kill switch before the tunnel is up, so SDK calls you make on the handle before that may fail under the advanced kill switch.
  • deviceModel(for:) returns the device model your app recorded, so the extension reports the same client details as the default path.

Switching Environment​

KapeSDKSettings(bundle:plistName:) reads a plist other than KapeSDK.plist. Ship one plist per environment in both targets — say KapeSDK.plist and KapeSDK-staging.plist — and have the app and the extension pick the same one. The extension can't see the app's in-memory choice, so the app has to persist it somewhere both processes read, such as App Group UserDefaults:

// In the app, when the user picks an environment, then rebuild KapeSDKSettings and KapeSdkHandle:
UserDefaults(suiteName: "group.com.your.app")?.set("KapeSDK-staging", forKey: "sdkPlistName")

// In the extension's sdkSettings override:
let plistName =
UserDefaults(suiteName: "group.com.your.app")?.string(forKey: "sdkPlistName")
?? KapeSDKSettings.defaultPlistName
let settings = try KapeSDKSettings(bundle: .main, plistName: plistName)
  • Every plist must carry the same AppGroupIdentifier, the one in both targets' entitlements.
  • Give each environment its own KeychainService, so a session signed in against one environment is never sent to the other.
  • A missing plist throws KapeSDKSettingsError.plistNotFound(name:).

Supplying Your Own Configurations​

Most integrations never touch this. If you import KapeVPN-PacketTunnel directly and drive KapeSessionController yourself, you supply a VpnConfigurationGenerator (and add KapeVPN-WireGuard, KapeVPN-Lightway or KapeVPN-OpenVPN for the protocols you connect with):

public protocol VpnConfigurationGenerator: Sendable {
func nextConfiguration() async throws -> (any VpnConfiguration)?
}

The session controller calls it once per connection attempt and connects to whatever comes back, so your generator owns discovery, ordering, credentials, and re-fetching when it runs out of endpoints. Return nil when you have nothing available right now — the session backs off and asks again rather than tearing the tunnel down. Throw for a real failure (network, auth, subscription); the error is surfaced to the app and the session retries with backoff.

A configuration you return has to be ready to connect. For WireGuard that means authorizing it first, which your PacketTunnelWireguardAuthenticator does for you:

func nextConfiguration() async throws -> (any VpnConfiguration)? {
guard let config = endpoints.popFirst() else { return nil }
return try await authenticator.authorize(config, logger: logger)
}

Required Entitlements​

Both the app and extension need:

<key>com.apple.developer.networking.networkextension</key>
<array>
<string>packet-tunnel-provider</string>
</array>
<key>com.apple.security.application-groups</key>
<array>
<string>group.com.your.app</string>
</array>

How App and Extension Communicate​

DirectionMechanism
App → ExtensionNETunnelProviderSession.sendProviderMessage()
Extension → AppDarwin notifications
Shared configKapeSDK.plist (in both bundles)
Shared stateJSON file in App Group container

You don't interact with these directly — KapeVPNManager and KapePacketTunnelProvider handle all IPC.