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>
145 lines
4.1 KiB
Go
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);")
|
|
}
|