Go layer (c-archive) provides a localhost TCP proxy through a WireGuard tunnel. Flutter connects gRPC to localhost:PORT, Go forwards via tsnet to heater's Tailscale IP. No VPN entitlement needed — uses userspace netstack. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
279 lines
5.9 KiB
Go
279 lines
5.9 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"
|
|
"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 C.CString("already started")
|
|
}
|
|
|
|
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
|