flutter-tsnet/ios/Go/bridge.go
Mathias Beaulieu-Duncan f070bcdb86 Add Android and Linux platform support (local gRPC working, tsnet WIP)
- Android: Dart FFI → Go c-shared (.so) in jniLibs (arm64-v8a + x86_64)
- Linux: Dart FFI → Go c-shared (.so) via Docker cross-compilation (amd64)
- Dart API: TsnetFlutter uses MethodChannel on iOS/macOS, FFI on Android/Linux
- Add ffi package dependency for native function bindings
- Build script: ./build_go.sh [ios|macos|android|linux|apple|all]
- Android RegisterInterfaceGetter to bypass netlink CAP_NET_ADMIN restriction
- Make TailscaleStart() idempotent and add GODEBUG=netdns=go for Android

Known: Android tsnet tunnel blocked by Go stdlib net.Interfaces() netlink
call — local gRPC works, Tailscale fallback needs libtailscale integration.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-14 07:49:11 -04:00

288 lines
6.1 KiB
Go

// 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 <stdlib.h>
*/
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:<localPort>.
// 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