// Package main provides C-exported functions for embedding Tailscale's // tsnet into a Flutter iOS app via c-archive. // // Build: go build -buildmode=c-archive -o libtailscale.a // // All exported functions use C-compatible types. Error convention: // - Returns NULL on success, C string with error message on failure // - Caller must free returned strings with TailscaleFreeString() // // Uses userspace netstack — NO VPN entitlement needed on iOS. package main /* #include */ import "C" import ( "context" "encoding/json" "fmt" "io" "net" "os" "runtime" "sync" "unsafe" "tailscale.com/tsnet" ) var ( mu sync.Mutex server *tsnet.Server proxyMu sync.Mutex proxyListener net.Listener proxyCancel context.CancelFunc ) // TailscaleStart joins the Tailnet with the given auth key. // stateDir: app's documents directory for persisting identity. // authKey: Tailscale auth key (reusable, tag-scoped). // hostname: device name in the Tailnet. // Returns NULL on success, error string on failure (caller must free). // //export TailscaleStart func TailscaleStart(stateDir, authKey, hostname *C.char) *C.char { mu.Lock() defer mu.Unlock() if server != nil { return nil // already started — idempotent } // On Android, Go's net.Interfaces() uses netlink which requires // CAP_NET_ADMIN. Set GODEBUG to use Go's pure DNS resolver and // suppress network interface enumeration errors. if runtime.GOOS == "android" { os.Setenv("GODEBUG", "netdns=go") } s := &tsnet.Server{ Dir: C.GoString(stateDir), Hostname: C.GoString(hostname), AuthKey: C.GoString(authKey), Ephemeral: false, } if err := s.Start(); err != nil { return C.CString(fmt.Sprintf("tsnet start: %v", err)) } server = s return nil } // TailscaleStartProxy creates a localhost TCP proxy to a Tailscale IP. // Writes the local port to *localPort. Connect gRPC to 127.0.0.1:. // Returns NULL on success, error string on failure (caller must free). // //export TailscaleStartProxy func TailscaleStartProxy(remoteIP *C.char, remotePort C.int, localPort *C.int) *C.char { mu.Lock() s := server mu.Unlock() if s == nil { return C.CString("not started, call TailscaleStart() first") } proxyMu.Lock() defer proxyMu.Unlock() stopProxyLocked() ln, err := net.Listen("tcp", "127.0.0.1:0") if err != nil { return C.CString(fmt.Sprintf("listen: %v", err)) } ctx, cancel := context.WithCancel(context.Background()) proxyListener = ln proxyCancel = cancel remoteAddr := fmt.Sprintf("%s:%d", C.GoString(remoteIP), int(remotePort)) port := ln.Addr().(*net.TCPAddr).Port *localPort = C.int(port) go acceptLoop(ctx, s, ln, remoteAddr) return nil } func acceptLoop(ctx context.Context, s *tsnet.Server, ln net.Listener, remoteAddr string) { for { conn, err := ln.Accept() if err != nil { select { case <-ctx.Done(): return default: continue } } go handleConn(ctx, s, conn, remoteAddr) } } func handleConn(ctx context.Context, s *tsnet.Server, local net.Conn, remoteAddr string) { defer local.Close() remote, err := s.Dial(ctx, "tcp", remoteAddr) if err != nil { return } defer remote.Close() done := make(chan struct{}, 2) go func() { io.Copy(remote, local) done <- struct{}{} }() go func() { io.Copy(local, remote) done <- struct{}{} }() select { case <-done: case <-ctx.Done(): } } // TailscaleStopProxy stops the local TCP proxy. // Returns NULL on success, error string on failure (caller must free). // //export TailscaleStopProxy func TailscaleStopProxy() *C.char { proxyMu.Lock() defer proxyMu.Unlock() stopProxyLocked() return nil } func stopProxyLocked() { if proxyCancel != nil { proxyCancel() proxyCancel = nil } if proxyListener != nil { proxyListener.Close() proxyListener = nil } } // TailscaleStop disconnects from the Tailnet and cleans up. // Returns NULL on success, error string on failure (caller must free). // //export TailscaleStop func TailscaleStop() *C.char { TailscaleStopProxy() mu.Lock() defer mu.Unlock() if server == nil { return nil } err := server.Close() server = nil if err != nil { return C.CString(fmt.Sprintf("close: %v", err)) } return nil } // TailscaleStatus returns the current Tailscale status as a JSON string. // On success, returns the JSON string (caller must free). Sets *errOut to NULL. // On failure, returns NULL and sets *errOut to error string (caller must free). // //export TailscaleStatus func TailscaleStatus(errOut **C.char) *C.char { mu.Lock() s := server mu.Unlock() if s == nil { *errOut = nil return C.CString("{}") } lc, err := s.LocalClient() if err != nil { *errOut = C.CString(fmt.Sprintf("local client: %v", err)) return nil } st, err := lc.Status(context.Background()) if err != nil { *errOut = C.CString(fmt.Sprintf("status: %v", err)) return nil } b, err := json.Marshal(st) if err != nil { *errOut = C.CString(fmt.Sprintf("marshal: %v", err)) return nil } *errOut = nil return C.CString(string(b)) } // TailscaleIP returns this node's Tailscale IPv4 address. // On success, returns the IP string (caller must free). Sets *errOut to NULL. // On failure, returns NULL and sets *errOut to error string (caller must free). // //export TailscaleIP func TailscaleIP(errOut **C.char) *C.char { mu.Lock() s := server mu.Unlock() if s == nil { *errOut = C.CString("not started") return nil } lc, err := s.LocalClient() if err != nil { *errOut = C.CString(fmt.Sprintf("local client: %v", err)) return nil } st, err := lc.Status(context.Background()) if err != nil { *errOut = C.CString(fmt.Sprintf("status: %v", err)) return nil } if st.Self == nil || len(st.Self.TailscaleIPs) == 0 { *errOut = C.CString("no tailscale IP assigned yet") return nil } for _, ip := range st.Self.TailscaleIPs { if ip.Is4() { *errOut = nil return C.CString(ip.String()) } } *errOut = nil return C.CString(st.Self.TailscaleIPs[0].String()) } // TailscaleFreeString frees a string returned by other Tailscale functions. // //export TailscaleFreeString func TailscaleFreeString(s *C.char) { if s != nil { C.free(unsafe.Pointer(s)) } } func main() {} // required for c-archive, unused