Extracted from the officer-suite monorepo into its own public repo. The app is
open source and free; the rest of the suite is not, so it could not stay in a
tree that ships an account layer.
What moved:
- apps/offscale/* -> repo root (App shell, app.json, assets)
- packages/officer-net/ -> modules/officer-net/, a `file:` dependency
- packages/core VPN closure -> src/vpn/ (screen, hook, native bridge, invite)
- packages/core shared primitives -> src/ui/ (theme, cards, tabs, backdrop, splash)
- scripts/build-{ios,android}.sh -> scripts/, cut down to this one app
What deliberately did NOT move: the platform account layer. VpnScreen used to
render core's AppSettingsScreen, which drags in ServersSection -> servers.ts,
useAuth, api.ts, shared-session, install-guard, api-keys. None of that exists
here. src/settings/SettingsScreen.tsx is a fresh replacement rendering exactly
what VpnScreen passed it — the app's own sections plus About — with no Servers
tab, no Appearance control (the app is pinned to dark) and no sign-out.
The one platform reference left in the closure was a dead `import { request }
from './api'` in officer-net.ts, unused since enrollVpn() was removed. Gone. The
app now contacts nothing but the control server you point it at, its peers, and
the enrolment sidecar named inside an invite link you tapped.
Dependencies are pinned to the exact versions the monorepo has installed, not to
ranges: this repo starts from versions already proven to build, and an unpinned
install is a supply-chain decision nobody made. react-query, react-dom,
react-native-screens, expo-secure-store, track-player, expo-audio and
expo-file-system were all declared by the old app and imported by none of this
code, so they are not here.
NOTICE carries Tailscale's BSD-3 text, names the built artifacts that contain
their compiled code, and treats the trademark question separately — including
that src/ui/TailscaleMark.tsx reproduces their logo in the attribution strip.
134 lines
6.8 KiB
Markdown
134 lines
6.8 KiB
Markdown
# 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.
|