# OffScale A mesh VPN client for iOS and Android that works with **your own Headscale** and with **Tailscale's** hosted control plane, treating both as equals. It embeds the real Tailscale client core (Go, compiled with `gomobile`) and runs it inside the OS VPN APIs — `NEPacketTunnelProvider` on iOS, `VpnService` on Android. It is not a wrapper around another app and not a reimplementation of the protocol: it is the same core, in a different shell. MIT licensed, free, and intended to stay both. ## Why it exists The thing that made this worth building is that **Headscale is a first-class destination here, not an "advanced" option buried three screens deep.** If you self-host, you should not have to feel like you are using the software wrong. First run offers two equal buttons: your Headscale, or Tailscale's network. ## It never talks to a server of ours There is no sign-in, no account, no analytics, no crash reporting and no update check. There is no backend belonging to this project for the app to contact, and no code path that reaches for one. The only hosts OffScale ever connects to are: - **the control server you point it at** — your Headscale, or `controlplane.tailscale.com`; - **the peers on your tailnet**, over WireGuard; - **the enrolment sidecar named inside an invite link**, and only if you tapped one (see `src/vpn/invite.ts`; the base URL comes out of the link's fragment and nowhere else). This is a design constraint rather than a current fact. A VPN client that needed some other service to be reachable in order to authenticate would be a bootstrap deadlock, because the tunnel is very often the thing that makes that service reachable in the first place. Please keep it that way in any fork: the dependency runs one direction only. Node keys and WireGuard keys are generated and held by the core in the Keychain / Keystore. They never cross into Swift, Kotlin or JavaScript, let alone the network. ## Features - Multiple tailnets at once, with a switcher — a self-hosted Headscale and a Tailscale account side by side, each with its own node key and prefs. - Pre-auth key enrolment, Tailscale interactive login, and one-tap invite links. - Peer list with online state, exit-node selection, and subnet-route acceptance. - Node-key expiry warnings before the device quietly stops working. - Reconnect on boot (Android), and a tunnel-crash report you can read locally. - Tablet-aware layout; phone-shaped, not stretched. ## Building Requires **bun**, **Xcode** (iOS), the **Android SDK/NDK** (Android), **Go 1.26+** with `gomobile`, and **ImageMagick** for icon generation (optional — a committed icon is used if it is missing). ```bash bun install --frozen-lockfile go install golang.org/x/mobile/cmd/gomobile@latest go install golang.org/x/mobile/cmd/gobind@latest ``` Then, with a device attached: ```bash scripts/build-ios.sh # Release .app, installed on the cabled iPhone DEVICE= scripts/build-ios.sh # pick one, when several are paired NO_INSTALL=1 scripts/build-ios.sh # build only scripts/build-android.sh # Release APK, installed over adb ABIS=arm64-v8a,x86_64 scripts/build-android.sh # add an emulator ABI ``` Both scripts build the **Release** configuration and nothing else. A Debug build needs Metro attached and behaves differently from what ships, so testing one answers the wrong question. Logs land in `$TMPDIR/offscale-build/`. ### Two things about the scripts that are load-bearing **They run `bun run prebuild`, not `expo prebuild`.** The package script runs `scripts/gen-adaptive-icon.sh` first, which insets the Android adaptive-icon foreground so the launcher mask does not crop the artwork. Calling `expo prebuild` directly skips that silently and ships a cropped icon. **Android builds `arm64-v8a` only.** Expo's generated `gradle.properties` asks for four ABIs, and the Tailscale core is a large `.so` that gets built once per ABI. `-PreactNativeArchitectures` is passed on the command line rather than edited into `gradle.properties`, because `expo prebuild --clean` regenerates that file on every build and an edit there survives nothing. Pass `ABIS=` to override. ### iOS needs an Xcode account signed in The packet tunnel requires the Network Extensions entitlement, and `-allowProvisioningUpdates` cannot create the profile for it 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. See `modules/officer-net/plugin/README.md` for the full provisioning list. ## Changing the Go core means rebuilding it BY HAND, per platform This is the trap that costs the most time, so it gets its own section. `modules/officer-net/gocore/` is bound with `gomobile` into a `.xcframework` (iOS) and an `.aar` (Android). **Neither is regenerated by an ordinary app build.** Touch any `.go` file, `go.mod` or `go.sum` and you must run: ```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 does not fail where you changed something. It fails as a missing symbol, and the two platforms fail very differently: | platform | error | when | |----------|-------------------------------------------------------|------| | Android | `e: Unresolved reference 'loginTo'` | Kotlin compile — loud, early, obvious | | iOS | `error: cannot find 'TailcoreListProfiles' in scope` | Swift compile, **long after the Go compiled cleanly** against a vendored xcframework whose headers are simply older than the source beside them | `scripts/ensure-core.sh` compares mtimes and runs the right script for you before each app build, which is why both build scripts source it — a rule you have to remember every time is not a rule. If you are debugging, confirm the symbols actually landed: ```bash grep Tailcore modules/officer-net/ios/libs/TailCore.xcframework/ios-arm64/Tailcore.framework/Headers/*.h ``` The built artifacts are gitignored because of their size. They are not optional. ## Layout ``` src/ App.tsx the whole shell — splash, theme, one screen ui/ shared primitives: theme, cards, tabs, the wallpaper frame vpn/ the screen, its state hook, the native bridge, invite handling settings/ the settings page (no accounts, no servers, no sign-in) modules/officer-net/ the native module: Go core shim, Swift NE, Kotlin VpnService, config plugin scripts/ build-ios.sh, build-android.sh, ensure-core.sh, gen-adaptive-icon.sh ``` `src/vpn/officer-net.ts` is the readable index of the native surface — every capability the app has passes through it. ## Licence MIT — see [LICENSE](LICENSE). It embeds Tailscale's client core under the BSD 3-Clause licence. **[NOTICE](NOTICE) is required reading before you distribute a build**: it carries Tailscale's copyright and licence text, says which built artifacts contain their code, and covers the trademark position separately from the licence. OffScale is an independent, unofficial client. It is not affiliated with, endorsed by or supported by Tailscale Inc. Please do not ask their support about it.