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())
]
}
}
| Protocol | Default (.lightway / .openVPN / .wireGuard) | Custom |
|---|---|---|
| Lightway | the Kape connection token as the auth token | PacketTunnelLightwayAuthenticator.credentials(for:) returns a token or a username and password |
| OpenVPN | username jwt-auth, password = the Kape connection token | PacketTunnelOpenVPNAuthenticator.credentials(for:) returns the auth-user-pass pair |
| WireGuard | key exchange through the Kape token service | PacketTunnelWireguardAuthenticator.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
AppGroupIdentifierand keychain access group as your app, or the extension cannot read the selected location or the signed-in session. sdkSettingsis read once perstartTunnel.
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
AppGroupIdentifierand 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
| Direction | Mechanism |
|---|---|
| App → Extension | NETunnelProviderSession.sendProviderMessage() |
| Extension → App | Darwin notifications |
| Shared config | KapeSDK.plist (in both bundles) |
| Shared state | JSON file in App Group container |
You don't interact with these directly — KapeVPNManager and KapePacketTunnelProvider handle all IPC.