init: OffScale — standalone MIT Tailscale/Headscale client

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.
This commit is contained in:
2026-08-14 20:32:17 +01:00
commit ef3582f2aa
68 changed files with 11534 additions and 0 deletions
+31
View File
@@ -0,0 +1,31 @@
apply plugin: 'com.android.library'
group = 'expo.modules.officernet'
version = '0.1.0'
def expoModulesCorePlugin = new File(project(":expo-modules-core").projectDir.absolutePath, "ExpoModulesCorePlugin.gradle")
apply from: expoModulesCorePlugin
applyKotlinExpoModulesCorePlugin()
useCoreDependencies()
useDefaultAndroidSdkVersions()
useExpoPublishing()
android {
namespace "expo.modules.officernet"
defaultConfig {
minSdkVersion 24
}
// The gomobile-built Tailscale core (libtailscale) ships as officernet.aar → exploded into
// libs/officernet.jar (Kotlin/Java bindings) + src/main/jniLibs/<abi>/libgojni.so (the Go runtime),
// by scripts/build-libtailscale-android.sh. jniLibs/<abi> is auto-picked up from src/main/jniLibs.
// arm64-v8a at minimum; add x86_64 for the emulator. All gitignored (see ../.gitignore).
}
dependencies {
// The vendored gomobile Tailscale core bindings (tailcore.Tailcore). `api` so the consuming app links
// the types too. Produced by scripts/build-libtailscale-android.sh (aar → libs/tailcore.jar +
// src/main/jniLibs/arm64-v8a/libgojni.so). Gitignored; present after that script runs.
api files('libs/tailcore.jar')
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.10.1'
}
@@ -0,0 +1,4 @@
<!-- The FOREGROUND_SERVICE / VpnService permissions + the <service> registration are injected into the
consuming app's manifest by the Expo config plugin (plugin/app.plugin.js → withAndroidManifest), so
they can be toggled via plugin props. This module manifest stays empty, mirroring bw-vault. -->
<manifest xmlns:android="http://schemas.android.com/apk/res/android"/>
@@ -0,0 +1,55 @@
package expo.modules.officernet
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.net.VpnService
import android.util.Log
/**
* Bring the tunnel back after a reboot — but only if the user had it on.
*
* Android's counterpart to iOS's on-demand rules. Without this the VPN comes back DOWN after a
* restart and stays down until someone opens the app and taps Connect, which is silent and easy to
* miss: sites behind the tailnet simply fail while everything else works.
*
* `KEY_WANT_CONNECTED` is the user's intent, not the tunnel's state. `up()` sets it, `down()` clears
* it, and only `down()` clears it — a crash, a reboot or the system killing the service all leave it
* set, so those recover. A deliberate disconnect stays disconnected until the user says otherwise.
*
* `VpnService.prepare` is re-checked because consent can be revoked while the phone is off (another
* VPN taking over, for instance). It returns non-null when consent is needed, and that needs an
* activity we do not have at boot — so we stand down rather than crash, and the next manual connect
* will show the dialog properly.
*/
class BootReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
// BOOT_COMPLETED only. LOCKED_BOOT_COMPLETED fires before the user has unlocked, when there is
// no network and our prefs live in credential-encrypted storage we cannot even read yet.
if (intent.action != Intent.ACTION_BOOT_COMPLETED) return
val prefs = context.getSharedPreferences("officer_net", Context.MODE_PRIVATE)
if (!prefs.getBoolean(KEY_WANT_CONNECTED, false)) {
Log.i(TAG, "boot: user had the tunnel off — leaving it off")
return
}
if (!prefs.getBoolean(KEY_REGISTERED, false)) {
Log.i(TAG, "boot: no node identity yet — nothing to reconnect")
return
}
if (VpnService.prepare(context) != null) {
Log.w(TAG, "boot: VPN consent not granted; a manual connect is needed")
return
}
Log.i(TAG, "boot: reconnecting")
context.startForegroundService(
Intent(context, OfficerVpnService::class.java).setAction(ACTION_UP),
)
}
private companion object {
const val TAG = "OfficerVpnBoot"
}
}
@@ -0,0 +1,336 @@
package expo.modules.officernet
import android.app.Activity
import android.content.Context
import android.content.Intent
import android.net.VpnService
import expo.modules.kotlin.modules.Module
import expo.modules.kotlin.modules.ModuleDefinition
import expo.modules.kotlin.Promise
import tailcore.Tailcore
// OffTail VPN engine — Android RN-process module. Parity with ios/OfficerNetModule.swift and the JS
// bridge in packages/core/src/services/officer-net.ts (requireNativeModule('OfficerNet')).
//
// Unlike iOS (a separate NE process), Android's VpnService runs in THIS app's process — so this module
// binds/starts OfficerVpnService directly. The Tailscale core (libtailscale, gomobile .aar) is hosted
// inside OfficerVpnService, which owns the tun fd. This module just: persists config, drives the
// one-time system consent (VpnService.prepare), and starts/stops the service.
//
// The core call-sites (register/up/status via the gomobile bindings) live in OfficerVpnService.kt.
private const val PREFS = "officer_net"
const val KEY_CONTROL_URL = "controlUrl"
const val KEY_HOSTNAME = "hostname"
const val KEY_AUTH_KEY = "authKey" // single-use Headscale pre-auth key; consumed by the core on up().
const val KEY_REGISTERED = "registered" // set by the service once the core has a persisted identity.
/** The USER's intent, not the tunnel's state — only down() clears it, so crashes and reboots recover. */
const val KEY_WANT_CONNECTED = "wantConnected"
const val ACTION_UP = "dev.officer.mobile.net.UP"
const val ACTION_DOWN = "dev.officer.mobile.net.DOWN"
// Request code for the VpnService.prepare() consent activity result, reached via up().
private const val VPN_PREPARE_REQUEST = 0xF00D
// Request code for consent asked for ON ITS OWN, by requestAuthorization(), before any tailnet is
// chosen. It has to be distinct from the one above: that path starts the tunnel as soon as consent
// lands, and here there is nothing to start — no control URL has been entered yet.
private const val VPN_AUTHORIZE_REQUEST = 0xF00E
class OfficerNetModule : Module() {
private val context: Context
get() = requireNotNull(appContext.reactContext) { "React context unavailable" }
private fun prefs() = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
/**
* Forget the cached node IP after anything that changes which tailnet we are on.
*
* The cached address is per-DEVICE but describes the CURRENT tailnet, and startTunnel trusts it to
* build the tun up front. After a switch it belongs to the network just left, so the tun comes up
* holding an address from the wrong tailnet — peers become unreachable even by IP, which reads as a
* routing or server fault rather than a stale cache.
*
* iOS has the same fix in the extension (forgetCachedNetmap); this is its Android counterpart, and
* it was missed when the rest of that family was closed.
*/
private fun forgetCachedNodeIp() {
// The exit node goes with it: it is a peer on the tailnet being left, so keeping it would claim
// the default route for a machine that is not on the new network.
prefs().edit().remove("nodeIpv4").remove("nodeIpv6").remove(KEY_EXIT_NODE).apply()
}
// Pending up() promise, resolved after the user answers the consent dialog (see OnActivityResult).
private var pendingUp: Promise? = null
// Pending requestAuthorization() promise. Separate from pendingUp so the two flows cannot resolve
// each other's caller — they answer different questions and only one of them starts the tunnel.
private var pendingAuthorize: Promise? = null
override fun definition() = ModuleDefinition {
Name("OfficerNet")
// Native → JS status/peer stream. Emitted by OfficerVpnService via the module (see emitStatus()).
Events("onStatus")
OnCreate {
// OfficerVpnService pushes status snapshots here so onStatus/status() can carry peer detail.
OfficerVpnService.statusSink = { snapshot -> this@OfficerNetModule.sendEvent("onStatus", snapshot) }
}
OnDestroy {
OfficerVpnService.statusSink = null
}
// Point the node at a Headscale control server. Stored for the service to read on up(). No I/O.
AsyncFunction("configure") { controlUrl: String, hostname: String? ->
prefs().edit()
.putString(KEY_CONTROL_URL, controlUrl)
.putString(KEY_HOSTNAME, hostname)
.apply()
}
// Register the node with a Headscale pre-auth key. We only stash it; the core consumes it on the
// next up() (the core owns + persists the node/WireGuard keys in the Keystore).
AsyncFunction("loginWithAuthKey") { authKey: String ->
prefs().edit().putString(KEY_AUTH_KEY, authKey).apply()
}
// The embedded Tailscale core version, from the vendored gomobile .aar (tailcore.Tailcore).
AsyncFunction("coreVersion") {
Tailcore.version()
}
// ── Profiles ─────────────────────────────────────────────────────────────────────────────
//
// Several tailnets on one device. The core has always supported it; these four just expose it.
// All of them require the node to be started, because they operate on the running LocalBackend —
// calling them while the tunnel is down returns "not started" from Go rather than crashing.
/** Every stored login as JSON, with the active one flagged. */
AsyncFunction("listProfiles") {
Tailcore.listProfiles()
}
/**
* Make an existing login active.
*
* The tun is NOT recycled here: the node re-registers under the new identity and the netmap —
* and therefore the IPs and routes — change underneath it. The caller brings the tunnel down and
* up around this, which is also what makes the UI honest about what is happening.
*/
AsyncFunction("switchProfile") { id: String ->
Tailcore.switchProfile(id)
forgetCachedNodeIp()
}
/** Begin an additional login. Lands in NeedsLogin, so status().authUrl carries where to go. */
AsyncFunction("newProfile") {
Tailcore.newProfile()
}
/**
* Join ANOTHER tailnet on the live backend, without recycling the tunnel.
*
* newProfile() alone cannot do it. It creates a blank profile, but the control URL and pre-auth
* key only reach the core through Start — and restarting to trigger that LOSES the blank profile,
* because an unregistered one is not persisted: the reload falls back to the previous profile and
* Start overwrites ITS control URL. The second tailnet then silently replaces the first, keeping
* the same profile id. Doing both inside the core is the only ordering where neither step undoes
* the other. See LoginTo in gocore/tailcore.go.
*/
AsyncFunction("loginToNewProfile") { controlUrl: String, authKey: String?, hostname: String? ->
Tailcore.loginTo(controlUrl, authKey ?: "", hostname ?: "")
// Keep the app's own record in step, or the next tunnel start hands the core the PREVIOUS
// tailnet's URL — the same global-vs-per-profile trap that put a Tailscale node on Headscale.
prefs().edit().putString(KEY_CONTROL_URL, controlUrl).putString(KEY_HOSTNAME, hostname).apply()
// The new tailnet issues its own address; the cached one belongs to the previous network.
forgetCachedNodeIp()
Unit
}
// ── Live preferences ────────────────────────────────────────────────────────────────────
//
// EditPrefs on the running node, so choosing an exit node does not drop the tunnel.
/**
* Route all traffic through a peer. Empty id turns it off.
*
* The choice is also written to prefs, because the TUN has to be built differently for it — an
* exit node needs the default route, not just the tailnet ranges. The service watches this and
* re-establishes; setting the core's preference alone changes nothing observable.
*/
AsyncFunction("setExitNode") { stableId: String? ->
Tailcore.setExitNode(stableId ?: "")
prefs().edit().putString(KEY_EXIT_NODE, stableId ?: "").apply()
}
/** Use subnet routes advertised by other nodes. Off by default in Tailscale. */
AsyncFunction("setAcceptRoutes") { on: Boolean ->
Tailcore.setAcceptRoutes(on)
}
/** Live prefs as JSON — what IS set, not what was last asked for. */
AsyncFunction("prefs") {
Tailcore.prefs()
}
/*
Collect a pending report about how the LAST tunnel run ended, or "" if it ended cleanly.
Read straight off disk rather than through the service: the file is in the app's own filesDir, and
requiring a running service would mean the one case worth reporting — a tunnel that died and was
never restarted — is the case that can never be read. iOS has no such luxury and must ask the
extension.
A TAKE, not a get: the report is cleared once handed over, so a crash is reported once.
*/
AsyncFunction("lastExitReport") {
val f = java.io.File(context.filesDir, "last-exit.json")
if (!f.exists()) return@AsyncFunction ""
runCatching {
val obj = org.json.JSONObject(f.readText())
val previous = obj.optJSONObject("previous") ?: return@runCatching ""
obj.put("previous", org.json.JSONObject.NULL)
f.writeText(obj.toString())
previous.toString()
}.getOrElse { "" }
}
/** Forget a login. Deleting the ACTIVE one leaves the node logged out — switch away first. */
AsyncFunction("deleteProfile") { id: String ->
Tailcore.deleteProfile(id)
forgetCachedNodeIp()
}
// Whether a node identity already exists on this device (the service flips this once registered).
AsyncFunction("isRegistered") {
prefs().getBoolean(KEY_REGISTERED, false)
}
/**
* Whether the OS has granted this app the right to run a tunnel.
*
* This is NOT `isRegistered` — that answers "does this device have a node identity on some
* tailnet", which is a completely different question and can be true while consent is absent
* (reinstall, restore, or the user revoking VPN rights). The first-run flow needs them apart:
* "Allow VPN" comes before choosing a tailnet, and choosing a tailnet is what creates identity.
*
* `VpnService.prepare()` is the authority and is cheap — it returns an Intent when consent is
* needed and null once granted. Never cache the answer: consent is revocable at any time from
* Settings, or by another VPN app taking over, and a stale `true` leaves a connect toggle that
* silently does nothing.
*/
AsyncFunction("isAuthorized") {
VpnService.prepare(context) == null
}
/**
* Ask for tunnel consent on its own, without connecting.
*
* Deliberately does not start the service on success: at this point in the first-run flow no
* control URL exists, so there is nothing to connect to. Starting here would fail confusingly, or
* worse, come up against whatever stale prefs a previous install left behind.
*/
AsyncFunction("requestAuthorization") { promise: Promise ->
val consent = VpnService.prepare(context)
if (consent == null) {
promise.resolve(true) // already granted — nothing to ask
} else {
val activity = appContext.currentActivity
if (activity == null) {
// Nothing to host the system dialog. Report "not granted" rather than throwing: the caller
// shows the same "Allow VPN" button either way, and it will succeed once foregrounded.
promise.resolve(false)
} else {
pendingAuthorize = promise
activity.startActivityForResult(consent, VPN_AUTHORIZE_REQUEST)
}
}
}
// Bring the tunnel up. VpnService needs a ONE-TIME system consent dialog: VpnService.prepare()
// returns a consent Intent the first time, or null once granted. If it returns an intent we launch
// it from the current activity and resolve after OnActivityResult; otherwise we start the service now.
AsyncFunction("up") { promise: Promise ->
val consent = VpnService.prepare(context)
if (consent != null) {
val activity = appContext.currentActivity
if (activity == null) {
// No foreground activity to host the dialog — tell JS consent is needed so it can retry.
promise.resolve(mapOf("needsConsent" to true))
} else {
pendingUp = promise
activity.startActivityForResult(consent, VPN_PREPARE_REQUEST)
}
} else {
prefs().edit().putBoolean(KEY_WANT_CONNECTED, true).apply()
startVpn()
promise.resolve(mapOf("needsConsent" to false))
}
}
// Bring the tunnel down.
AsyncFunction("down") {
// Cleared here and ONLY here: this is the one path that means the user chose to be off, so
// BootReceiver leaves it off. Anything else — a crash, a reboot, the system reclaiming the
// service — leaves the flag set and recovers.
prefs().edit().putBoolean(KEY_WANT_CONNECTED, false).apply()
context.startService(serviceIntent(ACTION_DOWN))
// Explicit Unit: startService returns a ComponentName, and Kotlin would make that this
// lambda's return value. Expo cannot bridge it and rejects the whole call — the function runs,
// then fails on the way back, so the tunnel stops but JS sees an error.
Unit
}
// Current status snapshot. Sourced from the running service (self + peers from the core's netmap);
// "stopped" when the service isn't running.
AsyncFunction("status") {
OfficerVpnService.lastStatus ?: mapOf(
"state" to "stopped",
"self" to null,
"peers" to emptyList<Any>(),
"error" to null,
)
}
// Forget the node identity + keys (full reset): stop the tunnel, tell the service to wipe the core
// identity, and clear stored config.
AsyncFunction("logout") {
context.startService(serviceIntent(ACTION_DOWN).putExtra("logout", true))
prefs().edit()
.remove(KEY_CONTROL_URL).remove(KEY_HOSTNAME).remove(KEY_AUTH_KEY).remove(KEY_REGISTERED)
.remove(KEY_WANT_CONNECTED)
.apply()
}
// Resolve the pending up() promise once the consent dialog returns.
OnActivityResult { _, payload ->
if (payload.requestCode == VPN_PREPARE_REQUEST) {
val granted = payload.resultCode == Activity.RESULT_OK
if (granted) {
// Same as the no-consent-needed path: the user asked for this, so remember it for boot.
prefs().edit().putBoolean(KEY_WANT_CONNECTED, true).apply()
startVpn()
}
pendingUp?.resolve(mapOf("needsConsent" to false, "granted" to granted))
pendingUp = null
}
// Consent asked for on its own: report the answer and start nothing. The first-run flow moves
// to "pick a tailnet" next, and the tunnel starts when that produces a control URL.
if (payload.requestCode == VPN_AUTHORIZE_REQUEST) {
pendingAuthorize?.resolve(payload.resultCode == Activity.RESULT_OK)
pendingAuthorize = null
}
}
}
private fun serviceIntent(action: String): Intent =
Intent(context, OfficerVpnService::class.java).setAction(action)
private fun startVpn() {
// Foreground service — required for a long-running VpnService (foregroundServiceType=specialUse).
context.startForegroundService(serviceIntent(ACTION_UP))
}
}