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>
This commit is contained in:
+144
@@ -0,0 +1,144 @@
|
||||
// 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);")
|
||||
}
|
||||
Reference in New Issue
Block a user