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.
510 lines
20 KiB
Go
510 lines
20 KiB
Go
// Package tailcore is the Go shim that gomobile-binds the real Tailscale core (tailscale.com) into a
|
|
// native framework (iOS .xcframework / Android .aar) for OffTail. Everything the NE / VpnService needs
|
|
// from the core is exposed here as gomobile-friendly functions (simple params + return types).
|
|
//
|
|
// This is the WHOLE-DEVICE path: Start() takes the OS tun file descriptor handed over by the platform
|
|
// (iOS NEPacketTunnelProvider's utun, Android VpnService.establish()) and drives it through wgengine +
|
|
// LocalBackend, pointed at a self-hosted Headscale. Modeled directly on tsnet.Server.start() — the
|
|
// canonical in-repo node bring-up — but with a real fd-backed tun instead of netstack-only.
|
|
//
|
|
// STATUS: compiles + binds; the control-plane bring-up (register with Headscale via the pre-auth key)
|
|
// is wired. The last mile is the native ROUTER/DNS bridge — routes + DNS must be applied on the OS side
|
|
// (NEPacketTunnelNetworkSettings / VpnService.Builder), which needs on-device testing. Until that's in,
|
|
// a nil Go router means packets reach the tun but the OS isn't told which routes to send through it.
|
|
package tailcore
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"log"
|
|
"os"
|
|
"path/filepath"
|
|
"sync"
|
|
|
|
"tailscale.com/ipn"
|
|
"tailscale.com/ipn/ipnlocal"
|
|
"tailscale.com/ipn/store"
|
|
"tailscale.com/tailcfg"
|
|
"tailscale.com/net/tsdial"
|
|
"tailscale.com/tsd"
|
|
"tailscale.com/types/logid"
|
|
"tailscale.com/types/opt"
|
|
"tailscale.com/version"
|
|
"tailscale.com/wgengine"
|
|
"tailscale.com/wgengine/netstack"
|
|
)
|
|
|
|
var (
|
|
mu sync.Mutex
|
|
backend *ipnlocal.LocalBackend
|
|
cancel context.CancelFunc
|
|
// Kept alongside `cancel` so operations started AFTER Start — LoginTo's interactive login — share
|
|
// the tunnel's lifetime and are cancelled with it, rather than running on a detached Background.
|
|
runCtx context.Context
|
|
)
|
|
|
|
// Version returns the embedded Tailscale core version — a no-network call proving the core is linked in.
|
|
func Version() string { return version.Long() }
|
|
|
|
// Ping is a trivial liveness check for the native bridge.
|
|
func Ping() string { return "tailcore-ok" }
|
|
|
|
// Start brings up a whole-device Tailscale node on the OS tun file descriptor, pointed at a Headscale
|
|
// control server, registering with a pre-auth key. gomobile-friendly signature (int32/string/error).
|
|
func Start(tunFD int32, controlURL, authKey, hostname, stateDir string) error {
|
|
mu.Lock()
|
|
defer mu.Unlock()
|
|
if backend != nil {
|
|
return nil // already running
|
|
}
|
|
|
|
// Give Tailscale's logpolicy a writable directory for its log-state/ID. On Android (GOOS=android)
|
|
// logpolicy.LogsDir() matches none of its OS cases and its fallbacks all fail — DefaultTailscaledStateFile
|
|
// is empty, UserCacheDir errors (no $HOME), cwd is "/", and MkdirTemp("") fails (no writable /tmp) — so it
|
|
// PANICS ("no safe place found to store log state"). TS_LOGS_DIR is the first thing it honors; point it at
|
|
// the per-app state dir the platform handed us (Android filesDir / iOS Application Support). Harmless on
|
|
// iOS, where a cache dir already exists.
|
|
_ = os.MkdirAll(stateDir, 0o700)
|
|
_ = os.Setenv("TS_LOGS_DIR", stateDir)
|
|
|
|
// Log to stderr → surfaces in the device's unified log (Console.app, process OffTailTunnel) so we can
|
|
// watch the core register/connect. TODO: quiet or gate this once the tunnel is proven.
|
|
logf := func(format string, args ...any) { log.Printf("[tailcore] "+format, args...) }
|
|
|
|
// Wrap the OS-provided tun fd (NEPacketTunnelProvider's utun on iOS, VpnService.establish() on
|
|
// Android). The wrapping differs per-OS — iOS must skip setMTU, Android must skip netlink — so it
|
|
// lives behind build-tagged makeTUN (maketun_apple.go / maketun_droid.go).
|
|
dev, err := makeTUN(tunFD)
|
|
if err != nil {
|
|
return fmt.Errorf("tun from fd: %w", err)
|
|
}
|
|
|
|
sys := tsd.NewSystem()
|
|
dialer := &tsdial.Dialer{Logf: logf}
|
|
dialer.SetBus(sys.Bus.Get())
|
|
|
|
eng, err := wgengine.NewUserspaceEngine(logf, wgengine.Config{
|
|
Tun: dev,
|
|
EventBus: sys.Bus.Get(),
|
|
Dialer: dialer,
|
|
SetSubsystem: sys.Set,
|
|
ControlKnobs: sys.ControlKnobs(),
|
|
HealthTracker: sys.HealthTracker.Get(),
|
|
Metrics: sys.UserMetricsRegistry(), // required by NewUserspaceEngine
|
|
// Router stays nil: the OS applies routes itself (NE settings / VpnService.Builder).
|
|
//
|
|
// DNS is NOT nil, and that is the fix for MagicDNS. Left nil, the engine substitutes a fake
|
|
// no-op OSConfigurator ("using fake (no-op) DNS configurator") and builds its dns.Manager
|
|
// around it — so the resolver at 100.100.100.100 had no base config to forward non-tailnet
|
|
// queries to. Ours returns the platform's real resolvers from GetBaseConfig; see dns.go.
|
|
//
|
|
// Passed here rather than constructed separately: the engine creates and registers the
|
|
// Manager itself, and calling sys.Set on a second one panics with "*dns.Manager is already
|
|
// set".
|
|
DNS: mobileOSConfigurator{},
|
|
})
|
|
if err != nil {
|
|
return fmt.Errorf("wgengine: %w", err)
|
|
}
|
|
sys.Set(eng)
|
|
|
|
ns, err := netstack.Create(logf, sys.Tun.Get(), eng, sys.MagicSock.Get(), dialer, sys.DNSManager.Get(), sys.ProxyMapper())
|
|
if err != nil {
|
|
return fmt.Errorf("netstack: %w", err)
|
|
}
|
|
sys.Tun.Get().Start()
|
|
sys.Set(ns)
|
|
|
|
st, err := store.New(logf, filepath.Join(stateDir, "tailscaled.state"))
|
|
if err != nil {
|
|
return fmt.Errorf("store: %w", err)
|
|
}
|
|
sys.Set(st)
|
|
|
|
lb, err := ipnlocal.NewLocalBackend(logf, logid.PublicID{}, sys, 0)
|
|
if err != nil {
|
|
return fmt.Errorf("localbackend: %w", err)
|
|
}
|
|
|
|
// Start netstack against the LocalBackend. netstack.Create() only INSTALLS its tstun hooks (incl. the
|
|
// PreFilterPacketOutboundToWireGuardNetstackIntercept); ns.Start() is what marks netstack ready, wires
|
|
// its LocalBackend, and launches the inject goroutine. WITHOUT this call the outbound intercept is live
|
|
// but netstack isn't running, so every outbound packet is dropped and no peer is ever materialized in
|
|
// wireguard-go (peers stay in the netmap + magicsock but never reach the engine — disco works, data
|
|
// doesn't). tsnet and tailscaled both call ns.Start(lb); omitting it was the whole-device data-path bug.
|
|
// Defaults (ProcessLocalIPs/ProcessSubnets = false) are correct here: this is a real-tun whole-device
|
|
// node, so tailnet traffic must flow through the OS tun to peers, not be terminated in netstack.
|
|
if err := ns.Start(lb); err != nil {
|
|
return fmt.Errorf("netstack start: %w", err)
|
|
}
|
|
|
|
ctx, cancelFn := context.WithCancel(context.Background())
|
|
cancel = cancelFn
|
|
runCtx = ctx
|
|
|
|
/*
|
|
NEVER rewrite an existing profile's control server.
|
|
|
|
The app keeps ONE stored control URL, but every profile has its own — and this Start runs on
|
|
whichever profile happens to be current, on every single tunnel start. So a restart while the app
|
|
held a different tailnet's URL silently rewrote the current profile to point at the wrong control
|
|
server: a Tailscale profile ended up carrying headscale.pastilhas.eu, mislabelled and unable to
|
|
reach its own tailnet.
|
|
|
|
A profile's control server is set once, when the profile is created (here for the first one,
|
|
LoginTo for the rest). After that the stored value wins and the caller's is ignored.
|
|
*/
|
|
effectiveControlURL := controlURL
|
|
// Hostname is the SAME class of bug: one app-level value, applied to whichever profile is current,
|
|
// on every start. Each profile advertises its own node name, and a restart would rename the node
|
|
// on one tailnet to whatever was last typed for another.
|
|
effectiveHostname := hostname
|
|
if p := lb.Prefs(); p.Valid() {
|
|
if p.ControlURL() != "" {
|
|
effectiveControlURL = p.ControlURL()
|
|
}
|
|
if p.Hostname() != "" {
|
|
effectiveHostname = p.Hostname()
|
|
}
|
|
}
|
|
|
|
if err := lb.Start(ipn.Options{
|
|
UpdatePrefs: &ipn.Prefs{
|
|
ControlURL: effectiveControlURL,
|
|
WantRunning: true,
|
|
Hostname: effectiveHostname,
|
|
// Do stateful packet filtering (double negative: NoStatefulFiltering=false ⇒ ON) so replies to
|
|
// THIS node's outbound connections are allowed back through the netmap's inbound filter. Without
|
|
// it, phone→service traffic goes out but every reply is dropped (the connection just hangs) unless
|
|
// the ACL happens to also grant the reverse direction. tailscaled's NewPrefs defaults this OFF
|
|
// (NoStatefulFiltering=true); a minimal bring-up like ours must opt in explicitly.
|
|
NoStatefulFiltering: opt.NewBool(false),
|
|
// Accept the DNS config the control plane sends — MagicDNS. Same trap as the line above:
|
|
// tailscaled's NewPrefs() defaults this ON, but we build Prefs as a struct literal so it
|
|
// gets Go's zero value, false. The node then tells Headscale it does not want DNS, and the
|
|
// netmap arrives with {DefaultResolvers:[] Routes:{} SearchDomains:[] Hosts:0} — which is
|
|
// why `alpha` never resolved no matter what the server was configured to send. Headscale's
|
|
// own docs call this out: MagicDNS only helps a node with accept-dns on (CorpDNS true).
|
|
CorpDNS: true,
|
|
},
|
|
AuthKey: authKey,
|
|
}); err != nil {
|
|
cancelFn()
|
|
return fmt.Errorf("localbackend start: %w", err)
|
|
}
|
|
if lb.State() == ipn.NeedsLogin {
|
|
if err := lb.StartLoginInteractive(ctx); err != nil {
|
|
cancelFn()
|
|
return fmt.Errorf("login: %w", err)
|
|
}
|
|
}
|
|
|
|
backend = lb
|
|
return nil
|
|
}
|
|
|
|
// ── Exit nodes ─────────────────────────────────────────────────────────────────────────────────
|
|
//
|
|
// Routing ALL traffic through another node on the tailnet. The single most-used control in the real
|
|
// Tailscale app after the on/off switch, and the reason a prefs-editing verb had to exist at all:
|
|
// everything else here is set once at Start, but an exit node is chosen and changed while running.
|
|
|
|
// SetExitNode routes all traffic through the peer with this stable node id. An empty id turns it off.
|
|
//
|
|
// EditPrefs rather than Start: this changes a preference on a RUNNING node, and restarting to apply
|
|
// it would drop the tunnel for something the core can do live. `ExitNodeIP` is deliberately not used
|
|
// — an IP can be reassigned, while the stable id survives the node changing address.
|
|
func SetExitNode(stableID string) error {
|
|
mu.Lock()
|
|
defer mu.Unlock()
|
|
lb := backend
|
|
if lb == nil {
|
|
return fmt.Errorf("not started")
|
|
}
|
|
mp := &ipn.MaskedPrefs{ExitNodeIDSet: true}
|
|
mp.ExitNodeID = tailcfg.StableNodeID(stableID)
|
|
// Allowing LAN access alongside an exit node is what people expect: routing everything through a
|
|
// remote node should not cut off the printer in the same room.
|
|
mp.ExitNodeAllowLANAccessSet = true
|
|
mp.ExitNodeAllowLANAccess = true
|
|
if _, err := lb.EditPrefs(mp); err != nil {
|
|
return fmt.Errorf("set exit node: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// SetAcceptRoutes turns on using subnet routes other nodes advertise.
|
|
//
|
|
// Off by default in Tailscale, and the reason a node can see its peers but not the network behind
|
|
// one of them — which looks like a broken route rather than a preference.
|
|
func SetAcceptRoutes(on bool) error {
|
|
mu.Lock()
|
|
defer mu.Unlock()
|
|
lb := backend
|
|
if lb == nil {
|
|
return fmt.Errorf("not started")
|
|
}
|
|
mp := &ipn.MaskedPrefs{RouteAllSet: true}
|
|
mp.RouteAll = on
|
|
if _, err := lb.EditPrefs(mp); err != nil {
|
|
return fmt.Errorf("set accept routes: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// SubnetRoutes returns the subnet routes this node should carry, as a JSON array of CIDR strings.
|
|
//
|
|
// Empty unless RouteAll is on — accepting routes is opt-in, and a tun that claimed them regardless
|
|
// would capture traffic the user never asked us to carry.
|
|
//
|
|
// These come from peers' PrimaryRoutes: the prefixes each node is currently the primary router for.
|
|
// The tailnet's own ranges are filtered out because the tun already claims those, and re-adding them
|
|
// as subnet routes would be redundant at best.
|
|
//
|
|
// It exists because accepting a route in the CORE is only half of it. The OS decides what enters the
|
|
// tunnel, so unless the tun claims these prefixes the traffic never reaches us — the same gap that
|
|
// made choosing an exit node do nothing observable.
|
|
func SubnetRoutes() string {
|
|
mu.Lock()
|
|
defer mu.Unlock()
|
|
if backend == nil {
|
|
return "[]"
|
|
}
|
|
p := backend.Prefs()
|
|
if !p.Valid() || !p.RouteAll() {
|
|
return "[]"
|
|
}
|
|
st := backend.Status()
|
|
seen := map[string]bool{}
|
|
out := []string{}
|
|
for _, ps := range st.Peer {
|
|
if ps == nil || ps.PrimaryRoutes == nil {
|
|
continue
|
|
}
|
|
for _, r := range ps.PrimaryRoutes.All() {
|
|
// Skip the tailnet's own space and default routes: the first is already claimed, and the
|
|
// second is an exit node advertising itself, which is a separate choice the user makes
|
|
// explicitly rather than something to inherit by turning on subnet routes.
|
|
if r.Bits() == 0 {
|
|
continue
|
|
}
|
|
a := r.Addr()
|
|
if a.Is4() && a.As4()[0] == 100 && a.As4()[1] >= 64 && a.As4()[1] <= 127 {
|
|
continue
|
|
}
|
|
cidr := r.String()
|
|
if !seen[cidr] {
|
|
seen[cidr] = true
|
|
out = append(out, cidr)
|
|
}
|
|
}
|
|
}
|
|
b, err := json.Marshal(out)
|
|
if err != nil {
|
|
return "[]"
|
|
}
|
|
return string(b)
|
|
}
|
|
|
|
// Prefs returns the live preferences as JSON, so the app can show what is actually set rather than
|
|
// what it last asked for. gomobile binds simple types only, hence a string.
|
|
func Prefs() string {
|
|
mu.Lock()
|
|
defer mu.Unlock()
|
|
lb := backend
|
|
if lb == nil {
|
|
return "{}"
|
|
}
|
|
p := lb.Prefs()
|
|
if !p.Valid() {
|
|
return "{}"
|
|
}
|
|
out := map[string]any{
|
|
"exitNodeID": string(p.ExitNodeID()),
|
|
"acceptRoutes": p.RouteAll(),
|
|
"corpDNS": p.CorpDNS(),
|
|
}
|
|
b, err := json.Marshal(out)
|
|
if err != nil {
|
|
return "{}"
|
|
}
|
|
return string(b)
|
|
}
|
|
|
|
// NOTE ON LOCKING
|
|
//
|
|
// These accessors used to take the mutex only to COPY the backend pointer, then release it and call
|
|
// methods on that copy. That is a use-after-shutdown waiting to happen: Stop() calls Shutdown() and
|
|
// nils the pointer under the same mutex, so a caller that had already copied it goes on to use a
|
|
// backend that is being torn down. Android polls Status() every two seconds and a profile switch
|
|
// calls Stop() — the two race, and the result is a native crash with no Java stack, intermittently.
|
|
//
|
|
// iOS never showed it because the extension is a separate process torn down whole.
|
|
//
|
|
// So the short operations below hold the lock for the WHOLE call. They are microseconds of work;
|
|
// serialising them against Stop costs nothing. LoginTo is deliberately excluded — it blocks on
|
|
// StartLoginInteractive, and holding the mutex across a network round trip would block Stop instead.
|
|
|
|
// ── Profiles ───────────────────────────────────────────────────────────────────────────────────
|
|
//
|
|
// Several tailnets on one device, switched between: a self-hosted Headscale and a login.tailscale.com
|
|
// account, say. The core has supported this the whole time — LocalBackend keeps a profile per login,
|
|
// each with its own control URL, node key and prefs — it was simply never exposed.
|
|
//
|
|
// gomobile only binds simple types, so these take and return strings; the list is JSON.
|
|
|
|
// ProfileInfo is the gomobile-safe shape of a login profile. `ipn.LoginProfileView` cannot cross the
|
|
// binding, and most of it is uninteresting to a picker.
|
|
type profileInfo struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"` // the login name, e.g. andre@example.com
|
|
Tailnet string `json:"tailnet"` // NetworkProfile.DomainName — which tailnet this is
|
|
Current bool `json:"current"`
|
|
// ControlURL is which control plane this profile talks to, and it is the ONLY authoritative way to
|
|
// say whether a stored login is Tailscale or a self-hosted Headscale. Inferring it from the tailnet
|
|
// name does not work: a personal Tailscale tailnet is named after the login email, and a Headscale
|
|
// namespace can be called anything at all.
|
|
ControlURL string `json:"controlUrl"`
|
|
}
|
|
|
|
// ListProfiles returns every stored login as JSON, with the active one flagged.
|
|
func ListProfiles() string {
|
|
mu.Lock()
|
|
defer mu.Unlock()
|
|
lb := backend
|
|
if lb == nil {
|
|
return "[]"
|
|
}
|
|
currentID := lb.CurrentProfile().ID()
|
|
out := []profileInfo{}
|
|
for _, p := range lb.ListProfiles() {
|
|
out = append(out, profileInfo{
|
|
ID: string(p.ID()),
|
|
Name: p.Name(),
|
|
Tailnet: p.NetworkProfile().DomainName,
|
|
Current: p.ID() == currentID,
|
|
ControlURL: p.ControlURL(),
|
|
})
|
|
}
|
|
b, err := json.Marshal(out)
|
|
if err != nil {
|
|
return "[]"
|
|
}
|
|
return string(b)
|
|
}
|
|
|
|
// SwitchProfile makes an existing login the active one. The node re-registers under that identity;
|
|
// the caller is responsible for tearing the tun down and back up around it.
|
|
func SwitchProfile(id string) error {
|
|
mu.Lock()
|
|
defer mu.Unlock()
|
|
lb := backend
|
|
if lb == nil {
|
|
return fmt.Errorf("not started")
|
|
}
|
|
return lb.SwitchProfile(ipn.ProfileID(id))
|
|
}
|
|
|
|
// NewProfile begins an additional login, leaving the existing ones intact. The node lands in
|
|
// NeedsLogin, which is the point: Status().AuthURL then carries where to send the user.
|
|
/*
|
|
LoginTo joins ANOTHER tailnet without restarting the tunnel.
|
|
|
|
NewProfile alone is not enough. It creates a blank profile on the running backend, but the control
|
|
URL and the pre-auth key only reach the core through Start — and on iOS Start only happens when the
|
|
Network Extension launches. Recycling the tunnel to trigger it *loses the blank profile*: an
|
|
unregistered profile is not persisted, so on reload the core falls back to the previous one and
|
|
Start then overwrites ITS control URL. The result is a second tailnet silently replacing the first,
|
|
same profile id and all.
|
|
|
|
Doing both here, against the live backend, is the only ordering where neither step undoes the other:
|
|
branch to a new profile, then start it with its own control URL and key. An empty authKey lands in
|
|
NeedsLogin and begins interactive login, exactly as the initial Start does.
|
|
*/
|
|
func LoginTo(controlURL, authKey, hostname string) error {
|
|
mu.Lock()
|
|
lb := backend
|
|
ctxLocal := runCtx
|
|
mu.Unlock()
|
|
if lb == nil {
|
|
return fmt.Errorf("not started")
|
|
}
|
|
if err := lb.NewProfile(); err != nil {
|
|
return fmt.Errorf("new profile: %w", err)
|
|
}
|
|
// Same prefs as the initial Start — see the comments there for why CorpDNS and stateful
|
|
// filtering must be set explicitly rather than left at Go's zero values.
|
|
if err := lb.Start(ipn.Options{
|
|
UpdatePrefs: &ipn.Prefs{
|
|
ControlURL: controlURL,
|
|
WantRunning: true,
|
|
Hostname: hostname,
|
|
NoStatefulFiltering: opt.NewBool(false),
|
|
CorpDNS: true,
|
|
},
|
|
AuthKey: authKey,
|
|
}); err != nil {
|
|
return fmt.Errorf("start profile: %w", err)
|
|
}
|
|
if lb.State() == ipn.NeedsLogin {
|
|
if ctxLocal == nil {
|
|
ctxLocal = context.Background()
|
|
}
|
|
if err := lb.StartLoginInteractive(ctxLocal); err != nil {
|
|
return fmt.Errorf("login: %w", err)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func NewProfile() error {
|
|
mu.Lock()
|
|
defer mu.Unlock()
|
|
lb := backend
|
|
if lb == nil {
|
|
return fmt.Errorf("not started")
|
|
}
|
|
return lb.NewProfile()
|
|
}
|
|
|
|
// DeleteProfile forgets a login. Deleting the ACTIVE profile is allowed by the core and leaves the
|
|
// node logged out, so callers should switch away first if that is not what they meant.
|
|
func DeleteProfile(id string) error {
|
|
mu.Lock()
|
|
defer mu.Unlock()
|
|
lb := backend
|
|
if lb == nil {
|
|
return fmt.Errorf("not started")
|
|
}
|
|
return lb.DeleteProfile(ipn.ProfileID(id))
|
|
}
|
|
|
|
// Stop tears the node down.
|
|
func Stop() {
|
|
mu.Lock()
|
|
defer mu.Unlock()
|
|
if cancel != nil {
|
|
cancel()
|
|
cancel = nil
|
|
}
|
|
if backend != nil {
|
|
backend.Shutdown()
|
|
backend = nil
|
|
}
|
|
}
|
|
|
|
// Status returns the node status as JSON (self IPs, peers, backend state) — drives the RN UI.
|
|
func Status() string {
|
|
mu.Lock()
|
|
defer mu.Unlock()
|
|
if backend == nil {
|
|
return `{"BackendState":"Stopped"}`
|
|
}
|
|
b, err := json.Marshal(backend.Status())
|
|
if err != nil {
|
|
return `{"BackendState":"Error"}`
|
|
}
|
|
return string(b)
|
|
}
|