Mirrors monorepo-mobile/apps/offscale. This repo has no icon pipeline — its assets are committed directly — so the framed icon is copied in rather than generated here. Both trees have to be updated for every icon change until one of them goes away. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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).
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:
scripts/build-ios.sh # Release .app, installed on the cabled iPhone
DEVICE=<udid> 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:
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:
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.
It embeds Tailscale's client core under the BSD 3-Clause licence. 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.
Upgrading over the air: turn the tunnel off first
An update from the install page downloads in full and then silently does nothing while the tunnel is running. No error, no progress, the app simply stays at the old version.
Toggle the VPN off in OffScale, install, toggle it back on. That is the whole workaround — it is done in the app, not in iOS Settings, and it takes a tap.
Established on 2026-08-15 across seven attempts. Every upgrade with the tunnel down succeeded; every upgrade with it up failed the same way. The server log tells them apart, and it is the only place the difference is visible:
working GET manifest.plist → HEAD .ipa → GET .ipa then the app updates
failing GET manifest.plist → HEAD .ipa → GET .ipa then nothing, and a retry
The likely cause is that iOS will not replace a bundle whose network extension is live — the packet
tunnel runs from inside the app bundle being replaced. Installing the same .ipa with
devicectl device install app succeeds while connected, because that path goes through developer
services rather than the ordinary installer.
Not yet worked around in software, and it may not be worth it: the OS gives the app no signal that an install was attempted, so there is nothing to react to. A first install is unaffected — there is no tunnel yet — which is the case that matters for someone new.