# officer-net — the OffScale VPN engine (native) The native half of an in-app Tailscale client. It embeds the **Tailscale core** (Go, via `gomobile`) behind the OS VPN APIs — **iOS `NEPacketTunnelProvider`** and **Android `VpnService`** — and talks directly to a control server: your own **Headscale**, or Tailscale's. It contacts nothing else. There is no backend, no telemetry and no account layer anywhere in this package; the data path is the mesh, and the only host it reaches is the control URL it was configured with. Node/WireGuard keys are generated and persisted by the core in the **Keychain / Keystore** and never cross into Swift, Kotlin or JS. The JS side is one line — `requireNativeModule('OfficerNet')`, in `src/vpn/officer-net.ts` at the repo root. This package is Swift + Kotlin + Go + the Expo config plugin + the two build scripts. ## Layout ``` officer-net/ package.json index.ts expo-module.config.json app.plugin.js .gitignore gocore/ # the Go shim bound by gomobile tailcore.go # the whole exported surface (profiles, up/down, prefs, status) dns.go protect.go maketun_apple.go maketun_droid.go interfaces_droid.go go.mod go.sum # pins tailscale.com — see ../../NOTICE ios/ OfficerNet.podspec # vendors libs/TailCore.xcframework, compiles the Swift below OfficerNetModule.swift # RN process: NETunnelProviderManager config + commands PacketTunnelProvider.swift # NE target: hosts the core, owns packetFlow android/ build.gradle src/main/AndroidManifest.xml src/main/java/expo/modules/officernet/ OfficerNetModule.kt # RN process: prepare()/start/stop/status OfficerVpnService.kt # VpnService: builds the tun, hands the fd to the core BootReceiver.kt # reconnect-on-boot plugin/ app.plugin.js # entitlements, App Group, Android service, iOS NE target withNetworkExtensionTarget.js # synthesises the NE Xcode target at prebuild README.md scripts/ build-libtailscale-ios.sh # gomobile bind → ios/libs/TailCore.xcframework build-libtailscale-android.sh # gomobile bind → android/libs/tailcore.aar libs/ ios/libs/ android/libs/ # the built core (gitignored — see below) ``` ## The built core is NOT in the repo, and NOT rebuilt by an app build `gomobile bind` output is tens of megabytes per platform, so it is gitignored and produced on the machine that builds the app. **Nothing rebuilds it automatically.** Changing anything under `gocore/` means running the script for each platform you are about to build: ```bash PATH="$PATH:$HOME/go/bin" modules/officer-net/scripts/build-libtailscale-ios.sh # a few minutes PATH="$PATH:$HOME/go/bin" modules/officer-net/scripts/build-libtailscale-android.sh ``` Forgetting is silent until much later, and the error names a symbol rather than the cause: | platform | how it fails | when | |----------|-------------------------------------------------------|---------------------| | Android | `e: Unresolved reference 'loginTo'` | Kotlin compile — loud and early | | iOS | `error: cannot find 'TailcoreListProfiles' in scope` | Swift compile — long after the Go compiled cleanly | The repo's `scripts/ensure-core.sh` compares mtimes and runs the right script for you before each app build, which is why the two build scripts source it. Confirm the symbols landed if you are debugging: ```bash grep Tailcore modules/officer-net/ios/libs/TailCore.xcframework/ios-arm64/Tailcore.framework/Headers/*.h ``` Requires Go plus gomobile on `PATH`: ```bash go install golang.org/x/mobile/cmd/gomobile@latest go install golang.org/x/mobile/cmd/gobind@latest ``` `TAILSCALE_REF` at the top of each script pins the Tailscale version. Keep it paired with the Headscale release you run — Headscale implements a specific client protocol version, and a core built far ahead of it will fail to register. ## Native surface Mirrored one-for-one by `src/vpn/officer-net.ts`; that file is the readable index of it. Roughly: `configure(controlUrl, hostname?)`, `loginWithAuthKey`, `loginTo` (add a second tailnet in one core operation), `isRegistered`, `up`/`down`, `status`, `prefs`, `setExitNode`, `setAcceptRoutes`, profile list/switch/delete, `logout`, and an `onStatus` event carrying `{ state, self?, peers, error? }`. ## App wiring The config plugin is enabled from `app.json` at the repo root: ```js plugins: [["officer-net", { appGroup: "group.pastilhas.officer.offscale", neBundleId: "dev.officer.offscale.net", enableNetworkExtension: true, }]] ``` It adds the iOS Network Extensions entitlement, synthesises the NE Xcode target, and registers the Android `VpnService`. See `plugin/README.md`. ## Apple provisioning prerequisites (one-time) - An **App ID for the extension** (`dev.officer.offscale.net` by default) with the **Network Extensions** capability. - The **Network Extensions** capability on the main app ID. - Provisioning profiles for both. `-allowProvisioningUpdates` cannot create these while Xcode has no account signed in — it falls back to the wildcard team profile and fails with *"Provisioning profile … doesn't include the Network Extensions capability"*. Sign in under Xcode → Settings → Accounts first. Android has no equivalent gate. ## Notes and traps - **iOS jetsams a packet tunnel over roughly 50 MB**, so the core must run WireGuard in userspace (netstack). See the header comment in `PacketTunnelProvider.swift`. Crashes are *inferred* rather than caught — no handler runs — via a start marker that is still `running` at the next start. - **`neBundleId` must come from `OfficerNetExtensionBundleId` in `Info.plist`**, never a constant. A hardcoded value asks iOS to launch an extension the binary does not contain: the start command is accepted, nothing matches, and the toggle silently does nothing. - **Android protects the core's sockets per-socket** (`gocore/protect.go` registers `netns.SetAndroidProtectFunc`), not by excluding the whole app from its own tunnel. `gomobile` generates `boolean protect(int)` from `tailcore.Protector` — the same signature as `VpnService.protect(int)` — so a service implementing the interface directly would have its override call itself forever. It delegates through `CoreProtector`. - **Logging out needs a RUNNING extension.** The identity is `tailscaled.state` inside the extension's container (Android: `filesDir`), unreachable from the app process, so `logout()` starts the tunnel briefly just to issue the wipe. - **Reinstalling invalidates the saved VPN configuration.** iOS hands it back from `loadAllFromPreferences`, reports "unchanged" on save, then refuses to start it. `up()` removes and rewrites it on failure, which is self-healing.