Files
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

117 lines
3.6 KiB
Go

//go:build linux
package tailcore
import (
"encoding/json"
"net"
"net/netip"
"strings"
"sync/atomic"
"tailscale.com/net/netmon"
)
// Android interface enumeration bridge.
//
// Android (SDK 30+) blocks Go's net.Interfaces()/net.Interface.Addrs() — they make raw netlink
// RTM_GETLINK/GETADDR syscalls the app sandbox denies, surfacing as "route ip+net: netlinkrib:
// permission denied" during wgengine bring-up. Tailscale's netmon anticipates this
// (net/netmon/state.go: "Android SDK 30 no longer permits Go's net.Interfaces to work") and exposes
// RegisterInterfaceGetter so the host app can supply interfaces via a permitted path.
//
// We use a push model: the Kotlin VpnService enumerates interfaces with java.net.NetworkInterface (which
// Android DOES permit) and pushes a JSON snapshot here — once before Tailcore.start and again whenever
// connectivity changes. Our registered getter just parses the latest snapshot. AltAddrs is populated so
// netmon.Interface.Addrs() returns them directly and never falls back to the netlink path.
var androidIfaceJSON atomic.Value // string; latest snapshot pushed from Kotlin
type jsonIface struct {
Name string `json:"name"`
Index int `json:"index"`
MTU int `json:"mtu"`
Up bool `json:"up"`
Loopback bool `json:"loopback"`
P2P bool `json:"pointToPoint"`
Multicast bool `json:"multicast"`
MAC string `json:"mac"`
Addrs []string `json:"addrs"` // "10.0.0.2/24", "fe80::1%wlan0/64"
}
func init() {
netmon.RegisterInterfaceGetter(androidInterfaces)
}
// SetAndroidInterfaces is called from Kotlin with a JSON array snapshot of the device's network
// interfaces. Must be called before Tailcore.start (netmon reads interfaces during bring-up) and on
// each connectivity change to keep endpoint discovery current.
func SetAndroidInterfaces(jsonStr string) {
androidIfaceJSON.Store(jsonStr)
}
func androidInterfaces() ([]netmon.Interface, error) {
s, _ := androidIfaceJSON.Load().(string)
if s == "" {
// No snapshot yet — return empty rather than falling back to the (denied) netlink path.
return nil, nil
}
var raw []jsonIface
if err := json.Unmarshal([]byte(s), &raw); err != nil {
return nil, err
}
out := make([]netmon.Interface, 0, len(raw))
for _, r := range raw {
var flags net.Flags
if r.Up {
flags |= net.FlagUp | net.FlagRunning
}
if r.Loopback {
flags |= net.FlagLoopback
}
if r.P2P {
flags |= net.FlagPointToPoint
}
if r.Multicast {
flags |= net.FlagMulticast
}
var mac net.HardwareAddr
if r.MAC != "" {
mac, _ = net.ParseMAC(r.MAC)
}
ni := &net.Interface{
Index: r.Index,
MTU: r.MTU,
Name: r.Name,
HardwareAddr: mac,
Flags: flags,
}
var altAddrs []net.Addr
for _, a := range r.Addrs {
pfx, err := netip.ParsePrefix(stripIPZone(a))
if err != nil {
continue
}
altAddrs = append(altAddrs, &net.IPNet{
IP: pfx.Addr().AsSlice(),
Mask: net.CIDRMask(pfx.Bits(), pfx.Addr().BitLen()),
})
}
out = append(out, netmon.Interface{Interface: ni, AltAddrs: altAddrs})
}
return out, nil
}
// stripIPZone removes an IPv6 scope zone from a CIDR string ("fe80::1%wlan0/64" → "fe80::1/64") so
// netip.ParsePrefix accepts it. Java's InterfaceAddress hands us link-local v6 with a %zone suffix.
func stripIPZone(s string) string {
host, suffix := s, ""
if slash := strings.LastIndexByte(s, '/'); slash >= 0 {
host, suffix = s[:slash], s[slash:]
}
if pct := strings.IndexByte(host, '%'); pct >= 0 {
host = host[:pct]
}
return host + suffix
}