Files
offscale-mobile/modules/officer-net/gocore/protect.go
T
pastilhas ef3582f2aa 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.
2026-08-14 20:32:17 +01:00

66 lines
2.6 KiB
Go

//go:build android
package tailcore
/*
Give the Tailscale core a way to keep its own sockets out of its own tunnel.
WHY THIS EXISTS
A VPN has a bootstrap problem: the core's WireGuard sockets must reach the internet, but the tunnel
they create captures the internet. Left alone, the encrypted packets are routed back into the tunnel
that produced them and loop forever.
Android's answer is `VpnService.protect(fd)`, which marks ONE socket as belonging outside the tunnel.
This app has been using a much blunter substitute — `addDisallowedApplication(packageName)`, which
excludes the ENTIRE app from its own VPN. That does stop the loop, and it also means nothing this app
does can ever travel through the tunnel it is holding open: not an in-app browser, not Officer's calls
to the platform, not the core reaching a service that only exists on the tailnet.
`SetProtector` narrows it to what actually needs excluding. The core protects the sockets it dials
out; everything else the app does is free to use the tunnel like any other app's traffic.
WHAT THIS DOES NOT DO
It does not decide whether an address inside the tailnet is reachable — that is tsdial's business and
goes over WireGuard rather than any OS socket. Do not expect this to make an in-tailnet DNS resolver
start answering; that was a separate claim and it was never verified.
Android only. iOS Network Extensions are excluded from their own tunnel by the OS, with no equivalent
call and no equivalent problem — hence the build tag.
*/
import (
"errors"
"tailscale.com/net/netns"
)
// Protector is implemented on the Java side by the VpnService, which is the only object that can call
// protect(). gomobile generates a Java interface from this; int32 maps to Java's int, matching
// VpnService.protect(int).
type Protector interface {
// Protect marks a socket as belonging outside the tunnel. False means the call failed, which
// Android reports as a plain boolean with no reason attached.
Protect(fd int32) bool
}
// SetProtector installs the callback, or removes it when p is nil.
//
// Must be called BEFORE Start: the core dials as soon as it comes up, and a socket created before the
// hook is registered is an unprotected one that will loop.
func SetProtector(p Protector) {
if p == nil {
netns.SetAndroidProtectFunc(nil)
return
}
netns.SetAndroidProtectFunc(func(fd int) error {
if p.Protect(int32(fd)) {
return nil
}
// Returning an error here fails the dial, which is correct: an unprotected socket would loop
// back into the tunnel, and a hung connection is harder to diagnose than a refused one.
return errors.New("VpnService.protect failed")
})
}