// 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) }