flutter-tsnet/test/main.go
Mathias Beaulieu-Duncan 2dbadcd8ed Initial commit: embedded Tailscale tsnet Flutter plugin for remote heater access
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>
2026-03-14 05:13:55 -04:00

145 lines
4.1 KiB
Go

// Standalone test for the tsnet proxy — verifies the Go layer works
// without needing the Flutter app or iOS build.
//
// Usage:
// cd tailscale_kit/test
// go run tsnet_test.go \
// -authkey=tskey-auth-xxxxx \
// -heater-ip=100.x.x.x \
// -heater-port=5050
//
// This will:
// 1. Join the Tailnet with the given auth key
// 2. Open a local TCP proxy to the heater's Tailscale IP
// 3. Make a raw TCP connection through the proxy to verify connectivity
// 4. Print the result and clean up
package main
import (
"context"
"flag"
"fmt"
"io"
"log"
"net"
"os"
"time"
"tailscale.com/tsnet"
)
func main() {
authKey := flag.String("authkey", "", "Tailscale auth key for the phone/test client")
heaterIP := flag.String("heater-ip", "", "Heater's Tailscale IP (100.x.x.x)")
heaterPort := flag.Int("heater-port", 5050, "Heater's gRPC port")
flag.Parse()
if *authKey == "" || *heaterIP == "" {
fmt.Fprintf(os.Stderr, "Usage: go run tsnet_test.go -authkey=tskey-auth-xxx -heater-ip=100.x.x.x\n")
os.Exit(1)
}
// Create temp state dir
stateDir, err := os.MkdirTemp("", "tsnet-test-*")
if err != nil {
log.Fatalf("Failed to create state dir: %v", err)
}
defer os.RemoveAll(stateDir)
// Step 1: Start tsnet
log.Println("[1/4] Starting tsnet...")
s := &tsnet.Server{
Dir: stateDir,
Hostname: "constellation-test-client",
AuthKey: *authKey,
Ephemeral: true, // don't persist for tests
}
if err := s.Start(); err != nil {
log.Fatalf("tsnet start failed: %v", err)
}
defer s.Close()
// Get our Tailscale IP
lc, err := s.LocalClient()
if err != nil {
log.Fatalf("local client: %v", err)
}
st, err := lc.Status(context.Background())
if err != nil {
log.Fatalf("status: %v", err)
}
log.Printf("[1/4] Connected to tailnet. Our IP: %v", st.Self.TailscaleIPs)
// Step 2: Start local proxy
log.Println("[2/4] Starting local proxy...")
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
log.Fatalf("listen: %v", err)
}
defer ln.Close()
localPort := ln.Addr().(*net.TCPAddr).Port
remoteAddr := fmt.Sprintf("%s:%d", *heaterIP, *heaterPort)
log.Printf("[2/4] Proxy: 127.0.0.1:%d → %s (via Tailscale)", localPort, remoteAddr)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
// Accept one connection for the test
go func() {
conn, err := ln.Accept()
if err != nil {
return
}
defer conn.Close()
remote, err := s.Dial(ctx, "tcp", remoteAddr)
if err != nil {
log.Printf("[PROXY] Dial failed: %v", err)
return
}
defer remote.Close()
done := make(chan struct{}, 2)
go func() { io.Copy(remote, conn); done <- struct{}{} }()
go func() { io.Copy(conn, remote); done <- struct{}{} }()
<-done
}()
// Step 3: Connect through the proxy
log.Println("[3/4] Testing TCP connection through proxy...")
conn, err := net.DialTimeout("tcp", fmt.Sprintf("127.0.0.1:%d", localPort), 10*time.Second)
if err != nil {
log.Fatalf("FAIL — could not connect through proxy: %v", err)
}
defer conn.Close()
// For gRPC/HTTP2, we can just check that the TCP connection succeeds
// and the remote end responds (gRPC sends a settings frame)
conn.SetReadDeadline(time.Now().Add(5 * time.Second))
buf := make([]byte, 64)
n, err := conn.Read(buf)
if err != nil && err != io.EOF {
// Timeout is OK — gRPC server waits for client to send first
if netErr, ok := err.(net.Error); ok && netErr.Timeout() {
log.Println("[3/4] TCP connection established (gRPC server awaiting client preface)")
} else {
log.Printf("[3/4] Read response: %v (this may be OK for gRPC)", err)
}
} else {
log.Printf("[3/4] Received %d bytes from heater", n)
}
// Step 4: Result
log.Println("[4/4] SUCCESS — Tailscale tunnel is working!")
log.Printf("")
log.Printf(" Heater reachable at %s via Tailscale", remoteAddr)
log.Printf(" Local proxy: 127.0.0.1:%d", localPort)
log.Printf("")
log.Printf(" Next: use this in the Flutter app:")
log.Printf(" await tailscaleKit.start(authKey: '%s');", *authKey)
log.Printf(" final port = await tailscaleKit.startProxy('%s');", *heaterIP)
log.Printf(" grpcService.connect('127.0.0.1', port: port);")
}