Adopting tailscale.com as Celium's data plane
Status: feasible, with two named exceptions. A working proof exists at engine/tsengine: two mesh nodes built on tailscale.com's engine, configured only from a Celium network map, exchanging real TCP payloads over both the direct UDP path and a DERP relay, with no Tailscale control plane, no login and no ipn.LocalBackend anywhere in the process. go test -v ./engine/... passes and needs no root.
Paths in this document: celium/... means this repository; an unqualified path like wgengine/userspace.go means the read-only module cache at /Users/yimingwang/deepseek/.gomodcache/tailscale.com@v1.102.3 (module tailscale.com v1.102.3). Where a line number is given it was read out of that checkout. Celium references name the symbol as well as the line, because Celium files were being edited by other work during this spike.
0. What the proof actually establishes
engine/tsengine is deliberately small — five non-test files, ~700 lines including comments — and it is the entire integration surface:
| File | Purpose |
|---|---|
doc.go | What the interface is, and the three pieces of state a coordination server must supply. |
keys.go | Celium types/key → tailscale.com/types/key. |
translate.go | Celium tailcfg/netmap → tailscale.com/tailcfg. |
mapresponse.go | The JSON entry point: tailcfg.MapResponse bytes → *netmap.NetworkMap. |
node.go | Node: construction, SetNetworkMap, dial/listen, close. |
Fifteen tests, which between them assert rather than assert-about:
- two end-to-end tests in
engine/tsengine/node_test.go(direct path, relay path); - nine unit tests in
engine/tsengine/translate_test.gocovering the individual
translation decisions — the ones whose wrong answer is silent, such as a DERP node whose literal address is not mirrored into IPv4/IPv6, or a rule rendered with a non-nil Bits that makes their filter discard the entire policy — plus four on the key bridge, including one that would fail if the Ed25519→X25519 derivation were ever replaced by a byte copy;
- two guard tests in
engine/tsengine/deps_test.gothat keep the dependency
claim in section 4.3 true over time.
The end-to-end tests:
TestTwoNodesEchoOverDirectPathbrings up two engines in one process with
distinct node keys, distinct disco keys and distinct UDP ports; both are given a network map built by marshalling a Celium tailcfg.MapResponse to JSON and parsing it back through NetworkMapFromJSON; node B runs a TCP echo server inside its userspace stack; node A dials 100.64.0.2:7 from its own stack and gets the payload back. No DERP region exists in that map at all, so there is no second path the bytes could have taken. The test also dials 100.64.0.99, which no peer owns, and requires that dial to fail — otherwise a working echo would prove nothing about the map steering traffic.
TestTwoNodesEchoOverDERPRelayrunstailscale.com/derp/derpserveron an
httptest TLS listener and points both engines at it. Direct UDP is removed with Tailscale's own TS_DEBUG_NEVER_DIRECT_UDP knob, and the test is an A/B: with no relay in the map the echo fails and the peer's stack never accepts a connection; add one DERP region and the same echo succeeds, with the engine reporting packets received over the relay.
Two things break and are reported in place rather than worked around, and one early limitation turned out to be fixable:
TS_DEBUG_ALWAYS_USE_DERPdeadlocks shutdown of a fullwgengine
(section 7.3). The relay test forces the relay path with a different knob.
wgengine.Configcannot forward a custom UDP socket constructor
(section 1.2) — the one genuine API wall the spike hit.
- Machine keys are bridgeable after all: the first version of this package
left tailcfg.Node.Machine zero because Celium's keys are Ed25519 and Tailscale's are X25519, but Celium already derives the X25519 key it needs for its own Noise handshake, and that is exactly Tailscale's machine key. The translation now runs that derivation (section 3), and the store can migrate in either direction.
Everything else that the spike looked for turned out to be available.
1. Entry points
1.1 Building the engine
There is no wgengine.New. The constructor is NewUserspaceEngine, and it is the only one that wires a real data plane:
// wgengine/userspace.go:304
func NewUserspaceEngine(logf logger.Logf, conf Config) (_ Engine, reterr error)
Config (wgengine/userspace.go:169) is large but only a handful of fields matter, and every one of them has a safe default:
| Field | Line | Required? |
|---|---|---|
Tun tun.Device | :173 | No — and nil is what Celium wants. A nil Tun makes the engine install tstun.NewFake() (userspace.go:316-319), a TUN that goes nowhere. That is the userspace configuration: packets are handled by their gVisor stack instead of an OS device. |
Metrics *usermetric.Registry | :203 | Yes, mandatory. NewUserspaceEngine returns an error without it (userspace.go:309-311). Satisfied from the subsystem container (below). |
EventBus *eventbus.Bus | :249 | In practice yes: magicsock's Options.EventBus "must not be nil outside of tests" (wgengine/magicsock/magicsock.go:453-455). |
SetSubsystem func(any) | :238 | Yes, and this is the important one. See 1.3. |
HealthTracker *health.Tracker | :199 | Yes in tests — NewUserspaceEngine panics if it is nil while testenv.InTest() (userspace.go:308-310). |
Dialer *tsdial.Dialer | :207 | Only if you also build netstack, which needs the same dialer. Otherwise the engine makes its own. |
ListenPort uint16 | :225 | No; zero picks a port, which magicsock.Conn.LocalPort() then reports. |
ForceDiscoKey key.DiscoPrivate | :256 | Effectively yes for Celium: without it magicsock generates a fresh disco key per process and every peer's view of our disco key is wrong. Config even documents it as "not for production", but it is the only way to pin the key the coordination server already knows. |
OnDERPRecv func(regionID int, src key.NodePublic, pkt []byte) bool | :263 | No. The only public hook for observing relay traffic; the relay test uses it. |
Router, DNS | :181, :185 | No; nil installs a fake router and a no-op DNS configurator (userspace.go:320-331). |
1.2 magicsock
magicsock.NewConn (wgengine/magicsock/magicsock.go:645) is called by NewUserspaceEngine; you do not call it yourself:
// wgengine/magicsock/magicsock.go:645
func NewConn(opts Options) (*Conn, error)
Options (magicsock.go:451) requires Logf (:459, panicked on if nil at :528-532) and, outside tests, EventBus (:455); NetMon "must be non-nil" (:482) and is supplied by the engine from Config.NetMon or a fresh netmon.New (userspace.go:389-397). Port is Options.Port at :463.
The consequence for Celium is that the only handle on magicsock is the one you gave the engine: Config.SetSubsystem receives the *magicsock.Conn as the engine builds (userspace.go:580-588), so a tsd.System is the way to get it back. That is how tsengine reaches SetDERPMap and SetNetworkMap, and it is worth stating plainly because it is the least obvious part of their API.
**What is not forwarded, and it bites: magicsock.Options.TestOnlyPacketListener (magicsock.go:477-479, used at :3709) lets a caller substitute the UDP socket constructor, and wgengine.Config has no equivalent field. magicsock.Conn has no setter either. A Celium integration that goes through NewUserspaceEngine therefore cannot replace its own UDP sockets**, which rules out tstest/natlab style testing and any platform needing a custom socket. This is the one place where the spike hit an API wall: the workaround is to build magicsock by hand and lose the engine, or to fork. Neither was needed for the proof.
1.3 The TUN device and the userspace stack
There is no top-level net/netstack in v1.102.3. The userspace stack is wgengine/netstack, and it is not a tun.Device — it is a layer that attaches to the engine's TUN wrapper:
// wgengine/netstack/netstack.go:333
func Create(logf logger.Logf, tundev *tstun.Wrapper, e wgengine.Engine,
mc *magicsock.Conn, dialer *tsdial.Dialer, dns *dns.Manager, pm *proxymap.Mapper) (*Impl, error)
// wgengine/netstack/netstack.go:633
func (ns *Impl) Start(b LocalBackend) error
// wgengine/netstack/netstack.go:705
func (ns *Impl) UpdateNetstackIPs(nm *netmap.NetworkMap)
// wgengine/netstack/netstack.go:948
func (ns *Impl) DialContextTCP(ctx context.Context, ipp netip.AddrPort) (*gonet.TCPConn, error)
Create takes the engine and magicsock, so it must run after NewUserspaceEngine; and it takes the tstun.Wrapper the engine created, which is reachable as sys.Tun.Get(). Start takes a LocalBackend, which is an alias for ipnlocal.LocalBackend (netstack.go:621-622) — passing nil is explicitly supported (netstack.go:634-636), and that is the crux of the whole spike: their userspace stack runs with no ipnlocal.
The forced order, which is documented nowhere as a sequence, is:
sys := tsd.NewSystem().wgengine.NewUserspaceEngine(logf, Config{Tun: nil, SetSubsystem: sys.Set, …}).sys.Set(eng)—SetSubsystemdoes not receive the engine itself, only its
subsystems (userspace.go:580-588); tsnet does the same at tsnet/tsnet.go:865.
netstack.Create(logf, sys.Tun.Get(), eng, sys.MagicSock.Get(), dialer, sys.DNSManager.Get(), sys.ProxyMapper()).sys.Tun.Get().Start().sys.Set(ns), thenns.ProcessLocalIPs = true; ns.ProcessSubnets = true—
both may only be set before Start (netstack.go:200, :206) and both are required for inbound traffic to a local address to be delivered rather than forwarded to the host's loopback (netstack.go:1280-1283).
ns.Start(nil).
tsengine.New in engine/tsengine/node.go is exactly this sequence, and the doc comment on it says so.
1.4 What drives the engine
// wgengine/wgengine.go:94
Reconfig(*wgcfg.Config, *router.Config, *dns.Config) error
wgcfg.Config is just two fields in v1.102.3:
// wgengine/wgcfg/config.go:22
type Config struct {
PrivateKey key.NodePrivate
Addresses []netip.Prefix
}
Peers are not in it. wireguard-go learns the peer set lazily through Engine.SetPeerConfigFunc(func(key.NodePublic) ([]netip.Prefix, bool)) (wgengine/wgengine.go:200-213) and SetPeerByIPPacketFunc(func(netip.Addr) (key.NodePublic, bool)) (wgengine/wgengine.go:184). Both are documented as "expected to be called once during LocalBackend construction, before the first Reconfig". Celium must write this glue, because stock Tailscale keeps it inside ipnlocal.
The control→data surface is otherwise tiny: magicsock.Conn.SetDERPMap (wgengine/magicsock/derp.go:800), SetNetworkMap(self tailcfg.NodeView, peers []tailcfg.NodeView) (magicsock.go:3022), SetPrivateKey (:2758), SetNetworkUp (:2717), and Engine.SetFilter (wgengine/wgengine.go:121). magicsock imports nothing from control/ except the leaf controlknobs package, and nothing from ipn/ except the types-only ipn/ipnstate.
2. Types a coordination server must produce
2.1 The translation table
This is engine/tsengine/translate.go, which is the whole of it. "Needed?" means: does the engine fail, drop the node, or silently misbehave without it.
Celium tailcfg.Node → tailscale.com/tailcfg.Node
Celium (types/tailcfg/tailcfg.go) | Tailscale (tailcfg/tailcfg.go) | Translation | Needed? |
|---|---|---|---|
ID NodeID :251 | ID NodeID :353 | int64 cast | Yes. upsertPeerLocked drops peers with ID == 0 (wgengine/magicsock/magicsock.go:3197-3200). |
StableID StableNodeID :252 | StableID StableNodeID :354 | string cast | No, but used by status and persist.NodeID. |
Name string :255 | Name string :360 | copy | No; used by MagicDNS. |
User UserID :257 | User UserID :365 | int64 cast | No. |
Key key.NodePublic :259 | Key key.NodePublic :370 | 32-byte copy, §3 | Yes. devPanicf("node with zero key") at magicsock.go:3202. |
KeyExpiry *time.Time :264 | KeyExpiry time.Time :371 | nil → zero | No. |
KeySignature []byte :262 | KeySignature tkatype.MarshaledSignature :372 | []byte conversion | No — verified: nothing in wgengine, magicsock or derp reads it. |
Machine key.MachinePublic :266 | Machine key.MachinePublic :373 | derived, not copied: Celium's Ed25519 → X25519 via NoisePublic (§3) | No — nothing in wgengine/, magicsock, derp or types/netmap reads it, so a netmap may omit it (translates to zero). |
DiscoKey key.DiscoPublic :268 | DiscoKey key.DiscoPublic :374 | 32-byte copy | Yes. A peer with a zero disco key is dropped (magicsock.go:3261-3265). |
Addresses []netip.Prefix :270 | Addresses []netip.Prefix :377 | copy (preserve nil) | Yes — ep.nodeAddr and netstack's local-IP set come from here. |
AllowedIPs []netip.Prefix :273 | AllowedIPs []netip.Prefix :384 | copy (preserve nil) | Yes for subnet routers / exit nodes. Their comment _not_ omitempty; only nil is special means nil and empty differ. |
Endpoints []string :275 | Endpoints []netip.AddrPort :386 | parse each; fail loudly | Yes for a direct path. This is the only real type change in the node. |
HomeDERP int :278 | HomeDERP int :405 | copy | Yes for relay: it becomes the peer's magic DERP address 127.3.3.40:<region> (wgengine/magicsock/endpoint.go:1540). |
Hostinfo *Hostinfo :281 | Hostinfo HostinfoView :407 | build theirs, .View() | No on the packet path. |
Created time.Time :283 | Created time.Time :408 | copy | No. |
| (absent) | Cap CapabilityVersion :409 | tailcfg.CurrentCapabilityVersion (= 142, :192) | Yes, synthesized. A zero Cap means "pre-relay-support node" (endpoint.go:1554). |
Capabilities []string :285 | Capabilities []NodeCapability :446 | element cast | No, but relay-client gating reads it. |
Tags []string :287 | Tags []string :418 | copy | No. |
Online opt.Bool :289 | Online *bool :435 | set → &v, unset → nil | No. |
LastSeen *time.Time :290 | LastSeen *time.Time :430 | copy | No. |
Expired bool :292 | Expired bool :494 | copy | No. |
MachineAuthorized bool :294 | MachineAuthorized bool :437 | copy | No. |
IsExitNode bool :296 | (absent) | dropped | No — derived from AllowedIPs on their side. |
Celium tailcfg.DERPMap → tailscale.com/tailcfg.DERPMap (tailcfg/derpmap.go:18)
Same shape, with one addition: their DERPNode carries IPv4/IPv6 overrides (derpmap.go:169, :176) that decide whether the client resolves HostName or dials a literal, and mark the node as a test node for netcheck (net/netcheck/netcheck.go:513). Celium has only HostName, so a literal address is mirrored into the right family and the other is set to "none". Fields that must be non-zero: Regions[].RegionID (must be non-zero and positive, derpmap.go:82-86), Regions[].Nodes[].HostName, and DERPPort (zero means 443). STUNPort is optional: -1 disables STUN on that node (net/netcheck/netcheck.go:1243), which is what keeps a local relay test from turning into a NAT-discovery test. InsecureForTests (derpmap.go:200) skips TLS verification (derp/derphttp/derphttp_client.go:665).
Celium tailcfg.DNSConfig → tailscale.com/tailcfg.DNSConfig (tailcfg/tailcfg.go:1785)
Resolvers and Routes differ in element type: Celium's DNSResolver (types/tailcfg/tailcfg.go:437) versus their dnstype.Resolver (types/dnstype/dnstype.go:19), whose BootstrapResolution is []netip.Addr (:37) where Celium's is []string (types/tailcfg/tailcfg.go:441) — a string that is not a bare address cannot be represented and must be reported. Nameservers is deprecated on their side and is []netip.Addr (:1818) against Celium's []string (:429). Nothing here is required for the data plane to come up: the engine's own dns.Config (the OS configurator) is a separate type and tsengine passes an empty one. MagicDNS resolution inside netstack is a different matter — see the caveat at the end of section 5.
Celium tailcfg.PacketFilter → []tailscale.com/tailcfg.FilterRule
There is no tailscale.com/tailcfg.PacketFilter type in v1.102.3. Their MapResponse.PacketFilter is []FilterRule (tailcfg/tailcfg.go:2109), and filter.MatchesFromFilterRules(pf []tailcfg.FilterRule) ([]Match, error) (wgengine/filter/tailcfg.go:31) takes a plain slice. The container is therefore not a problem; four other things are, all handled in convertPacketFilter:
| Celium | Tailscale | Consequence | |
|---|---|---|---|
| sources | Srcs []netip.Prefix :473 | SrcIPs []string :1725 | must be rendered as text |
| destinations | Dsts []NetPortRange :475 | DstPorts []NetPortRange :1748 | different JSON key |
NetPortRange.IP | netip.Prefix :486 | string (IP, CIDR, a-b, "*") :1550 | must be rendered as text; a host prefix is written as a bare address |
| port field | Port PortRange :488 | Ports PortRange :1552 | different JSON key |
empty IPProto | means any protocol :477 | means TCP, UDP, ICMP only (wgengine/filter/tailcfg.go:19-24, applied :48-49) | a silent narrowing: "allow all protocols" becomes "allow three" |
NetPortRange.Bits | *int, inert :487 | *int, fatal :1551 | non-nil aborts the entire conversion (filter/tailcfg.go:74-76), unlike a bad prefix which only drops one rule |
CapGrant | name only :504 | needs Dsts + Caps/CapMap :1556 | cannot be expressed; tsengine refuses rather than inventing policy |
NetPortRange also gains _ structs.Incomparable (tailcfg.go:1549), so == on it will not compile.
2.2 What the engine's own netmap.NetworkMap needs
wgengine/netstack.Impl.UpdateNetstackIPs(netmap.NetworkMap) (netstack.go:705) reads SelfNode, GetAddresses() (which is SelfNode.Addresses(), types/netmap/netmap.go:93-99) and GetVIPServiceIPMap(). tsengine builds that value with SelfNode, NodeKey, Peers, DERPMap, DNS, PacketFilterRules and Domain; everything else in tailscale.com/types/netmap.NetworkMap (types/netmap/netmap.go:29) is optional for a data-plane-only node — TKA, SSHPolicy, DisplayMessages, UserProfiles, CollectServices, ControlDialPlan are all consumed by ipnlocal, which is not in this picture.
2.3 Address conversions
netip appears throughout and needs no conversion: both systems use netip.Prefix/netip.AddrPort natively. tcpip.Address (gVisor) appears only inside their netstack, which converts internally (netstack.go:691 ipPrefixToAddressWithPrefix). There is no tcpip.Address in the interface a coordination server has to produce — that was the part of the design that could have leaked and does not.
3. Keys
Full analysis with every citation is in engine/research/keys.md; this is the part that changes a decision.
| Kind | Celium | Tailscale v1.102.3 | Bridge | Text |
|---|---|---|---|---|
| NodePublic / NodePrivate | X25519 [32]byte, clamped at generation (types/key/key.go:225-237) | X25519 [32]byte, clamped (types/key/node.go:51-61) | byte copy — same scalar, same public derivation | identical: nodekey:/privkey: + lowercase hex |
| DiscoPublic / DiscoPrivate | X25519 [32]byte (key.go:395-413), Raw32 at key.go:696, :699 | X25519 [32]byte (types/key/disco.go:33-41) | byte copy | public identical (discokey:); Celium's discoprivkey: has no counterpart |
| MachinePublic | Ed25519 [32]byte (key.go:106) | X25519 [32]byte (types/key/machine.go:172) | one derivation: MachinePublic.NoisePublic (types/key/noise.go:39) | mkey: collides but denotes a different identity |
| MachinePrivate | Ed25519 [64]byte (key.go:110), Seed/MachinePrivateFromSeed at key.go:706, :713 | X25519 [32]byte (machine.go:38), no Sign, no MachinePrivateFromRaw32 | NoiseKeypair (noise.go:47) + their UnmarshalText | mprivkey: vs privkey: differ |
| DiscoShared (ciphertext) | raw X25519 output as Salsa20 key (key.go:509-540) | box.Precompute = HSalsa20(X25519) (types/key/disco.go:77-84) | not interchangeable | n/a |
| ControlPublic/ControlPrivate | Ed25519 signing key for netmaps (key.go:560-593, sign.go:22-42) | no equivalent — types/key/control.go:18-25 wraps a MachinePrivate | no | no |
Node and disco keys cross by copying 32 bytes, and that is proven rather than argued. Both sides derive the public half with the same X25519 scalar multiplication (curve25519.X25519(k, Basepoint) vs curve25519.ScalarBaseMult), the same clamping, and neither clamps on raw import — so a copy preserves clamping state exactly. Two assertions make it falsifiable: after construction, Node.DiscoPublicKey() (read back out of magicsock via Conn.DiscoPublicKey(), magicsock.go:1251) must equal the public key derived from the Celium DiscoPrivate passed in as ForceDiscoKey; and TestDiscoKeysAreCopies checks the translated private key byte for byte.
Machine keys are the same device identity under a different curve, and the bridge is one derivation. Celium's are Ed25519 with real Sign/Verify (key.go:152, :155); Tailscale's are X25519 with no Sign method anywhere in types/key/machine.go. The mkey: text prefix is identical on both sides, which is the trap: a Celium mkey: parses into Tailscale's type and then denotes a different 32-byte value, because an Ed25519 public key is a compressed Edwards point and an X25519 public key is a Montgomery u-coordinate.
They are nevertheless the same identity, because Celium already derives an X25519 key from its Ed25519 one for its own Noise handshake: MachinePrivate.NoisePrivate (types/key/noise.go:30-36) is SHA-512(seed)[:32] clamped — libsodium's crypto_sign_ed25519_sk_to_curve25519 — and MachinePublic.NoisePublic (noise.go:39-41) does u = (1+y)/(1-y). Tailscale's machine key is that X25519 key; it is what their control handshake uses as the Noise static key (control/controlbase/handshake.go:270-298). So the translation runs Celium's own existing derivation and hands the result over, and no device changes identity and no machine re-registers: a device Celium already authenticates with this key authenticates with the same key on the engine side.
convertNode therefore populates tailcfg.Node.Machine rather than leaving it zero, and TestMachinePublicToTSUsesCeliumsOwnDerivation pins three things at once: that the result equals Celium's own NoisePublic, that it agrees with the public half of NoiseKeypair's private scalar, and that it is not a copy of the Ed25519 bytes. A netmap entry that omits a machine key translates to zero rather than erroring, and the data plane does not care either way: tailcfg.Node.Machine is not read by wgengine, magicsock, derp or types/netmap (grep for .Machine() over those trees returns nothing). It is consumed by peerAPI, SSH and tailnet lock, none of which are in scope here.
Two secondary consequences, both from engine/research/keys.md and both verified there:
- Tailscale's
NodePublic.MarshalBinaryprefixes"np"(types/key/node.go:342-345)
where Celium's is bare 32 bytes. JSON and MarshalText are unaffected; binary encodings are not.
- Disco shared secrets differ by one HSalsa20 step, so disco packets are not
mutually decryptable even with identical keys. Adopting their data plane means adopting their disco framing. The disco key bytes still transfer verbatim, so coordination state for disco keys survives.
Verdict: Celium's coordination server can keep its own key types on its own JSON wire, unchanged, for every key it owns. Its MarshalText output is byte-identical to Tailscale's for nodekey: and discokey:, machine keys map across by a derivation Celium already implements, and the machine-key store can migrate in either direction now that Seed and MachinePrivateFromSeed exist. The two things that remain genuinely unbridgeable are the node-key certificate scheme (Tailscale has no control-plane signing key) and disco ciphertext, and neither is on the data plane.
4. Protocol
4.1 If Tailscale's stock client had to attach
It is more work than it looks, and it is not what Celium should do. The wire surface is three requests (control/controlclient/direct.go):
GET <serverURL>/key?v=<CAPVER>over plain HTTPS, returning
tailcfg.OverTLSPublicKeyResponse (direct.go:1534).
POST <serverURL>/ts2021withUpgrade: tailscale-control-protocoland an
X-Tailscale-Handshake: base64(init) header (control/controlhttp/constants.go:26, client.go:541-547). The server must answer 101 with a matching Upgrade header (client.go:566, :569), then speak the Noise IK handshake Noise_IK_25519_ChaChaPoly_BLAKE2s (control/controlbase/handshake.go:31) with the version mixed into the prologue (:42-49), and then — this is the part that surprises people — HTTP/2 with prior knowledge only (control/ts2021/client.go:165-172, reference server testcontrol.go:466-497). HTTP/1.1 inside the tunnel cannot work.
POST /machine/register(direct.go:839) and streamingPOST /machine/map
(direct.go:1193). Map framing is unintuitive: repeated <uint32 LE length><payload> frames (direct.go:1303-1314) whose payload is always zstd-compressed JSON with no plain-JSON fallback (direct.go:1494); the first non-keepalive frame must carry MapResponse.Node (direct.go:1384-1391); a 120-second watchdog cancels a silent stream (direct.go:1054, :1205).
A real minimal implementation exists to copy — control/testcontrol/testcontrol.go (serveRegister :953, serveMap :1390) — and control/controlhttp/controlhttpserver.go has the server side of the upgrade. But it is Noise, HTTP/2, zstd and a bespoke framing, all of it new to Celium, and controlclient additionally wants a tsdial.Dialer with a NetMon, an eventbus.Bus, a persist.Persist and a Hostinfo (direct.go:301-320, :423, :1111).
4.2 What Celium actually needs: the minimum data-plane interface
None of the above. This is the spike's most consequential finding, and it is established by construction rather than by reading: engine/tsengine contains no reference to controlclient, controlhttp, controlbase or ipn, and its two tests pass.
The complete control→data interface is:
| Call | Line | Why |
|---|---|---|
magicsock.Conn.SetDERPMap(*tailcfg.DERPMap) | wgengine/magicsock/derp.go:800 | without it there are no relays |
magicsock.Conn.SetNetworkMap(self tailcfg.NodeView, peers []tailcfg.NodeView) | magicsock.go:3022 | discovery, hole punching, relay fallback |
Engine.SetPeerConfigFunc + SetPeerByIPPacketFunc | wgengine/wgengine.go:184, :200 | how peers reach wireguard-go; wgcfg.Config has no peers |
Engine.SetSelfNode + Engine.Reconfig | wgengine/wgengine.go:166, :94 | addresses and private key |
Engine.SetFilter(*filter.Filter) | wgengine/wgengine.go:121 | mandatory: a nil filter drops every packet (net/tstun/wrap.go:813, :1160) |
netstack.Impl.UpdateNetstackIPs | netstack.go:705 | which addresses are ours |
Flowing back to the coordination server: magicsock.Conn.DiscoPublicKey() (:1251), LocalPort() (:1482), Options.EndpointsFunc (:467) and SetNetInfoCallback (:1151). All plain data Celium's MapRequest already carries (types/tailcfg/tailcfg.go Endpoints, DiscoKey, NetInfo).
One inbound coupling exists and does not matter: wgengine/userspace.go:599 subscribes to events.PeerDiscoKeyUpdate, published only by controlclient (direct.go:427) — a fast path for disco-key rotation that simply never fires with a custom control plane, in which case the disco key from the map is used.
Consequence for the ACL and the map format: Celium's coordination server can keep emitting its own JSON MapResponse. Only PacketFilter needs a shape change (section 2.1) — and only because the field names differ, not because the model does.
4.3 The state machine question, measured
The question that decides the size of this migration is whether their engine can be driven with the data-plane inputs alone — node key, disco key, DERP map, netmap (peer addresses, keys, endpoints), DNS config, packet filter — or whether it drags their ipn/controlclient state machine along. The answer is yes for the engine and no for the userspace stack, and the difference is a real cost worth stating exactly:
| Package | Transitive closure | Control plane reachable? |
|---|---|---|
tailscale.com/wgengine | 405 packages | No. Only tailscale.com/ipn/ipnstate, which is types only. |
tailscale.com/wgengine/magicsock | 399 packages | No. The same. |
tailscale.com/wgengine/netstack | 501 packages | Yes, unconditionally: ipnlocal, controlclient, controlhttp, controlbase, ipnauth, ipnext, ipn/store/mem. |
The 96-package difference is the state machine, and it enters through exactly one import: wgengine/netstack/netstack.go:37 (ipnlocal), used only by the optional Start(LocalBackend) hook. tsengine calls ns.Start(nil) and the node runs, so the dependency is a build and binary-size cost rather than an API or runtime requirement — but it is unavoidable if the userspace stack is used, because that import is unconditional.
Two guard tests keep this honest and would catch a Tailscale release moving the dependency the wrong way:
TestEngineClosureIsFreeOfTheControlPlaneStateMachinefails if anything under
ipn/ other than the types-only ipnstate, or anything under control/, appears in the wgengine or magicsock closure.
TestNetstackPullsTheControlClientrecords the 96-package cost. It does not
fail if a future release decouples netstack, since that would be an improvement; it logs that section 6.2 needs updating.
At runtime the picture matches. The engine's whole inbound surface from a coordination server is SetDERPMap and SetNetworkMap; nothing in wgengine, magicsock or derp holds a control-plane handle, and magicsock.Options has no field through which one could be passed. The one inbound coupling, wgengine/userspace.go:599 subscribing to events.PeerDiscoKeyUpdate, is published only by controlclient (direct.go:427) and simply never fires with a custom control plane — the disco key from the map is used instead. This is also why Celium's account and session model is irrelevant to the engine work: the engine never learns how a node authenticated, only what its keys and addresses are.
5. What Celium can delete
Line counts are non-test .go lines. Full per-package public-API listings and call sites are in engine/research/delete-map.md.
| Celium package | LOC | Tailscale counterpart | Verdict |
|---|---|---|---|
net/packet/ | 678 (+883 test) | net/packet/ (1,879) | DELETE NOW, independently of this decision |
wgengine/filter.go, wgengine/acltun.go, wgengine/packet.go | 221 + rest | wgengine/filter/ (1,028) | REPLACE, with five semantic changes (5.1) |
wgengine/magicsock/ | 2,013 | wgengine/magicsock/ (11,015) | REPLACE; loses RelayStatus/Report/PeerPath diagnostics |
wgengine/ | 1,657 | wgengine/ (2,292) + wgengine/filter + wgengine/router | REPLACE; Engine is a different contract, and the netmap→wgcfg glue must be rewritten (it lives in ipnlocal on their side) |
derp/ | 2,262 | derp/ (1,007) + derp/derpserver/ (2,976) + derp/derphttp/ (1,424) | REPLACE, but a flag day: wire-incompatible in both directions (5.2) |
net/netstack/ | 733 | wgengine/netstack/ (2,942) + net/tstun/ (2,457) | REPLACE; loses the closure-shaped inbound/outbound filter hooks, which become one *filter.Filter plus InjectOutbound |
net/tun/ | 1,338 | net/tstun/ + wgengine/router/ | REPLACE; loses Celium's single atomic route diff (router.go:151-269), which makes route churn cheap |
net/dns/ | 1,550 | net/dns/ + net/dns/resolver/ (6,141+) | KEEP FOR NOW — theirs drags in health, eventbus, controlknobs and tsdial, none of which Celium has; Celium's callback-shaped Configurer is cheaper to keep |
disco/ | 257 | disco/ (710) | KEEP — not interoperable, and it is imported by exactly one file, so keeping costs nothing. It dies with wgengine/magicsock in practice. |
net/socks5/ | 266 | net/socks5/ (729) | KEEP — theirs cannot express Celium's resolver hook, which keeps MagicDNS names off public resolvers |
5.1 The ACL question: whose filter wins, and what changes
Both systems enforce a tailcfg.PacketFilter, and the two are not the same policy engine. Theirs is stateful (wgengine/filter/filter.go:4), with a 512-entry flowtrack LRU (filter.go:103-109). Celium's live filter (wgengine/filter.go) is stateless: match (filter.go:159-175) tries each rule twice, as written and with the ends swapped (:170), which is how it admits replies.
Theirs wins, because it is the filter their TUN wrapper enforces and the one their engine installs. Adopting it changes five observable behaviours — these are the real cost of the ACL half of the migration, more than the type reshaping:
- Outbound is no longer policy-checked. Their
runOutreturns
Accept, "ok out" unconditionally (filter.go:627) and only records flow state (:642-650). Celium does check outbound (wgengine/filter.go:99-107, wgengine/wgengine.go:589). A deny that today blocks both directions would become inbound-only, and outbound enforcement has to move somewhere else.
- UDP and SCTP replies become state-dependent. They are admitted only by
the LRU, which is written only from runOut (filter.go:626). A flow injected by anything that bypasses runOut — which is precisely what Celium's netstack filter hooks do — is dropped as "no rules matched" unless the caller calls UpdateOutboundFlowState (filter.go:630-650). This is the change most likely to produce a subtle, hard-to-find breakage.
- Inbound TCP widens sharply. Their filter accepts any non-SYN TCP packet
to a local address (filter.go:531-534, :594-596); Celium's allows only the reversed rule.
- Fragments and ICMP widen. Celium fails closed on non-first fragments
(wgengine/filter.go:133-135) and rejects ESP and IPv6-in-IPv6 (wgengine/packet.go:195); theirs passes both (filter.go:712-717) and always accepts ICMP errors and echo replies (:511-518).
nilandemptyswap meaning — the sharpest trap. Celium:nilmeans
accept-all (wgengine/filter.go:46-48 compileFilter), non-nil-but-empty means default-deny. Theirs: filter.New(nil, …) is deny everything (filter.go:200-202) with no nil case in New. A coordination server that omits the filter would lock down every node. tsengine handles this by materializing an explicit allow-all rule through the same conversion path as a real policy — the equivalent of their tailcfg.FilterAllowAll (tailcfg/tailcfg.go:1774-1782) — and says so in a comment.
Which filter the proof actually used: theirs. engine/tsengine builds a *filter.Filter from wgengine/filter (filter.MatchesFromFilterRules + filter.New, node.go buildFilter) and installs it with Engine.SetFilter. Celium's net/packet and wgengine filters are not compiled into the proof at all. That was not a preference so much as a forced choice: their tstun drops every packet when the filter is nil (net/tstun/wrap.go:813, :1160), so an engine with no filter installed forwards nothing, and their filter is the only one their packet path consults. The proof's ACL permits only TCP to port 7 between tailnet addresses, and the echo is asserted to succeed through it — so the translated policy is on the live path, not decorative.
On the server side, MatchesFromFilterRules accepting a plain slice means no new type is needed; the four shape differences are in section 2.1. Two failure modes deserve emphasis because they are asymmetric: a bad prefix only drops one rule (accumulated into erracc, filter/tailcfg.go:62-65), while a non-nil Bits discards every rule (:74-76). Celium never sets Bits (controlplane/acl.go sets only IP and Port), so this is satisfied today by accident and should be pinned with a test.
5.2 Why derp cannot be swapped quietly
DERP is a wire protocol, and the two implementations do not share it:
- Frame numbers differ entirely. Celium (
derp/derp.go:107-132): KeepAlive
0x00, NotePreferred 0x01, SendPacket 0x02, RecvPacket 0x03, … Tailscale (derp/derp.go:72-99): ServerKey 0x01, ClientInfo 0x02, ServerInfo 0x03, SendPacket 0x04, RecvPacket 0x05, KeepAlive 0x06, NotePreferred 0x07, …
- Handshakes differ: Celium negotiates over HTTP headers
(derp/derp.go:94-97); Tailscale uses in-band FrameServerKey/FrameClientInfo/FrameServerInfo.
- Celium's
derpis both ends —NewServeris run by Celium's own control
plane (cmd/celium-control/main.go), while Tailscale splits the server into derp/derpserver (derpserver.go:371 New, handler.go:16 Handler).
So a migration cannot keep Celium's relays and adopt Tailscale's clients, or vice versa. It is a flag day, or Celium keeps its own DERP and writes a client adapter — which is not possible either, because the client is inside magicsock and not pluggable. The relays must move at the same time as the nodes.
5.3 One unproven area, named
net/dns: tsengine passes an empty dns.Config (wgengine/dns) because that type configures the host resolver, which a userspace node does not have in the same sense. Their in-netstack DNS service is reachable (netstack.go acceptTCP routes port 53 to ns.dns), but MagicDNS resolution inside netstack is driven by ipnlocal, which this design deliberately omits. Not proven here: whether MagicDNS works for a map-driven node with a nil LocalBackend. Treat Celium's net/dns as staying until that is tested.
6. Platform reach
Measured by cross-compiling, not by reading build tags. Full matrix, per-platform file lists and the raw compiler output are in engine/research/platform.md.
6.1 The matrix
Three import sets compiled with GOOS/GOARCH go build: A = the data plane (wgengine, magicsock, filter, router, wgcfg, derp, derphttp, types/key, types/netmap, tailcfg); B = A + wgengine/netstack + tsd; C = B + control/controlclient + control/controlhttp.
| set | darwin/arm64 | android/arm64 | ios/arm64 | windows/amd64 | linux/amd64 |
|---|---|---|---|---|---|
| A | PASS | PASS | FAIL (CGO=0) | PASS | PASS |
| B | PASS | PASS | FAIL (CGO=0) | PASS | PASS |
| C | PASS | PASS | FAIL (CGO=0) | PASS | PASS |
The data plane cross-compiles cleanly for Android and Windows. The single failure is ios at CGO_ENABLED=0, which is a Go rule rather than a tailscale.com problem: ios/arm64 requires external (cgo) linking, but cgo is not enabled. With CGO_ENABLED=1 the build and link succeed on this host, but the artifact is not a real iOS binary — -ldflags=-v shows clang invoked with no -isysroot and no -miphoneos-version-min, and the output is tagged LC_BUILD_VERSION platform 1 (macOS). Go deliberately never supplies those flags (cmd/go/internal/work/security.go:115); gomobile must. So:
- Android:
CGO_ENABLED=0, no toolchain special-casing. - Windows:
CGO_ENABLED=0(cgo is not in the closure at all —go list -deps
finds zero runtime/cgo).
- iOS:
CGO_ENABLED=1and an iPhoneOS SDK plusCC/CGO_CFLAGS/CGO_LDFLAGS
supplied by the build. No build tag or stub is needed and no Tailscale file blocks iOS; this is the same requirement any cgo-using gomobile library has.
6.2 Adopting the userspace stack costs the control client
Sets B and C are the same closure — 480 packages on Android, 518 on Windows, identical counts. The reason is wgengine/netstack/netstack.go:42, which imports tailscale.com/ipn/ipnlocal unconditionally, and ipnlocal already pulls in control/controlclient and control/controlhttp. So the moment Celium adopts the userspace stack it ships Tailscale's control client, whether or not it uses it. Set A, by contrast, is control-plane-free. This is the concrete price of the netstack half of the adoption, and it is the reason net/dns should be left alone for now.
6.3 What their module does not ship, and what a client shell must provide
There are no android/ or ios/ packages. A search of the whole module returns only tstest/iosdeps (a test-only import list, with a comment at iosdeps.go:4-10 explaining that the real iOS Go side is a private ipn-go-bridge in Tailscale's corp repository). There is no golang.org/x/mobile dependency and no .aar/.framework; the only "gomobile" string in the module is a license attribution. Three *_ios.go files exist in total.
A Celium gomobile shell therefore has to supply:
- The TUN device —
wgengine.Config.Tun. On Android,
tun.CreateUnmonitoredTUNFromFD(fd) from the VPN service; on iOS, tun.CreateTUNFromFile(*os.File, mtu).
- A router —
wgengine.Config.Router, or register
router.HookNewUserspaceRouter. On Android nothing registers that hook: GOOS=android compiles only osrouter.go and runner.go, and router.New returns unsupported OS "android". On iOS the BSD router is registered but shells out to ifconfig/route, which an app sandbox cannot do — so iOS must override it too. In practice both platforms pass their own.
- A DNS configurator —
wgengine.Config.DNS. Android's default is already
a no-op (manager_default.go), which is correct; iOS compiles manager_darwin.go, which writes /etc/resolver/<suffix> and runs /usr/sbin/scutil — unusable in a sandbox and must be replaced.
- Android socket protection —
netns.SetAndroidProtectFuncand
netns.SetAndroidBindToNetworkFunc (net/netns/netns_android.go:50, :58). Without them the tunnel routes its own packets back into itself. This is mandatory, not optional.
- Batch VPN configuration —
wgengine.Config.ReconfigureVPN, which exists
because Android can only apply a whole VPN configuration at once.
- Android user-installed CAs —
tsd.System.ExtraRootCAs. - Netmon wakeups —
net/netmon/polling.go:61-65stretches polling to ten
minutes on Android and expects the platform to poke the link monitor.
wintun.dllon Windows:net/tstun/tun_windows.go:11-18and
wireguard-go/tun/tun_windows.go:69 end in newLazyDLL("wintun.dll", …), so the shipped client must place the DLL next to the binary.
Not shell work: version.IsMobile() is runtime-derived (version/prop.go:35-37), so there is no flag to set; it only switches in-module behaviour (route consolidation, a larger netstack in-flight cap, a smaller disco ring buffer). iOS memory tuning is automatic from the _ios.go filename tag.
6.4 Trimming the build
Their buildfeatures package turns ts_omit_* tags into compile-time constants and whole-file swaps. A 62-tag omit build (dropping osrouter, tap, dns, netstack, netlog, capture, ssh, drive, taildrop, webclient, clientupdate, and the CLI/desktop/kube/tpm/portmapper features) compiles clean for Android, iOS, Windows and macOS. For a data-plane-only client the tags that matter are osrouter, dns, netlog, capture, listenrawdisco, tundevstats and debug; netstack and gro only if the userspace stack is dropped. One tag is not composable in v1.102.3: ts_omit_clientmetrics breaks the build on every target with undefined: clientmetric.TypeGauge. That is a Tailscale-internal tag limitation, not a platform one, and it is worth knowing before someone tries to shave the binary.
6.5 The userspace datapath needs no root, and that is asserted
The proof runs on darwin/arm64, macOS 26.6.2, as uid 501 — a normal user, no sudo, no TUN device, no route change, no host DNS change. Two pieces of evidence rather than an assurance:
- The end-to-end tests assert
Node.UserspaceMode()is true for both nodes. That
method asks Tailscale's own subsystem container (tsd.System.IsNetstack, tsd/tsd.go:185), which decides by inspecting the TUN wrapper the engine registered — so it describes the engine that is actually running, not the configuration that was requested. If a future change made the engine open a real tunnel interface, this fails.
- The engine logs its own substitutions at startup, visible in
go test -v output: using fake (no-op) tun device, using fake (no-op) OS network configurator, using fake (no-op) DNS configurator (wgengine/userspace.go:317-330). With all three faked there is no privileged operation left to perform.
So the answer is yes: their gVisor-based userspace datapath works unprivileged here, and the tests are the demonstration.
6.6 Is a gomobile binding plausible?
At the level this environment can settle, yes, with one honest gap that cannot be closed here.
What was measured: the whole pure-Go closure — data plane, netstack, tsd and, for the record, controlclient — cross-compiles for android/arm64 with CGO_ENABLED=0, and for ios/arm64 with CGO_ENABLED=1. Nothing in it is darwin-host-only, and the platform-specific files that exist select correctly (netstack_tcpbuf_ios.go vs netstack_tcpbuf_default.go, netstack_userping_apple.go vs netstack_userping.go, gro's disabled variant on iOS). So the Go side of the binding has no structural obstacle on either mobile target.
What could not be measured here, and must be treated as an unknown until someone with the toolchains tries it: neither gomobile nor the Android NDK nor Xcode nor an iPhoneOS SDK is installed in this environment (verified: which gomobile empty, no ~/Library/Android/sdk/ndk, xcrun --sdk iphoneos --show-sdk-path fails, no Xcode.app). A real gomobile bind invokes cgo and links with the platform toolchain, and the GOOS=ios CGO_ENABLED=1 link observed here succeeds only because it silently used the host clang with no -isysroot and no -miphoneos-version-min, producing an artifact tagged as macOS. The honest statement is therefore: the Go packages are plausibly bindable, the cgo/link step is untested, and it is the ordinary requirement of any cgo-using gomobile library rather than anything specific to tailscale.com. Android needs the NDK; iOS needs Xcode and the iPhoneOS SDK.
6.7 A go.mod consequence that would have broken CI
.goenv.sh sets GOFLAGS=-mod=mod, so every cross-platform build silently adds requirements to go.mod. Building the trees for Linux, Windows and Android — which the userspace stack now makes necessary, because ipnlocal reaches net/dns/dbus.go (godbus), net/netmon (rtnetlink, mdlayher/netlink), magicsock_linux (mdlayher/socket) and netkernelconf (ethtool) — adds eleven indirect requirements. Until they were added, GOFLAGS=-mod=readonly go build ./... failed on Linux and Windows while succeeding on the macOS developer machine, which is exactly the kind of breakage that reaches CI first. This document's repository state has them added, and -mod=readonly builds for darwin/arm64, linux/amd64, windows/amd64 and android/arm64 all pass.
7. Cost and risk
7.1 What has to be rewritten
Files, not hours:
ipn/local/is the largest single piece. It owns the engine lifecycle, the
netmap→engine glue (SetNetworkMap, SetFilter, peer routing), status, ping and the proxies. Under this design most of it still exists but is rewritten against a different Engine contract. Call sites: ipn/local/local.go, ipn/local/status.go, ipn/local/ping.go, ipn/local/proxy.go.
- A new translation package replaces
wgengine/filter.goand
wgengine/packet.go: ~700 lines, of which engine/tsengine is a working skeleton.
- **
wgengine/(1,657),wgengine/magicsock/(2,013),net/netstack/(733),
net/tun/ (1,338), net/packet/ (678), derp/ (2,262) — deleted, roughly 8,700 lines** of Celium code gone, replaced by ~700 lines of glue.
controlplane/acl.gomust emit the reshapedFilterRule(field names,
text prefixes, the IPProto wildcard decision).
controlplane/must start emitting a non-zeroCapand non-zeroNodeIDs,
and must decide whether to keep signing netmaps with ControlPrivate (it can; nothing on the data path consumes KeySignature).
cmd/celiumd/,ipn/ipnserver/,cli/change wherever they touch
wgengine.Engine, magicsock.Options, magicsock.RelayStatus, netstack.Impl or packet.Filter.
tstest/end-to-end tests, which today drive Celium's own engine.- One piece of bad news:
wgengine.Configdoes not forward
magicsock.Options.TestOnlyPacketListener, so any test or platform needing a custom UDP socket constructor cannot use NewUserspaceEngine at all (section 1.2).
- Dependency cost is small and already paid:
go.modrequires `tailscale.com
v1.102.3 directly, plus eleven indirect requirements that exist only because of it — all eleven needed by Linux or Windows builds, as section 6.7 explains. Celium's own suite still passes in full after the upgrade (go test ./..., all packages ok), including its own gVisor-based netstack and its own DERP, which share the upgraded gvisor.dev/gvisor and gorilla/websocket. That was the main regression risk of touching go.mod` at all, and it did not materialize.
7.2 The biggest risk
Not the code — the DERP flag day. Every deployed node must move to Tailscale's DERP wire format, and every deployed relay must move with them, because neither side can talk to the other. That is a fleet-wide, atomic upgrade of the relay tier, and it is the one part of this migration that cannot be staged node-by-node behind a feature flag. If Celium has production relays and nodes with any deployment inertia, this alone may decide the question.
The second risk is quieter: the filter swap. Celium's stateless reversed-rule filter and Tailscale's stateful filter disagree about outbound enforcement, UDP reply admission, TCP non-SYN packets and fragments. Four of those five widen access, and one of them (nil vs empty) inverts the meaning of "no ACL". A migration that does not explicitly audit every caller of Celium's allowOutbound path will produce an ACL that is more permissive than the policy the operator wrote.
7.3 Bugs and API walls found during the spike
Recorded because they are the kind of thing that costs a day each later:
TS_DEBUG_ALWAYS_USE_DERPdeadlocks shutdown of a fullwgengine.
magicsock replaces its sockets with blockForeverConn (wgengine/magicsock/blockforever_conn.go:33 writes are discarded, reads block until close), so wireguard-go's receive routines park in blockForeverConn.ReadFromUDPAddrPort. userspaceEngine.Close closes magicsock first (wgengine/userspace.go:1115) and then wgdev.Close() (:1121), which calls closeBindLocked and waits on those routines (github.com/tailscale/wireguard-go/device/device.go:656) — and rebind() (magicsock.go:3824) has no closed check, so a Rebind racing the close installs a fresh, never-closed blockForeverConn. Verified by goroutine dump during a 75-second test timeout; not root-caused further because a different knob achieves the same forcing without the hazard. Any Celium code path that sets this knob in production would hang on shutdown.
wgengine.Confighas noTestOnlyPacketListener(section 1.2) — an API
wall, not a bug. It is the reason the tests cannot use tstest/natlab.
- The DERP server is not in
tailscale.com/derp. In v1.102.3 the client and
the wire format are in derp/, and the server is in derp/derpserver (derpserver.go:371). Documentation written against older layouts points at the wrong import.
discoshared-secret derivation differs by one HSalsa20 step (§3), so
even identical disco keys do not interoperate. This is invisible from the type signatures and only shows up as "disco packets are silently rejected".
- Celium's
DiscoPrivatehad no raw accessor —types/keyexposed
MarshalText and Public but no Raw32, while Tailscale's side needs raw bytes, so the first version of discoPrivateToTS round-tripped through the text form. Raw32 now exists on both disco halves (types/key/key.go:696, :699) and the bridge is a plain copy; the round-trip hack is gone. Worth recording as a pattern: the key bridge is only ever as clean as the accessors Celium exposes, and missing ones force lossy-looking code that a reviewer then has to trust.
net/packet/is dead code today.packet.Parse,IPHeader,
IP4Header, IP6Header have zero references outside the package, and Filter.CheckInbound/CheckOutbound/CheckTuple are called only from net/packet/filter_test.go. The sole production reference builds a *packet.Filter and never interrogates it. 678 lines plus 883 test lines can go regardless of what is decided here.
wgengine/netstackimportsipn/ipnlocalunconditionally
(netstack.go:37, no build tag) purely for the optional Start(LocalBackend) type switch. The spike confirms that whole closure does compile and run under Celium's toolchain (Go 1.26.6) with a nil backend — so the import is a cost in build time and binary size, not a blocker.
Recommendation
Adopt the data plane only. Keep Celium's coordination server, account system, control protocol and clients; replace wgengine/, wgengine/magicsock/, derp/, net/netstack/, net/tun/ and net/packet/ with tailscale.com's engine, driven by Celium's own network map through the interface demonstrated in engine/tsengine — about 700 lines of translation and lifecycle code, which the spike has already written and tested end to end. Adopting wholesale is not worth it: section 4 shows that attaching Tailscale's stock client costs a Noise handshake, an HTTP/2 tunnel, a zstd-framed map stream and a persist/tsdial/ eventbus dependency graph, in exchange for a control plane Celium already has and would be throwing away; and the spike proves none of that is necessary, because magicsock.Conn needs nothing from the control plane beyond SetDERPMap and SetNetworkMap. Staying with the current implementation is also not defensible on the evidence: two nodes came up and exchanged real packets over both transports in a single process, without root, without a TUN device, without ipn, and with the direct path, the relay path and the ACL all exercised — which is the majority of what wgengine/, magicsock/, derp/, net/netstack/ and net/tun/ exist to do, at roughly 8,700 lines of Celium code replaced by 700. The reasoning that decided it is the shape of the interface: everything the engine needs is a map, everything it needs back is endpoints and a disco key, and both are things Celium's tailcfg.MapResponse and MapRequest already carry — so the coupling between the two systems is a translation layer and nothing more. Two things must be scheduled explicitly rather than discovered: the DERP flag day, which is unavoidable and fleet-wide because the two DERP wire formats do not interoperate in either direction; and the ACL semantic audit, because Tailscale's stateful filter stops checking outbound traffic, treats a nil filter as deny-all rather than allow-all, and admits non-SYN TCP, fragments and ICMP far more freely than Celium's does today. One area was deliberately left unproven and should be budgeted as an unknown rather than assumed: MagicDNS resolution inside their netstack, which is driven by the ipnlocal this design omits. The platform question, by contrast, is answered: the data plane cross-compiles for Android, Windows and Linux with CGO_ENABLED=0, and iOS needs only an iPhoneOS SDK and the matching cgo flags from the build — the ordinary price of a gomobile library that uses cgo, and not a reason to keep a hand-written engine.