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:
@@ -0,0 +1,54 @@
|
||||
# officer-net Expo config plugin
|
||||
|
||||
Wires the OS-level capabilities the VPN needs into the app at prebuild. Enabled from `app.json`:
|
||||
|
||||
```js
|
||||
plugins: [
|
||||
["officer-net", {
|
||||
appGroup: "group.pastilhas.officer.offscale",
|
||||
neBundleId: "dev.officer.offscale.net",
|
||||
enableNetworkExtension: true,
|
||||
}],
|
||||
]
|
||||
```
|
||||
|
||||
Props (all optional; defaults shown):
|
||||
|
||||
| prop | default | what it controls |
|
||||
|--------------------------|------------------------------------|---------------------------------------------------------|
|
||||
| `appGroup` | `group.pastilhas.officer.mobile` | iOS App Group shared by the app and the NE |
|
||||
| `neBundleId` | `dev.officer.mobile.net` | the Network Extension target's bundle id |
|
||||
| `specialUseSubtype` | VPN mesh tunnel justification | Android `FOREGROUND_SERVICE_SPECIAL_USE` purpose |
|
||||
| `enableNetworkExtension` | `false` | add the iOS entitlement and synthesise the NE target |
|
||||
|
||||
`enableNetworkExtension` is **off by default** because the entitlement fails device code-signing until
|
||||
the Apple provisioning below exists. With it off the app signs, installs and renders; the engine is
|
||||
inert. The repo's own `app.json` turns it on.
|
||||
|
||||
## What the plugin does
|
||||
|
||||
- **iOS** — adds `com.apple.developer.networking.networkextension` (`packet-tunnel-provider`) to the
|
||||
main app, and writes `OfficerNetExtensionBundleId` into `Info.plist`. That key is **load-bearing**,
|
||||
not a breadcrumb: the Swift module reads it to fill `providerBundleIdentifier`, which is how iOS
|
||||
knows which extension to launch. A hardcoded value there means the start command is accepted, nothing
|
||||
matches, and the tunnel silently never starts.
|
||||
- **iOS, under `enableNetworkExtension`** — synthesises the Network Extension Xcode target
|
||||
(`withNetworkExtensionTarget.js`): a new target compiling this package's
|
||||
`ios/PacketTunnelProvider.swift`, its `Info.plist` (`NSExtensionPointIdentifier =
|
||||
com.apple.networkextension.packet-tunnel`), its own entitlements, `NetworkExtension.framework`, the
|
||||
vendored `TailCore.xcframework`, and the app's *Embed App Extensions* phase. Doing this by hand after
|
||||
every `expo prebuild --clean` is what this replaces.
|
||||
- **Android** — adds `FOREGROUND_SERVICE` and `FOREGROUND_SERVICE_SPECIAL_USE`, and registers
|
||||
`OfficerVpnService` with `BIND_VPN_SERVICE`, `foregroundServiceType="specialUse"` and the
|
||||
`android.net.VpnService` intent-filter.
|
||||
|
||||
## Apple provisioning prerequisites (one-time, in the Apple Developer portal)
|
||||
|
||||
- A separate **App ID for the extension** (`neBundleId`) with the **Network Extensions** capability.
|
||||
- The **Network Extensions** capability on the main app ID.
|
||||
- Provisioning profiles for both, with those capabilities.
|
||||
|
||||
`xcodebuild -allowProvisioningUpdates` cannot create these while Xcode has no account signed in: it
|
||||
falls back to the wildcard team profile and fails with *"Provisioning profile 'iOS Team Provisioning
|
||||
Profile: \*' doesn't include the Network Extensions capability"*. Sign in under **Xcode → Settings →
|
||||
Accounts** first. Android needs none of this.
|
||||
@@ -0,0 +1,141 @@
|
||||
// Expo config plugin for officer-net. Wires the OS-level capabilities the VPN engine needs into the
|
||||
// consuming app at prebuild time. Native code lives in the package; this plugin touches manifests,
|
||||
// entitlements and Info.plist, plus (under enableNetworkExtension) the iOS Network Extension target.
|
||||
//
|
||||
// Usage in app.json:
|
||||
// plugins: [
|
||||
// ["officer-net", { appGroup: "group.pastilhas.officer.offscale",
|
||||
// neBundleId: "dev.officer.offscale.net",
|
||||
// enableNetworkExtension: true }]
|
||||
// ]
|
||||
//
|
||||
// iOS is the hard part: a packet tunnel requires a SECOND native target (the Network Extension) that
|
||||
// embeds PacketTunnelProvider.swift. withNetworkExtensionTarget.js synthesises it — see
|
||||
// plugin/README.md for what that needs from the Apple Developer portal first.
|
||||
|
||||
const {
|
||||
withEntitlementsPlist,
|
||||
withInfoPlist,
|
||||
withAndroidManifest,
|
||||
AndroidConfig,
|
||||
createRunOncePlugin,
|
||||
} = require('@expo/config-plugins');
|
||||
const { withNetworkExtensionTarget } = require('./withNetworkExtensionTarget');
|
||||
|
||||
const DEFAULTS = {
|
||||
// App Group IDs are globally unique across ALL Apple accounts, so this uses your own domain
|
||||
// (group.pastilhas.*) to guarantee it's free + yours.
|
||||
appGroup: 'group.pastilhas.officer.mobile',
|
||||
neBundleId: 'dev.officer.mobile.net',
|
||||
// Android FOREGROUND_SERVICE_SPECIAL_USE (API 34+) needs a stated purpose in the manifest.
|
||||
specialUseSubtype: 'VPN mesh tunnel (Tailscale/Headscale) for OffTail',
|
||||
// The iOS Network Extension entitlement + App Group require Apple provisioning (a registered App
|
||||
// Group, an NE App ID, the Network Extensions capability) that must exist BEFORE the app can sign.
|
||||
// Until that's set up, adding the entitlement fails device code-signing — so it's OFF by default.
|
||||
// Flip to true once provisioning is in place (see ../README.md). With it off the app still signs and
|
||||
// installs, the VPN screen renders, and the engine is inert.
|
||||
enableNetworkExtension: false,
|
||||
};
|
||||
|
||||
// ── iOS: main-app entitlements ──────────────────────────────────────────────
|
||||
// The main app needs the Network Extensions capability (packet-tunnel-provider) to create/drive the
|
||||
// NETunnelProviderManager. NO App Group: config reaches the NE via the manager's providerConfiguration
|
||||
// and status comes back via handleAppMessage — so there's no App-Group entitlement to provision (which
|
||||
// -allowProvisioningUpdates won't auto-assign anyway, especially on Xcode-managed App IDs).
|
||||
function withIosEntitlements(config) {
|
||||
return withEntitlementsPlist(config, (cfg) => {
|
||||
cfg.modResults['com.apple.developer.networking.networkextension'] = ['packet-tunnel-provider'];
|
||||
return cfg;
|
||||
});
|
||||
}
|
||||
|
||||
// Record the NE bundle id in the main app's Info.plist. This is LOAD-BEARING, not a breadcrumb: the
|
||||
// Swift module reads it to fill providerBundleIdentifier, which is how iOS knows which extension to
|
||||
// launch. It used to be described as "purely informational" while the module used a hardcoded
|
||||
// dev.officer.mobile.net — so every host but Officer pointed at an extension it does not ship, and
|
||||
// the tunnel silently never started. Keep this in step with each app's `neBundleId` prop.
|
||||
function withIosInfo(config, props) {
|
||||
return withInfoPlist(config, (cfg) => {
|
||||
cfg.modResults.OfficerNetExtensionBundleId = props.neBundleId;
|
||||
return cfg;
|
||||
});
|
||||
// TODO: create the Network Extension target here (or as a separate withXcodeProject mod):
|
||||
// - add a new PBXNativeTarget of productType com.apple.product-type.app-extension
|
||||
// - embed PacketTunnelProvider.swift + NetworkExtension.framework
|
||||
// - set its bundle id = props.neBundleId, its own entitlements (networkextension +
|
||||
// the same App Group), and an Info.plist with NSExtension →
|
||||
// NSExtensionPointIdentifier = com.apple.networkextension.packet-tunnel
|
||||
// NSExtensionPrincipalClass = $(PRODUCT_MODULE_NAME).PacketTunnelProvider
|
||||
// - add it to the app's "Embed App Extensions" build phase
|
||||
// This is intentionally NOT automated yet — see plugin/README.md for the manual Xcode steps.
|
||||
}
|
||||
|
||||
// ── Android: manifest ───────────────────────────────────────────────────────
|
||||
// Adds the foreground-service permissions and registers OfficerVpnService with the BIND_VPN_SERVICE
|
||||
// permission + the android.net.VpnService intent-filter (what makes it a system VPN provider).
|
||||
function withAndroidVpn(config, props) {
|
||||
config = AndroidConfig.Permissions.withPermissions(config, [
|
||||
'android.permission.FOREGROUND_SERVICE',
|
||||
'android.permission.FOREGROUND_SERVICE_SPECIAL_USE',
|
||||
// Reconnect after a reboot if the user had the tunnel on. See BootReceiver.
|
||||
'android.permission.RECEIVE_BOOT_COMPLETED',
|
||||
]);
|
||||
|
||||
return withAndroidManifest(config, (cfg) => {
|
||||
const app = AndroidConfig.Manifest.getMainApplicationOrThrow(cfg.modResults);
|
||||
app.service = app.service || [];
|
||||
|
||||
const name = 'expo.modules.officernet.OfficerVpnService';
|
||||
if (!app.service.some((s) => s.$?.['android:name'] === name)) {
|
||||
app.service.push({
|
||||
$: {
|
||||
'android:name': name,
|
||||
'android:permission': 'android.permission.BIND_VPN_SERVICE',
|
||||
'android:foregroundServiceType': 'specialUse',
|
||||
'android:exported': 'false',
|
||||
},
|
||||
'intent-filter': [{ action: [{ $: { 'android:name': 'android.net.VpnService' } }] }],
|
||||
// API 34+ requires declaring the special-use subtype/justification.
|
||||
property: [
|
||||
{
|
||||
$: {
|
||||
'android:name': 'android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE',
|
||||
'android:value': props.specialUseSubtype,
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
// Boot receiver: brings the tunnel back after a restart, but only when the user left it on.
|
||||
// exported=true is required — the sender is the system, not us.
|
||||
app.receiver = app.receiver || [];
|
||||
const receiver = 'expo.modules.officernet.BootReceiver';
|
||||
if (!app.receiver.some((r) => r.$?.['android:name'] === receiver)) {
|
||||
app.receiver.push({
|
||||
$: { 'android:name': receiver, 'android:exported': 'true', 'android:enabled': 'true' },
|
||||
'intent-filter': [
|
||||
{
|
||||
action: [{ $: { 'android:name': 'android.intent.action.BOOT_COMPLETED' } }],
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
return cfg;
|
||||
});
|
||||
}
|
||||
|
||||
const withOfficerNet = (config, userProps = {}) => {
|
||||
const props = { ...DEFAULTS, ...userProps };
|
||||
// The NE entitlement + App Group + the NE target only go on once Apple provisioning exists — otherwise
|
||||
// device signing fails. Gated so the inert-shell builds stay signable.
|
||||
if (props.enableNetworkExtension) {
|
||||
config = withIosEntitlements(config, props);
|
||||
config = withNetworkExtensionTarget(config, props);
|
||||
}
|
||||
config = withIosInfo(config, props);
|
||||
config = withAndroidVpn(config, props);
|
||||
return config;
|
||||
};
|
||||
|
||||
const pkg = require('../package.json');
|
||||
module.exports = createRunOncePlugin(withOfficerNet, pkg.name, pkg.version);
|
||||
@@ -0,0 +1,163 @@
|
||||
// Adds the iOS Network Extension (Packet Tunnel Provider) target to the app's Xcode project at prebuild
|
||||
// time, so it survives `expo prebuild` (a hand-added target would be wiped). Mirrors the proven pattern
|
||||
// expo-share-intent uses to add its share-extension target (addPbxGroup → addTarget "app_extension" →
|
||||
// addBuildPhase → build settings), plus the two things a VPN NE needs that a share extension doesn't:
|
||||
// the packet-tunnel Info.plist + entitlements, and linking the vendored TailCore.xcframework (the
|
||||
// gomobile Tailscale core) into the NE target — the core runs in the NE process.
|
||||
//
|
||||
// Only runs when props.enableNetworkExtension is true (needs Apple provisioning first; see README).
|
||||
|
||||
const { withXcodeProject, withDangerousMod } = require('@expo/config-plugins');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const EXT_NAME = 'OffTailTunnel'; // Xcode target + group name (bundle id is set separately)
|
||||
|
||||
// The NE's Info.plist: declares it a packet-tunnel provider whose principal class is our PacketTunnelProvider.
|
||||
const infoPlist = () => `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleName</key><string>$(PRODUCT_NAME)</string>
|
||||
<key>CFBundleDisplayName</key><string>OffTail Tunnel</string>
|
||||
<key>CFBundleExecutable</key><string>$(EXECUTABLE_NAME)</string>
|
||||
<key>CFBundleIdentifier</key><string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key><string>6.0</string>
|
||||
<key>CFBundlePackageType</key><string>XPC!</string>
|
||||
<key>CFBundleShortVersionString</key><string>$(MARKETING_VERSION)</string>
|
||||
<key>CFBundleVersion</key><string>$(CURRENT_PROJECT_VERSION)</string>
|
||||
<key>NSExtension</key>
|
||||
<dict>
|
||||
<key>NSExtensionPointIdentifier</key><string>com.apple.networkextension.packet-tunnel</string>
|
||||
<key>NSExtensionPrincipalClass</key><string>$(PRODUCT_MODULE_NAME).PacketTunnelProvider</string>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
||||
`;
|
||||
|
||||
// The NE's entitlements: just the packet-tunnel capability. No App Group — the app↔NE channel is
|
||||
// providerConfiguration (config in) + handleAppMessage (status out), so nothing to provision.
|
||||
const entitlements = () => `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>com.apple.developer.networking.networkextension</key>
|
||||
<array><string>packet-tunnel-provider</string></array>
|
||||
</dict>
|
||||
</plist>
|
||||
`;
|
||||
|
||||
// Pull DEVELOPMENT_TEAM from the main-app target (same helper expo-share-intent uses).
|
||||
function mainAppDevTeam(pbx) {
|
||||
const configs = pbx.pbxXCBuildConfigurationSection();
|
||||
for (const key in configs) {
|
||||
const bs = configs[key].buildSettings;
|
||||
if (!bs || !bs.PRODUCT_NAME) continue;
|
||||
const name = bs.PRODUCT_NAME.replace(/"/g, '');
|
||||
if (name.includes('Extension') || name.includes('Widget') || name.includes('Tunnel')) continue;
|
||||
if (bs.DEVELOPMENT_TEAM) return bs.DEVELOPMENT_TEAM.replace(/"/g, '');
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Stage 1 (dangerous mod): write the NE's own files (provider source, Info.plist, entitlements) into
|
||||
// ios/<EXT_NAME>/ before the Xcode-project mod references them.
|
||||
function withNeFiles(config, props) {
|
||||
return withDangerousMod(config, [
|
||||
'ios',
|
||||
(cfg) => {
|
||||
const iosRoot = cfg.modRequest.platformProjectRoot;
|
||||
const extDir = path.join(iosRoot, EXT_NAME);
|
||||
fs.mkdirSync(extDir, { recursive: true });
|
||||
// Copy our authoritative provider source out of the package into the target folder.
|
||||
const src = path.join(__dirname, '..', 'ios', 'PacketTunnelProvider.swift');
|
||||
fs.copyFileSync(src, path.join(extDir, 'PacketTunnelProvider.swift'));
|
||||
fs.writeFileSync(path.join(extDir, `${EXT_NAME}-Info.plist`), infoPlist());
|
||||
fs.writeFileSync(path.join(extDir, `${EXT_NAME}.entitlements`), entitlements());
|
||||
return cfg;
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
// Stage 2 (Xcode project): create the target, its group + build phases, link TailCore, set build settings.
|
||||
function withNeTarget(config, props) {
|
||||
return withXcodeProject(config, (cfg) => {
|
||||
const proj = cfg.modResults;
|
||||
if (proj.pbxTargetByName(EXT_NAME)) {
|
||||
console.log(`[officer-net] ${EXT_NAME} target already exists — skipping.`);
|
||||
return cfg;
|
||||
}
|
||||
|
||||
const sourceFiles = ['PacketTunnelProvider.swift'];
|
||||
const configFiles = [`${EXT_NAME}-Info.plist`, `${EXT_NAME}.entitlements`];
|
||||
|
||||
// Group holding the NE files, hung off the project root group.
|
||||
const group = proj.addPbxGroup([...sourceFiles, ...configFiles], EXT_NAME, EXT_NAME);
|
||||
const groups = proj.hash.project.objects.PBXGroup;
|
||||
Object.keys(groups).forEach((key) => {
|
||||
if (typeof groups[key] === 'object' && groups[key].name === undefined && groups[key].path === undefined) {
|
||||
proj.addToPbxGroup(group.uuid, key);
|
||||
}
|
||||
});
|
||||
|
||||
// addTarget bug workaround (single-target projects lack these sections).
|
||||
const objects = proj.hash.project.objects;
|
||||
objects.PBXTargetDependency = objects.PBXTargetDependency || {};
|
||||
objects.PBXContainerItemProxy = objects.PBXContainerItemProxy || {};
|
||||
|
||||
const target = proj.addTarget(EXT_NAME, 'app_extension', EXT_NAME);
|
||||
proj.addBuildPhase(sourceFiles, 'PBXSourcesBuildPhase', 'Sources', target.uuid);
|
||||
proj.addBuildPhase([], 'PBXResourcesBuildPhase', 'Resources', target.uuid);
|
||||
proj.addBuildPhase([], 'PBXFrameworksBuildPhase', 'Frameworks', target.uuid);
|
||||
|
||||
// Link the vendored gomobile Tailscale core (TailCore.xcframework) into the NE. The `xcode` lib has
|
||||
// no .xcframework extension mapping, so we MUST force lastKnownFileType='wrapper.xcframework' (else it
|
||||
// types it 'unknown' and Xcode never resolves the per-slice module → "no such module 'TailCore'").
|
||||
// Path is relative to SOURCE_ROOT (the ios/ project dir) and includes a dirname so the search path
|
||||
// is registered. Located in the package's libs/ (../../../ from apps/*/ios up to packages/).
|
||||
proj.addFramework('../../../packages/officer-net/ios/libs/TailCore.xcframework', {
|
||||
target: target.uuid,
|
||||
customFramework: true,
|
||||
lastKnownFileType: 'wrapper.xcframework',
|
||||
sourceTree: 'SOURCE_ROOT',
|
||||
embed: false,
|
||||
link: true,
|
||||
sign: false,
|
||||
});
|
||||
|
||||
const devTeam = mainAppDevTeam(proj);
|
||||
// Framework search path → the package's libs dir (from ios/: up to the monorepo, into packages).
|
||||
const fwSearch = '"$(PROJECT_DIR)/../../../packages/officer-net/ios/libs"';
|
||||
|
||||
const configs = proj.pbxXCBuildConfigurationSection();
|
||||
for (const key in configs) {
|
||||
const bs = configs[key].buildSettings;
|
||||
if (!bs || bs.PRODUCT_NAME !== `"${EXT_NAME}"`) continue;
|
||||
bs.INFOPLIST_FILE = `"${EXT_NAME}/${EXT_NAME}-Info.plist"`;
|
||||
bs.CODE_SIGN_ENTITLEMENTS = `"${EXT_NAME}/${EXT_NAME}.entitlements"`;
|
||||
bs.CODE_SIGN_STYLE = 'Automatic';
|
||||
bs.PRODUCT_BUNDLE_IDENTIFIER = `"${props.neBundleId}"`;
|
||||
bs.GENERATE_INFOPLIST_FILE = 'NO';
|
||||
bs.CLANG_ENABLE_MODULES = 'YES';
|
||||
bs.SWIFT_VERSION = '5.0';
|
||||
bs.SWIFT_EMIT_LOC_STRINGS = 'YES';
|
||||
bs.TARGETED_DEVICE_FAMILY = '"1,2"';
|
||||
bs.IPHONEOS_DEPLOYMENT_TARGET = '16.4';
|
||||
bs.ENABLE_BITCODE = 'NO';
|
||||
bs.MARKETING_VERSION = `"${config.version || '1.0.0'}"`;
|
||||
bs.CURRENT_PROJECT_VERSION = `"${config.ios?.buildNumber || '1'}"`;
|
||||
bs.FRAMEWORK_SEARCH_PATHS = ['"$(inherited)"', fwSearch];
|
||||
if (devTeam) bs.DEVELOPMENT_TEAM = devTeam;
|
||||
}
|
||||
|
||||
if (devTeam) {
|
||||
proj.addTargetAttribute('DevelopmentTeam', devTeam);
|
||||
proj.addTargetAttribute('DevelopmentTeam', devTeam, proj.pbxTargetByName(EXT_NAME));
|
||||
}
|
||||
|
||||
console.log(`[officer-net] Added Network Extension target ${EXT_NAME} (${props.neBundleId}).`);
|
||||
return cfg;
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { withNetworkExtensionTarget: (config, props) => withNeTarget(withNeFiles(config, props), props), EXT_NAME };
|
||||
Reference in New Issue
Block a user