The extraction typechecked from day one and had never been built. It took five attempts, each failing further along, and every cause was the same shape: something the shared node_modules or the monorepo's layout supplied without anyone declaring it. 1. The config plugin had the monorepo's geometry written into it — ../../../packages/officer-net/ios/libs, correct from apps/<name>/ios and meaningless from a repo root. It reported a path belonging to neither repo. Now derived from the plugin's own location with path.relative, so it is right wherever the module sits. 2. That fix appeared to do nothing, because officer-net was a dependency and bun COPIES those. Prebuild ran a week-old copy while the source was edited — a clean edit and a stale build, the same shadow-copy trap that cost a night in the monorepo. Making it a workspace turns node_modules/officer-net into a symlink. 3. @babel/plugin-transform-react-jsx was never declared by anything. It was hoisted into the monorepo by some other package, so babel-preset-expo with jsxImportSource found it by luck. 4. Same for react-native-reanimated and react-native-worklets: nativewind depends on react-native-css-interop, which lists reanimated as a PEER, whose babel plugin requires react-native-worklets/plugin. Three levels of nobody-declared-it. 5. And the self-inflicted one. Adding for (2) silently flipped bun to its isolated linker — only direct dependencies at top level, everything else symlinked into node_modules/.bun, which Metro cannot resolve. It surfaced as 'Unable to resolve module react-native-css-interop/jsx-runtime', naming a package that was installed and in the lockfile and simply not where the resolver looks. bunfig.toml now pins both properties that are needed at once: workspaces for the symlink, hoisted for the layout. Result: OffScale.app, 82 MB, with OffTailTunnel.appex at 20 MB carrying 129 Tailcore symbols — the Go core is in the extension, which is the half that matters. Not yet run on a device. It builds; that is a different claim. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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:
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:
grep Tailcore modules/officer-net/ios/libs/TailCore.xcframework/ios-arm64/Tailcore.framework/Headers/*.h
Requires Go plus gomobile on PATH:
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:
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.netby 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 stillrunningat the next start. neBundleIdmust come fromOfficerNetExtensionBundleIdinInfo.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.goregistersnetns.SetAndroidProtectFunc), not by excluding the whole app from its own tunnel.gomobilegeneratesboolean protect(int)fromtailcore.Protector— the same signature asVpnService.protect(int)— so a service implementing the interface directly would have its override call itself forever. It delegates throughCoreProtector. - Logging out needs a RUNNING extension. The identity is
tailscaled.stateinside the extension's container (Android:filesDir), unreachable from the app process, sologout()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.