23 KiB
Embedded Loopback VPN Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Embed a LocalDevVPN-compatible loopback Packet Tunnel in StikDebug, route RPPairing/RSD traffic through it, and remove WiFi as a hard prerequisite without changing the higher-level JIT/debug/location behavior.
Architecture: Add a StikDebugTunnel Packet Tunnel Extension with a point-to-point IPv4 route for the configured peer (10.7.0.1 by default), and manage it from the main app through NETunnelProviderManager. Move RPPairing tunnel creation behind an EmbeddedDeviceTransport so JIT, heartbeat, syslog, and location all share the same readiness gate. Keep ordinary Internet traffic outside the tunnel and report VPN/RSD failures separately from Internet or DDI-download failures.
Tech Stack: Swift 5, SwiftUI, NetworkExtension, XCTest/Swift Testing, existing idevice FFI, Xcode project format.
Global Constraints
- Minimum supported iOS target remains 17.4.
- The embedded tunnel includes only the configured peer route and excludes the default route.
- The default peer remains
10.7.0.1:49152; the existing custom peer IP setting remains supported. - No RSD/JIT feature may be gated on
NWPathMonitor(requiredInterfaceType: .wifi). - Existing
tunnel_create_rppairing/RPPairing/RSD behavior remains the protocol implementation. - The Packet Tunnel Extension does not implement WLocApp's HTTPS interception or location proxy.
- USB/usbmuxd transport is out of scope.
- Every new pure function receives a failing unit test before its implementation; Xcode project, plist, entitlement, and extension wiring are configuration exceptions.
- Real-device acceptance is required before claiming WiFi-free operation; Windows-side checks cannot prove Network Extension runtime behavior.
Task 1: Add testable tunnel configuration and IPv4 packet primitives
Files:
- Create:
StikJIT/Tunnel/StikDebugTunnelConfiguration.swift - Create:
StikJIT/Tunnel/IPv4PacketRewriter.swift - Modify:
StikJITTests/StikJITTests.swift - Modify:
StikDebug.xcodeproj/project.pbxproj
Interfaces:
-
Produces
StikDebugTunnelConfiguration.default,StikDebugTunnelConfiguration(interfaceIP:peerIP:), andproviderConfigurationvalues for the app and extension. -
Produces
IPv4PacketRewriter.swapEndpoints(in:)for the Packet Tunnel and unit tests. -
Step 1: Write failing tests for the defaults and packet swap.
Add tests to StikJITTests/StikJITTests.swift:
@Test func embeddedTunnelDefaultsMatchLocalDevVPNEndpoint() {
let configuration = StikDebugTunnelConfiguration.default
#expect(configuration.interfaceIP == "10.7.0.0")
#expect(configuration.peerIP == "10.7.0.1")
#expect(configuration.peerPrefixLength == 32)
#expect(configuration.providerConfiguration["interfaceIP"] == "10.7.0.0")
#expect(configuration.providerConfiguration["peerIP"] == "10.7.0.1")
}
@Test func ipv4PacketRewriterSwapsPacketEndpoints() {
var packet = [UInt8](repeating: 0, count: 20)
packet.replaceSubrange(12..<16, with: [10, 7, 0, 2])
packet.replaceSubrange(16..<20, with: [10, 7, 0, 1])
IPv4PacketRewriter.swapEndpoints(in: &packet)
#expect(Array(packet[12..<16]) == [10, 7, 0, 1])
#expect(Array(packet[16..<20]) == [10, 7, 0, 2])
}
@Test func ipv4PacketRewriterLeavesShortPacketsUnchanged() {
var packet = [UInt8](repeating: 0, count: 19)
let original = packet
IPv4PacketRewriter.swapEndpoints(in: &packet)
#expect(packet == original)
}
- Step 2: Run the focused test target and verify it fails because the new types do not exist.
Run on macOS/Xcode:
xcodebuild -project StikDebug.xcodeproj -scheme StikDebug -destination 'platform=iOS Simulator,name=iPhone 16' -only-testing:StikDebugTests/StikJITTests test
Expected: compilation failure identifying the missing StikDebugTunnelConfiguration and IPv4PacketRewriter symbols.
- Step 3: Implement the minimal pure types.
StikDebugTunnelConfiguration must store interfaceIP, peerIP, and peerPrefixLength, expose the constants interfaceIPKey = "interfaceIP" and peerIPKey = "peerIP", and build a [String: String] provider dictionary. Its default must be interface 10.7.0.0, peer 10.7.0.1, prefix 32.
IPv4PacketRewriter.swapEndpoints must return without modifying packets shorter than 20 bytes and must swap bytes 12–15 with bytes 16–19 for every IPv4 packet delivered by the peer-only route. It must not parse or rewrite IPv6 packets. This intentionally matches LocalDevVPN's packet loop; the route restriction ensures only loopback peer traffic enters the loop.
- Step 4: Add the files to the main app target and rerun the focused tests.
Add both Swift files to the StikDebug target in StikDebug.xcodeproj/project.pbxproj, then rerun the exact command from Step 2.
Expected: the three new tests pass and the existing tests remain green.
- Step 5: Commit the tested primitives.
git add StikJIT/Tunnel StikJITTests/StikJITTests.swift StikDebug.xcodeproj/project.pbxproj
git commit -m "feat: add loopback tunnel configuration primitives"
Task 2: Add the StikDebugTunnel Packet Tunnel Extension
Files:
- Create:
StikDebugTunnel/PacketTunnelProvider.swift - Create:
StikDebugTunnel/Info.plist - Create:
StikDebugTunnel/StikDebugTunnel.entitlements - Modify:
StikDebug.xcodeproj/project.pbxproj - Modify:
StikJIT/Tunnel/StikDebugTunnelConfiguration.swift
Interfaces:
-
Consumes:
StikDebugTunnelConfigurationandIPv4PacketRewriterfrom Task 1. -
Produces: an extension target with bundle identifier
com.stik.stikdebug.tunneland aPacketTunnelProviderthat configures the peer-only route and packet loop. -
Step 1: Add the extension resource/configuration entries.
Create StikDebugTunnel/Info.plist with an NSExtension dictionary containing:
<key>NSExtensionPointIdentifier</key>
<string>com.apple.networkextension.packet-tunnel</string>
<key>NSExtensionPrincipalClass</key>
<string>$(PRODUCT_MODULE_NAME).PacketTunnelProvider</string>
Create StikDebugTunnel/StikDebugTunnel.entitlements with:
<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.stik.stikdebug</string>
</array>
Add a PBXNativeTarget named StikDebugTunnel with product type com.apple.product-type.app-extension, product name StikDebugTunnel, bundle identifier com.stik.stikdebug.tunnel, deployment target 17.4, APPLICATION_EXTENSION_API_ONLY = YES, the new plist/entitlement paths, and the NetworkExtension framework. Add the target as a dependency of the main StikDebug target and add the provider/configuration source files to its source phase.
- Step 2: Implement the provider using the tested packet primitive.
PacketTunnelProvider.startTunnel must:
- Read
interfaceIPandpeerIPfromstartTunnel(options:), falling back toNETunnelProviderProtocol.providerConfigurationand thenStikDebugTunnelConfiguration.default. - Configure
NEIPv4Settings(addresses:subnetMasks:)using the interface IP and/24subnet mask. - Set
includedRoutesto oneNEIPv4Routefor the peer IP with subnet mask255.255.255.255. - Set
excludedRoutesto[.default()]. - Call
setTunnelNetworkSettings; report its error through the completion handler before starting packet reads. - Start a recursive
packetFlow.readPacketsloop that callsIPv4PacketRewriter.swapEndpointson each IPv4 packet, writes the packets back, and continues reading.
stopTunnel must cancel the packet loop state and call its completion handler. The provider must not configure DNS, HTTP proxy settings, or a default route.
- Step 3: Run static target checks.
Run:
rg -n -S "StikDebugTunnel|packet-tunnel-provider|com.stik.stikdebug.tunnel|StikDebugTunnel.entitlements|StikDebugTunnel/Info.plist" StikDebug.xcodeproj/project.pbxproj StikDebugTunnel
Expected: one extension target, one packet-tunnel extension point, the correct bundle identifier, both resources, and the Network Extension entitlement are present.
- Step 4: Commit the extension scaffold.
git add StikDebugTunnel StikJIT/Tunnel/StikDebugTunnelConfiguration.swift StikDebug.xcodeproj/project.pbxproj
git commit -m "feat: embed loopback packet tunnel extension"
Task 3: Add the main-app VPN manager and status/error model
Files:
- Create:
StikJIT/Utilities/StikDebugVPNManager.swift - Create:
StikJIT/Utilities/StikDebugVPNStatus.swift - Modify:
StikJITTests/StikJITTests.swift - Modify:
StikDebug.xcodeproj/project.pbxproj
Interfaces:
-
Consumes:
StikDebugTunnelConfigurationand theStikDebugTunnelbundle identifier from Task 1/2. -
Produces:
StikDebugVPNManager.shared.ensureReady() throws,.start() throws,.stop(),currentStatus, andStikDebugVPNErrorvalues that distinguish configuration, competing VPN, timeout, and start failures. -
Step 1: Write failing tests for status mapping and provider configuration.
Add pure tests:
@Test func vpnStatusMappingRecognizesConnectedAndReasserting() {
#expect(StikDebugVPNStatus(neStatus: .connected) == .connected)
#expect(StikDebugVPNStatus(neStatus: .reasserting) == .connecting)
#expect(StikDebugVPNStatus(neStatus: .disconnected) == .disconnected)
}
@Test func vpnManagerConfigurationUsesEmbeddedExtension() {
let configuration = StikDebugVPNManager.makeProviderConfiguration(
for: .default
)
#expect(configuration[StikDebugTunnelConfiguration.interfaceIPKey] == "10.7.0.0")
#expect(configuration[StikDebugTunnelConfiguration.peerIPKey] == "10.7.0.1")
#expect(StikDebugVPNManager.providerBundleIdentifier == "com.stik.stikdebug.tunnel")
}
- Step 2: Run the focused tests and verify they fail because the VPN types do not exist.
Run the same focused xcodebuild command from Task 1.
Expected: compilation failure for the missing VPN status and manager symbols.
- Step 3: Implement the pure status/error model.
StikDebugVPNStatus must map .invalid and .disconnected to .disconnected, .connecting and .reasserting to .connecting, .connected to .connected, .disconnecting to .disconnecting, and unknown values to .failed.
StikDebugVPNError must provide localized messages for profile creation failure, VPN permission/start failure, a conflicting active VPN, timeout waiting for .connected, and a disconnected peer route. It must not mention WiFi.
- Step 4: Implement
StikDebugVPNManager.
Use NETunnelProviderManager.loadAllFromPreferences to find only managers whose NETunnelProviderProtocol.providerBundleIdentifier equals com.stik.stikdebug.tunnel. Create one if absent, set localizedDescription to StikDebug Local Tunnel, assign the provider configuration, enable it, and save it back to preferences.
Configure an NEOnDemandRuleEvaluateConnection with interfaceTypeMatch = .any and a single NEEvaluateConnectionRule matching the peer IP. Do not remove or stop managers belonging to other VPNs; throw StikDebugVPNError.competingVPN if another manager is connected or connecting.
ensureReady() must load/create the manager, return immediately when its status is .connected, start it with the interface/peer options otherwise, and wait up to 10 seconds for .connected or a terminal failure using NEVPNStatusDidChange. It must be safe to call from the background queue used by startTunnelInBackground and must coalesce concurrent starts with a lock/semaphore.
- Step 5: Add the manager to the main app target and rerun tests.
Add both files to the StikDebug target, run the focused test command, and confirm the status/configuration tests pass.
- Step 6: Commit the VPN manager.
git add StikJIT/Utilities/StikDebugVPNManager.swift StikJIT/Utilities/StikDebugVPNStatus.swift StikJITTests/StikJITTests.swift StikDebug.xcodeproj/project.pbxproj
git commit -m "feat: manage embedded loopback vpn"
Task 4: Move RPPairing creation behind the embedded transport
Files:
- Create:
StikJIT/Utilities/DeviceTransport.swift - Modify:
StikJIT/Utilities/JITEnableContext.swift - Modify:
StikJIT/Utilities/IdeviceFFIBridge.swift - Modify:
StikJITTests/StikJITTests.swift - Modify:
StikDebug.xcodeproj/project.pbxproj
Interfaces:
-
Consumes:
StikDebugVPNManager.ensureReady()and existingideviceFFI symbols. -
Produces:
RPPairingTunnel,DeviceTransport, andEmbeddedDeviceTransport.shared.makeRPPairingTunnel(hostname:targetIPAddress:pairingFileURL:). -
Step 1: Write failing tests for transport endpoint validation and error classification.
Add pure tests for the new endpoint/error helpers:
@Test func transportEndpointUsesRPPairingPort() throws {
let endpoint = try RPPairingEndpoint(ip: "10.7.0.1")
#expect(endpoint.port == 49152)
#expect(endpoint.ip == "10.7.0.1")
}
@Test func transportEndpointRejectsMalformedIPv4() {
#expect(throws: DeviceTransportError.invalidIPAddress) {
try RPPairingEndpoint(ip: "not-an-ip")
}
}
@Test func transportErrorsDoNotReportWiFiAsTheRootCause() {
let message = DeviceTransportError.vpnUnavailable("permission denied").errorDescription ?? ""
#expect(!message.localizedCaseInsensitiveContains("wifi"))
#expect(message.localizedCaseInsensitiveContains("vpn"))
}
- Step 2: Run the focused tests and verify they fail because the transport types do not exist.
Run the focused xcodebuild test command from Task 1.
Expected: compilation failure for the missing transport types.
- Step 3: Implement endpoint validation and owned tunnel handles.
RPPairingEndpoint must validate an IPv4 address using inet_pton, store the address and port 49152, and expose a sockaddr_in conversion used by the FFI call.
RPPairingTunnel must own optional adapter/handshake handles and free the handshake before the adapter. Its free() method must be idempotent.
DeviceTransportError must include invalid IP, pairing file missing/read failure, VPN unavailable, FFI tunnel creation failure, and incomplete handle errors. Its descriptions must identify VPN/RPPairing/RSD layers and must not claim WiFi is required.
- Step 4: Implement
EmbeddedDeviceTransport.
makeRPPairingTunnel must:
- Call
StikDebugVPNManager.shared.ensureReady()before touching the RPPairing FFI. - Verify the pairing file path exists.
- Read the pairing file with
rp_pairing_file_readand always free it withrp_pairing_file_free. - Build
sockaddr_infor the validated target IP and port49152. - Call
tunnel_create_rppairingwith the caller's hostname. - Convert FFI errors to
DeviceTransportError.ffiFailurewhile freeing the FFI error. - Reject and free incomplete adapter/handshake results.
Keep the FFI implementation in this transport so callers do not duplicate VPN readiness, IP parsing, pairing-file lifetime, or error conversion.
- Step 5: Migrate
JITEnableContext.
Remove its private TunnelHandles, getPairingFile, and direct createTunnel implementation. Use RPPairingTunnel and EmbeddedDeviceTransport.shared.makeRPPairingTunnel for:
- the persistent
startTunnel()handle; withFreshDebugTunnelused by debug sessions;- heartbeat creation in
DebugHeartbeatKeepAlive; - syslog connection paths that currently call
createTunnel.
Preserve the existing adapter/handshake freeing order and concurrency lock. pubTunnelConnected remains true only after RPPairing/RSD setup succeeds.
- Step 6: Migrate location simulation.
In simulate_location, remove the duplicate pairing-file read, port setup, and direct tunnel_create_rppairing call. Call the transport with hostname StikDebugLocation, deviceIP, and the supplied pairing file path. Transfer the returned adapter/handshake into LocationSimulationState and leave the existing remote-server/location-simulation error status mapping unchanged.
- Step 7: Rerun tests and commit the transport migration.
Run the focused tests and then:
git add StikJIT/Utilities/DeviceTransport.swift StikJIT/Utilities/JITEnableContext.swift StikJIT/Utilities/IdeviceFFIBridge.swift StikJITTests/StikJITTests.swift StikDebug.xcodeproj/project.pbxproj
git commit -m "refactor: route RSD connections through embedded vpn"
Task 5: Remove WiFi gating and external LocalDevVPN UI/documentation
Files:
- Modify:
StikJIT/StikJITApp.swift - Modify:
StikJIT/Views/SettingsView.swift - Modify:
StikJIT/Resources/Localizable.xcstrings - Modify:
README.md - Modify:
StikDebug.xcodeproj/project.pbxprojonly if new manager files are added to the target here
Interfaces:
-
Consumes:
StikDebugVPNManagerand transport errors from Tasks 3–4. -
Produces: user flows that start the embedded VPN automatically and no longer instruct users to install or enable external LocalDevVPN/WiFi.
-
Step 1: Write a static regression check before editing the UI/diagnostic code.
Use a PowerShell assertion script in the task worktree, without adding it to production sources:
$app = Get-Content -Raw StikJIT\StikJITApp.swift
if ($app -match 'requiredInterfaceType:\s*\.wifi') { throw 'WiFi-only path monitor still blocks the app' }
if ($app -match 'Make sure Wi.?Fi and LocalDevVPN') { throw 'External WiFi/LocalDevVPN error copy still exists' }
Run it before the production edit and record the expected failure. The failure proves the regression check detects the old behavior.
- Step 2: Remove unused WiFi-only diagnostics and stale connection checks.
Confirm with rg that DNSChecker and checkDeviceConnection have no callers. Remove the unused DNSChecker class and the unused WiFi/same-network connection helper from StikJITApp.swift. Keep Network imported only if another helper in that file still uses it; otherwise remove the import.
Change startTunnelInBackground error handling to display the localized transport error. Do not mention WiFi or external LocalDevVPN. Keep DDI download failures separate from tunnel failures.
- Step 3: Replace the Settings external link with embedded VPN status/control.
Remove SettingsLinks.localDevVPN and the “Download LocalDevVPN” link. Add a section bound to StikDebugVPNManager.shared that displays disconnected/connecting/connected/error state and provides Start/Stop actions. Start calls the manager and surfaces its error; Stop calls stop(). The existing pairing-file import continues to call startTunnelInBackground, which now starts the embedded VPN through the transport.
- Step 4: Update localized strings and README.
Remove the Download LocalDevVPN localization entry. Add localized strings for “Embedded VPN”, “Start VPN”, “Stop VPN”, “VPN connected”, “VPN disconnected”, “VPN permission required”, and the transport error categories used by the manager.
Update README.md so Requirements list the embedded VPN capability rather than a separately installed LocalDevVPN app, and update the setup/troubleshooting sections to say that WiFi is not a prerequisite. State that Internet is still required for uncached DDI downloads and network-backed content, while cached RSD/JIT traffic is local to the Packet Tunnel.
- Step 5: Rerun the static regression check and commit.
Run:
$app = Get-Content -Raw StikJIT\StikJITApp.swift
if ($app -match 'requiredInterfaceType:\s*\.wifi') { throw 'WiFi-only path monitor still blocks the app' }
if ($app -match 'Make sure Wi.?Fi and LocalDevVPN') { throw 'External WiFi/LocalDevVPN error copy still exists' }
rg -n -S "LocalDevVPN|Download LocalDevVPN|\.wifi|requiredInterfaceType" StikJIT README.md
Expected: no RSD/JIT gating or external setup instruction remains; any remaining LocalDevVPN match must be a historical attribution or test/documentation reference explicitly marked as such.
git add StikJIT/StikJITApp.swift StikJIT/Views/SettingsView.swift StikJIT/Resources/Localizable.xcstrings README.md
git commit -m "feat: remove wifi and external vpn requirements"
Task 6: Build, test, and validate the project wiring
Files:
- Modify:
StikDebug.xcodeproj/project.pbxprojonly if verification identifies a missing source/build phase entry - Modify:
StikJITTests/StikJITTests.swiftonly for a verified regression test discovered during validation
Interfaces:
-
Consumes: all previous tasks.
-
Produces: verified project wiring, test results, and a documented real-device acceptance checklist.
-
Step 1: Run formatting and repository checks.
git diff --check
git status --short
rg -n -S "tunnel_create_rppairing|10\.7\.0\.1|49152|NWPathMonitor|requiredInterfaceType|LocalDevVPN|packet-tunnel-provider|com.stik.stikdebug.tunnel" StikJIT StikDebugTunnel StikDebug.xcodeproj README.md
Confirm that the tunnel_create_rppairing implementation call occurs only in DeviceTransport.swift (the FFI declaration in idevice.h remains), the default peer appears in configuration/tests/UI placeholder as intended, WiFi-only monitoring is absent, and the extension target has the expected Network Extension entries.
- Step 2: Run all unit tests on macOS/Xcode.
xcodebuild -project StikDebug.xcodeproj -scheme StikDebug -destination 'platform=iOS Simulator,name=iPhone 16' test
Expected: existing tests plus the new configuration, packet, status, endpoint, and error tests pass with exit code 0.
- Step 3: Build the app and extension for a generic iOS device.
xcodebuild -project StikDebug.xcodeproj -scheme StikDebug -destination 'generic/platform=iOS' build CODE_SIGNING_ALLOWED=NO
If unsigned build is not supported for the extension, run with the configured development team and record the exact entitlement/signing error rather than treating a simulator build as sufficient.
- Step 4: Inspect the built product wiring.
Verify the archive/build log contains both StikDebug.app and StikDebugTunnel.appex, the extension Info.plist packet-tunnel point, the app-group entitlement in both products, and the packet-tunnel entitlement in the extension. Verify the main app declares the embedded extension target dependency.
- Step 5: Execute the physical-device matrix.
With a valid pairing file and DDI available, test WiFi-only, cellular-only, both interfaces, and both interfaces disabled while the embedded VPN is connected. Then test VPN denied/stopped, DDI cached offline, DDI uncached with cellular Internet, background/foreground, VPN restart, app discovery, JIT enablement, device info, debug/heartbeat, syslog, and location simulation.
Record for each case whether the failure is VPN status, peer route/RPPairing, RSD, pairing, or ordinary Internet. Do not claim WiFi-free support unless the cellular-only or offline cases pass on a real device.
- Step 6: Commit only after verification evidence is captured.
git status --short
git log -6 --oneline
Create the final implementation commit only after the focused tests, full tests, build, and available device checks have been reviewed. If signing or hardware blocks a check, report that blocker explicitly instead of marking the requirement complete.