// 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);") }