diff --git a/lifecycle_sync_test.go b/lifecycle_sync_test.go new file mode 100644 index 0000000..bec7882 --- /dev/null +++ b/lifecycle_sync_test.go @@ -0,0 +1,447 @@ +// Copyright (c) Tailscale Inc & AUTHORS +// SPDX-License-Identifier: BSD-3-Clause + +package main + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "net" + "net/http" + "net/http/httptest" + "os" + "os/exec" + "path/filepath" + "runtime" + "strconv" + "strings" + "sync" + "syscall" + "testing" + "time" + + "github.com/tailscale/libtailscale/tsnetctest" + "tailscale.com/types/logger" +) + +func TestCloseNew(t *testing.T) { + sd := TsnetNewServer() + done := make(chan int, 1) + go func() { done <- int(TsnetClose(sd)) }() + if rc := awaitResult(t, done, "Close new server"); rc != 0 { + t.Fatalf("Close = %d", rc) + } + if rc := TsnetClose(sd); int(rc) != int(syscall.EBADF) { + t.Fatalf("second Close = %d", rc) + } +} + +func TestCloseOperationBoundary(t *testing.T) { + sd := TsnetNewServer() + s := acquireServer(sd) + // Two operations registered before deletion must both be drained. + second := acquireServer(sd) + var release sync.Once + defer release.Do(func() { s.ops.Done(); second.ops.Done() }) + done := make(chan int, 1) + go func() { done <- int(TsnetClose(sd)) }() + awaitCanceled(t, s.ctx) + if late := acquireServer(sd); late != nil { + late.ops.Done() + t.Fatal("operation registered after cancellation and handle removal") + } + if rc := TsnetStart(sd); int(rc) != int(syscall.EBADF) { + t.Fatalf("Start after deletion = %d", rc) + } + awaitStacks(t, 1, ".TsnetClose(", "sync.(*WaitGroup).Wait(") + // A waiting Close must not hold the registry lock and stall another node. + other := TsnetNewServer() + otherDone := make(chan int, 1) + go func() { otherDone <- int(TsnetClose(other)) }() + if rc := awaitResult(t, otherDone, "other Close"); rc != 0 { + t.Fatalf("other Close = %d", rc) + } + release.Do(func() { s.ops.Done(); second.ops.Done() }) + if rc := awaitResult(t, done, "drained Close"); rc != 0 { + t.Fatalf("Close = %d", rc) + } +} + +func TestConcurrentClose(t *testing.T) { + sd := TsnetNewServer() + s := acquireServer(sd) + var release sync.Once + defer release.Do(s.ops.Done) + const callers = 8 + done := make(chan int, callers) + for range callers { + go func() { done <- int(TsnetClose(sd)) }() + } + awaitCanceled(t, s.ctx) + // The winner is held by our operation. Every loser must already see EBADF. + for range callers - 1 { + if rc := awaitResult(t, done, "losing Close"); rc != int(syscall.EBADF) { + t.Fatalf("losing Close = %d", rc) + } + } + awaitStacks(t, 1, ".TsnetClose(", "sync.(*WaitGroup).Wait(") + release.Do(s.ops.Done) + if rc := awaitResult(t, done, "winning Close"); rc != 0 { + t.Fatalf("winning Close = %d", rc) + } +} + +func TestLastErrorConcurrent(t *testing.T) { + sd := TsnetNewServer() + s := acquireServer(sd) + defer func() { s.ops.Done(); TsnetClose(sd) }() + var wg sync.WaitGroup + start := make(chan struct{}) + for i := range 4 { + wg.Add(1) + go func() { + defer wg.Done() + <-start + for range 100 { + if i%2 == 0 { + s.recErr(errors.New("shared last error")) + s.recErr(nil) + } else { + rc, buf := tsnetctest.Errmsg(int32(sd), 4) + if (rc != 0 && rc != int(syscall.ERANGE)) || !bytes.Contains(buf, []byte{0}) { + t.Errorf("Errmsg = %d, %q", rc, buf) + return + } + } + } + }() + } + close(start) + wg.Wait() + s.recErr(errors.New("shared last error")) + for _, size := range []int{1, 4, 64} { + rc, buf := tsnetctest.Errmsg(int32(sd), size) + want := 0 + if size < len("shared last error")+1 { + want = int(syscall.ERANGE) + } + if rc != want || !bytes.Contains(buf, []byte{0}) { + t.Fatalf("Errmsg(%d) = %d, %q", size, rc, buf) + } + } + closed := TsnetNewServer() + TsnetClose(closed) + for _, size := range []int{1, 16} { + rc, buf := tsnetctest.Errmsg(int32(closed), size) + if rc != int(syscall.EBADF) || buf[0] != 0 { + t.Fatalf("Errmsg after close = %d, %q", rc, buf) + } + } +} + +func awaitCanceled(t *testing.T, ctx context.Context) { + t.Helper() + select { + case <-ctx.Done(): + case <-time.After(10 * time.Second): + t.Fatal("Close did not cancel the lifetime context") + } +} + +func TestLifecycleInitialization(t *testing.T) { + if mode := os.Getenv("LIBTAILSCALE_INIT_CHILD"); mode != "" { + testLifecycleInitialization(t, mode) + return + } + for _, mode := range []string{"failure-Start", "failure-Up", "failure-Dial", "failure-Listen", "failure-Loopback", "failure-StatusJSON", "failure-Funnel", "initializing", "initializing-Up", "concurrent-close", "Dial", "Listen", "Listen-reconfigure", "Loopback", "StatusJSON", "pending-StatusJSON", "pending-Funnel"} { + t.Run(mode, func(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second) + defer cancel() + cmd := exec.CommandContext(ctx, os.Args[0], "-test.run=^TestLifecycleInitialization$", "-test.v", "-test.timeout=40s", "-test.cpu="+strconv.Itoa(runtime.GOMAXPROCS(0))) + cmd.Env = append(lifecycleEnv(), "LIBTAILSCALE_INIT_CHILD="+mode, "LIBTAILSCALE_STATE="+t.TempDir()) + out, err := cmd.CombinedOutput() + t.Logf("child output:\n%s", out) + if err != nil { + t.Fatalf("child: %v (deadline: %v)", err, ctx.Err()) + } + }) + } +} + +func testLifecycleInitialization(t *testing.T, mode string) { + releaseControl := make(chan struct{}) + control := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + select { + case <-releaseControl: + case <-r.Context().Done(): + } + })) + defer control.Close() + defer close(releaseControl) + sd := TsnetNewServer() + s := acquireServer(sd) + s.s.Dir = os.Getenv("LIBTAILSCALE_STATE") + s.s.Hostname = "lifecycle-init" + s.s.ControlURL = control.URL + s.s.Logf = logger.Discard + s.s.UserLogf = logger.Discard + s.ops.Done() // All configuration is complete before starting operations. + + entry := strings.TrimPrefix(strings.TrimPrefix(mode, "failure-"), "pending-") + call := func() int { + switch entry { + case "Start": + return int(TsnetStart(sd)) + case "Up": + return int(TsnetUp(sd)) + case "Dial": + rc, fd := tsnetctest.Dial(int32(sd), "tcp", "100.64.0.1:80") + if fd >= 0 { + syscall.Close(fd) + } + return rc + case "Listen": + rc, fd := tsnetctest.Listen(int32(sd), "tcp", ":8081") + if fd >= 0 { + syscall.Close(fd) + } + return rc + case "Loopback": + rc, _, _, _ := tsnetctest.Loopback(int32(sd)) + return rc + case "StatusJSON": + rc, _ := tsnetctest.StatusJSON(int32(sd)) + return rc + case "Funnel": + return int(TsnetEnableFunnelToLocalhostPlaintextHttp1(sd, 8080)) + } + panic("unknown entry: " + entry) + } + closeDone := make(chan int, 1) + startClose := func() { go func() { closeDone <- int(TsnetClose(sd)) }() } + if strings.HasPrefix(mode, "failure-") { + badDir := filepath.Join(s.s.Dir, "file") + if err := os.WriteFile(badDir, nil, 0600); err != nil { + t.Fatal(err) + } + s.s.Dir = badDir + for range 2 { + if rc := call(); rc != -1 { + t.Fatalf("%s = %d, want initialization error", entry, rc) + } + } + if s.started || s.startErr == nil { + t.Fatal("failed initialization recorded as successful") + } + if entry == "Up" { + rc, msg := tsnetctest.Errmsg(int32(sd), 1024) + if rc != 0 || !bytes.HasPrefix(msg, []byte("tsnet.Up: ")) { + t.Fatalf("Up initialization error lost its prefix: %d, %q", rc, msg) + } + } + startClose() + if rc := awaitResult(t, closeDone, "Close failed initialization"); rc != 0 { + t.Fatalf("Close = %d", rc) + } + return + } + + switch mode { + case "Listen-reconfigure": + stateDir := s.s.Dir + badDir := filepath.Join(stateDir, "file") + if err := os.WriteFile(badDir, nil, 0600); err != nil { + t.Fatal(err) + } + s.s.Dir = badDir + rc, fd := tsnetctest.Listen(int32(sd), "invalid-network", ":8081") + if fd >= 0 { + syscall.Close(fd) + } + if rc != -1 { + t.Fatalf("invalid Listen = %d", rc) + } + // Invalid arguments did not initialize the node. Configuration is still + // permitted; a failed early Start would have poisoned both sync.Once's. + s.s.Dir = stateDir + if rc := TsnetStart(sd); rc != 0 { + t.Fatalf("invalid Listen consumed initialization: subsequent Start = %d", rc) + } + startClose() + case "concurrent-close": + if rc := TsnetStart(sd); rc != 0 { + t.Fatalf("Start = %d", rc) + } + hold := acquireServer(sd) + var release sync.Once + defer release.Do(hold.ops.Done) + losers := make(chan int, 8) + for range 8 { + go func() { losers <- int(TsnetClose(sd)) }() + } + awaitCanceled(t, s.ctx) + for range 7 { + if rc := awaitResult(t, losers, "losing initialized Close"); rc != int(syscall.EBADF) { + t.Fatalf("losing Close = %d", rc) + } + } + awaitStacks(t, 1, ".TsnetClose(", "sync.(*WaitGroup).Wait(") + release.Do(hold.ops.Done) + closeDone <- awaitResult(t, losers, "winning initialized Close") + case "pending-StatusJSON", "pending-Funnel": + if rc := TsnetStart(sd); rc != 0 { + t.Fatalf("Start = %d", rc) + } + lc, err := s.s.LocalClient() + if err != nil { + t.Fatal(err) + } + entered, release := make(chan context.Context, 1), make(chan struct{}) + defer close(release) + transport := &http.Transport{DialContext: lc.Dial} + defer transport.CloseIdleConnections() + // Set Transport before the first LocalClient request. Gate admission to + // the real in-memory transport, preserving its behavior once released. + lc.Transport = &cancelGateTransport{transport, entered, release} + done := make(chan int, 1) + callStart := time.Now() + go func() { done <- call() }() + var requestCtx context.Context + select { + case requestCtx = <-entered: + case <-time.After(10 * time.Second): + t.Fatal("LocalAPI request did not reach transport") + } + if mode == "pending-StatusJSON" { + // Context creation happened between callStart and this observation. + // Check the existing timeout without assuming scheduling latency. + deadline, ok := requestCtx.Deadline() + if !ok || deadline.Before(callStart.Add(10*time.Second)) || deadline.After(time.Now().Add(10*time.Second)) { + t.Fatalf("StatusJSON did not retain its 10s timeout: deadline=%v, present=%v", deadline, ok) + } + } + startClose() + if rc := awaitResult(t, done, "pending LocalAPI call"); rc != -1 { + t.Fatalf("%s = %d", entry, rc) + } + if err := requestCtx.Err(); !errors.Is(err, context.Canceled) { + t.Fatalf("LocalAPI request was not canceled by Close: %v", err) + } + case "initializing", "initializing-Up": + entered, release := make(chan struct{}), make(chan struct{}) + var once, releaseOnce sync.Once + defer releaseOnce.Do(func() { close(release) }) + // UserLogf is called synchronously by tsnet.start, before it completes. + s.s.UserLogf = func(string, ...any) { once.Do(func() { close(entered); <-release }) } + startDone := make(chan int, 1) + go func() { + if mode == "initializing-Up" { + startDone <- int(TsnetUp(sd)) + } else { + startDone <- int(TsnetStart(sd)) + } + }() + select { + case <-entered: + case <-time.After(10 * time.Second): + t.Fatal("initialization barrier not reached") + } + queued := make(chan int, 2) + if mode == "initializing-Up" { + for range 2 { + go func() { queued <- int(TsnetUp(sd)) }() + } + awaitStacks(t, 2, ".TsnetUp(", "(*server).start(", "sync.(*Mutex).Lock(") + } + startClose() + awaitCanceled(t, s.ctx) + awaitStacks(t, 1, ".TsnetClose(", "sync.(*WaitGroup).Wait(") + t.Log("STAGE: initialization held; cancellation delivered; Close waiting for operation") + releaseOnce.Do(func() { close(release) }) + want := 0 + if mode == "initializing-Up" { + want = -1 + } + if rc := awaitResult(t, startDone, "initializing operation after barrier"); rc != want { + t.Fatalf("initializing operation = %d, want %d", rc, want) + } + if mode == "initializing-Up" { + for range 2 { + if rc := awaitResult(t, queued, "Up queued on initialization"); rc != -1 { + t.Fatalf("queued Up = %d", rc) + } + } + } + case "Dial": + done := make(chan int, 1) + go func() { done <- call() }() + awaitStacks(t, 1, ".TsnetDial(", "(*Server).awaitRunning(", "(*LocalBackend).WatchNotificationsAs(") + startClose() + if rc := awaitResult(t, done, "pending Dial"); rc != -1 { + t.Fatalf("Dial = %d", rc) + } + case "Listen": + rc, fd := tsnetctest.Listen(int32(sd), "tcp", ":8081") + if rc != 0 { + t.Fatalf("implicit Listen = %d", rc) + } + defer syscall.Close(fd) + // A failure after successful initialization must not erase its result. + rc, duplicate := tsnetctest.Listen(int32(sd), "tcp", ":8081") + if duplicate >= 0 { + syscall.Close(duplicate) + } + if rc != -1 { + t.Fatalf("duplicate Listen = %d", rc) + } + for _, args := range [][2]string{{"", ":8082"}, {"tcp", ":http"}, {"tcp6", "[::]:8083"}} { + rc, fd := tsnetctest.Listen(int32(sd), args[0], args[1]) + if rc != 0 { + t.Fatalf("Listen(%q, %q) = %d", args[0], args[1], rc) + } + defer syscall.Close(fd) + } + startClose() + case "Loopback": + rc, addr, proxy, local := tsnetctest.Loopback(int32(sd)) + if rc != 0 || addr == "" || len(proxy) != 32 || len(local) != 32 { + t.Fatalf("Loopback result invalid: rc=%d", rc) + } + startClose() + case "StatusJSON": + rc, body := tsnetctest.StatusJSON(int32(sd)) + var status struct{ BackendState string } + if rc != 0 || json.Unmarshal([]byte(body), &status) != nil || status.BackendState == "" || status.BackendState == "Running" { + t.Fatalf("StatusJSON = %d, %s", rc, body) + } + startClose() + } + if rc := awaitResult(t, closeDone, "Close initialized server"); rc != 0 { + t.Fatalf("Close = %d", rc) + } + // After the bridge has drained and closed, a second underlying close must + // report already-closed. This detects missed implicit initialization. + if !s.started { + t.Fatal("successful initialization not recorded") + } + if err := s.s.Close(); !errors.Is(err, net.ErrClosed) { + t.Fatalf("underlying server was not closed: %v", err) + } +} + +type cancelGateTransport struct { + base http.RoundTripper + entered chan context.Context + release <-chan struct{} +} + +func (tr *cancelGateTransport) RoundTrip(req *http.Request) (*http.Response, error) { + tr.entered <- req.Context() + select { + case <-req.Context().Done(): + case <-tr.release: + } + return tr.base.RoundTrip(req) +} diff --git a/lifecycle_test.go b/lifecycle_test.go new file mode 100644 index 0000000..9d50e62 --- /dev/null +++ b/lifecycle_test.go @@ -0,0 +1,164 @@ +// Copyright (c) Tailscale Inc & AUTHORS +// SPDX-License-Identifier: BSD-3-Clause + +package main + +import ( + "context" + "errors" + "net" + "net/http" + "net/http/httptest" + "os" + "os/exec" + "runtime" + "strconv" + "strings" + "testing" + "time" + + "tailscale.com/types/logger" +) + +// Run the real-server cases in subprocesses: even a broken Close must not leave +// a blocked Up or a partially initialized tsnet server in the test process. +func TestClosePendingUp(t *testing.T) { + if mode := os.Getenv("LIBTAILSCALE_UP_CHILD"); mode != "" { + testClosePendingUp(t, mode) + return + } + for _, mode := range []string{"explicit", "implicit", "multiple"} { + t.Run(mode, func(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second) + defer cancel() + cmd := exec.CommandContext(ctx, os.Args[0], "-test.run=^TestClosePendingUp$", "-test.v", "-test.timeout=40s", "-test.cpu="+strconv.Itoa(runtime.GOMAXPROCS(0))) + cmd.Env = lifecycleEnv() + cmd.Env = append(cmd.Env, "LIBTAILSCALE_UP_CHILD="+mode, "LIBTAILSCALE_STATE="+t.TempDir()) + out, err := cmd.CombinedOutput() // CommandContext kills and Wait reaps on timeout. + t.Logf("child output:\n%s", out) + if err != nil { + t.Fatalf("child: %v (deadline: %v)", err, ctx.Err()) + } + }) + } +} + +func lifecycleEnv() []string { + var env []string + for _, entry := range os.Environ() { + key, _, _ := strings.Cut(entry, "=") + upper := strings.ToUpper(key) + if strings.HasPrefix(upper, "TS_") || strings.HasPrefix(upper, "TSNET_") || strings.HasPrefix(upper, "TAILSCALE_") || strings.HasPrefix(upper, "LIBTAILSCALE_") || strings.HasSuffix(upper, "_PROXY") { + continue + } + env = append(env, entry) + } + return append(env, "TS_NO_LOGS_NO_SUPPORT=true") +} + +func testClosePendingUp(t *testing.T, mode string) { + release := make(chan struct{}) + control := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + select { + case <-release: + case <-r.Context().Done(): + } + })) + defer control.Close() + defer close(release) // Independent of the implementation of TsnetClose. + sd := TsnetNewServer() + servers.mu.Lock() + s := servers.m[sd] // Configure before any operation starts. + servers.mu.Unlock() + s.s.Dir = os.Getenv("LIBTAILSCALE_STATE") + s.s.Hostname = "close-pending-up" + s.s.ControlURL = control.URL + s.s.Logf = logger.Discard + if mode == "explicit" { + if rc := TsnetStart(sd); rc != 0 { + t.Fatalf("Start = %d", rc) + } + t.Log("STAGE: explicit Start succeeded") + } + n := 1 + if mode == "multiple" { + n = 3 + } + upDone := make(chan int, n) + for range n { + go func() { upDone <- int(TsnetUp(sd)) }() + } + // Observe the actual Go-side bridge calls inside Next's blocking read. The + // control endpoint alone is insufficient evidence: Start also contacts it. + stacks := awaitStacks(t, n, ".TsnetUp(", "(*IPNBusWatcher).Next(", "encoding/json.(*Decoder).refill(") + t.Logf("STAGE: %d Up calls in watcher read, still pending\n%s", n, stacks) + select { + case rc := <-upDone: + t.Fatalf("Up completed before Close: %d", rc) + default: + } + closeDone := make(chan int, 1) + go func() { closeDone <- int(TsnetClose(sd)) }() + if rc := awaitResult(t, closeDone, "Close"); rc != 0 { + t.Fatalf("Close = %d", rc) + } + t.Log("STAGE: Close returned 0") + for range n { + if rc := awaitResult(t, upDone, "Up"); rc != -1 { + t.Fatalf("Up = %d, want -1 for control that can never reach Running", rc) + } + t.Log("STAGE: Up returned -1") + } + // Cancellation alone is not enough: implicit Up must also have recorded + // successful initialization so that Close tears down the native server. + // The observed watcher proves initialization finished, making this safe. + if err := s.s.Close(); !errors.Is(err, net.ErrClosed) { + t.Fatalf("Close left the underlying server open: %v", err) + } + t.Log("STAGE: underlying server confirmed closed") +} + +func goroutineStacks() string { + buf := make([]byte, 2<<20) + n := runtime.Stack(buf, true) + return string(buf[:n]) +} + +func awaitStacks(t *testing.T, count int, frames ...string) string { + t.Helper() + deadline := time.NewTimer(10 * time.Second) + defer deadline.Stop() + tick := time.NewTicker(time.Millisecond) + defer tick.Stop() + for { + var found []string + for _, stack := range strings.Split(goroutineStacks(), "\n\n") { + match := true + for _, frame := range frames { + match = match && strings.Contains(stack, frame) + } + if match { + found = append(found, stack) + } + } + if len(found) == count { + return strings.Join(found, "\n\n") + } + select { + case <-deadline.C: + t.Fatalf("precondition: did not observe %d stacks with %v\n%s", count, frames, goroutineStacks()) + case <-tick.C: + } + } +} + +func awaitResult(t *testing.T, done <-chan int, stage string) int { + t.Helper() + select { + case rc := <-done: + return rc + case <-time.After(10 * time.Second): + t.Fatalf("STAGE: %s did not complete\n%s", stage, goroutineStacks()) + return 0 + } +} diff --git a/listen_validation_test.go b/listen_validation_test.go new file mode 100644 index 0000000..65d0658 --- /dev/null +++ b/listen_validation_test.go @@ -0,0 +1,70 @@ +// Copyright (c) Tailscale Inc & AUTHORS +// SPDX-License-Identifier: BSD-3-Clause + +package main + +import ( + "os" + "path/filepath" + "strings" + "syscall" + "testing" + + "github.com/tailscale/libtailscale/tsnetctest" + "tailscale.com/tsnet" +) + +func TestListenValidationBeforeStart(t *testing.T) { + for _, tt := range []struct { + name, network, addr, errorText string + }{ + {"network", "invalid-network", ":80", "unsupported network type"}, + {"missing-port", "tcp", "127.0.0.1", "missing port in address"}, + {"port-range", "tcp", ":65536", "invalid port:"}, + {"port-name", "tcp", ":libtailscale-invalid-service", "invalid port:"}, + {"hostname", "tcp", "localhost:80", "host part must be empty or IP literal"}, + {"ipv4", "tcp4", "[::1]:80", "invalid non-IPv4 addr"}, + {"ipv6", "tcp6", "127.0.0.1:80", "invalid non-IPv6 addr"}, + } { + t.Run(tt.name, func(t *testing.T) { + sd := TsnetNewServer() + defer func() { + if rc := TsnetClose(sd); rc != 0 { + t.Errorf("Close = %d", rc) + } + }() + // Start would fail before allocating any networking resources. Invalid + // Listen arguments must be rejected before that initialization error. + badDir := filepath.Join(t.TempDir(), "file") + if err := os.WriteFile(badDir, nil, 0600); err != nil { + t.Fatal(err) + } + servers.mu.Lock() + s := servers.m[sd] + servers.mu.Unlock() + s.s.Dir = badDir + rc, fd := tsnetctest.Listen(int32(sd), tt.network, tt.addr) + if fd >= 0 { + syscall.Close(fd) + } + if rc != -1 || fd != -1 { + t.Fatalf("Listen = %d, fd=%d", rc, fd) + } + errRC, buf := tsnetctest.Errmsg(int32(sd), 1024) + message, _, _ := strings.Cut(string(buf), "\x00") + if errRC != 0 || !strings.Contains(message, tt.errorText) { + t.Fatalf("Listen error = %d, %q; want %q before Start", errRC, message, tt.errorText) + } + // Compare against tsnet's own pre-initialization validation, including + // exact error text. This also detects drift when the dependency changes. + native := &tsnet.Server{Dir: badDir} + ln, err := native.Listen(tt.network, tt.addr) + if ln != nil { + ln.Close() + } + if err == nil || message != err.Error() { + t.Fatalf("bridge error %q differs from tsnet: %v", message, err) + } + }) + } +} diff --git a/tailscale.go b/tailscale.go index 316efdd..aa28518 100644 --- a/tailscale.go +++ b/tailscale.go @@ -10,9 +10,11 @@ import "C" import ( "context" "encoding/json" + "errors" "fmt" "io" "net" + "net/netip" "os" "regexp" "strconv" @@ -39,15 +41,41 @@ var servers struct { } type server struct { - s *tsnet.Server + s *tsnet.Server + ctx context.Context + cancel context.CancelFunc + ops sync.WaitGroup + + // startOnce publishes the initialization result to operations. Close reads + // it only after ops.Wait: tsnet.Close must not overlap tsnet.Start, nor run + // on a server whose initialization failed. + startOnce sync.Once + startErr error + started bool + + errMu sync.Mutex lastErr string - started bool } -func getServer(sd C.int) *server { +// acquireServer registers an operation while the handle is still in the map. +// The caller must defer s.ops.Done until after its last resource access, +// including error recording and output handling. +func acquireServer(sd C.int) *server { servers.mu.Lock() defer servers.mu.Unlock() - return servers.m[sd] + s := servers.m[sd] + if s != nil { + s.ops.Add(1) + } + return s +} + +func (s *server) start() error { + s.startOnce.Do(func() { + s.startErr = s.s.Start() + s.started = s.startErr == nil + }) + return s.startErr } // listeners tracks all the tsnet_listener objects allocated via tsnet_listen. @@ -77,6 +105,8 @@ type conn struct { } func (s *server) recErr(err error) C.int { + s.errMu.Lock() + defer s.errMu.Unlock() if err == nil { s.lastErr = "" return 0 @@ -99,34 +129,33 @@ func TsnetNewServer() C.int { } sd := servers.next servers.next++ - s := &server{s: &tsnet.Server{}} + ctx, cancel := context.WithCancel(context.Background()) + s := &server{s: &tsnet.Server{}, ctx: ctx, cancel: cancel} servers.m[sd] = s return (C.int)(sd) } //export TsnetStart func TsnetStart(sd C.int) C.int { - s := getServer(sd) + s := acquireServer(sd) if s == nil { return C.EBADF } - err := s.s.Start() - if err == nil { - s.started = true - } - return s.recErr(err) + defer s.ops.Done() + return s.recErr(s.start()) } //export TsnetUp func TsnetUp(sd C.int) C.int { - s := getServer(sd) + s := acquireServer(sd) if s == nil { return C.EBADF } - _, err := s.s.Up(context.Background()) // cancellation is via TsnetClose - if err == nil { - s.started = true + defer s.ops.Done() + if err := s.start(); err != nil { + return s.recErr(fmt.Errorf("tsnet.Up: %w", err)) } + _, err := s.s.Up(s.ctx) return s.recErr(err) } @@ -143,10 +172,15 @@ func TsnetClose(sd C.int) C.int { return C.EBADF } - // TODO: cancel Up + // Deleting the handle under servers.mu prevents any further ops.Add. + // Cancel without holding either the registry lock or the initialization + // lock, then let all registered calls finish before destroying resources. + s.cancel() + s.ops.Wait() + // TODO: close related listeners / conns. if !s.started { - // Server was never started, nothing to close. + // Start was never called or failed; tsnet cannot safely close either. return 0 } if err := s.s.Close(); err != nil { @@ -165,9 +199,10 @@ func TsnetGetIps(sd C.int, buf *C.char, buflen C.size_t) C.int { panic("errmsg passed buflen of 0") } - servers.mu.Lock() - s := servers.m[sd] - servers.mu.Unlock() + s := acquireServer(sd) + if s != nil { + defer s.ops.Done() + } out := unsafe.Slice((*byte)(unsafe.Pointer(buf)), buflen) @@ -195,16 +230,19 @@ func TsnetErrmsg(sd C.int, buf *C.char, buflen C.size_t) C.int { panic("errmsg passed buflen of 0") } - servers.mu.Lock() - s := servers.m[sd] - servers.mu.Unlock() + s := acquireServer(sd) + if s != nil { + defer s.ops.Done() + } out := unsafe.Slice((*byte)(unsafe.Pointer(buf)), buflen) if s == nil { out[0] = '\x00' return C.EBADF } + s.errMu.Lock() n := copy(out, s.lastErr) + s.errMu.Unlock() if n >= len(out) { out[len(out)-1] = '\x00' // always NUL-terminate return C.ERANGE @@ -213,18 +251,62 @@ func TsnetErrmsg(sd C.int, buf *C.char, buflen C.size_t) C.int { return 0 } +// validateListenArgs preserves tsnet.Listen's validation-before-Start behavior. +// Invalid arguments must not consume startOnce or initialize a node. Keep these +// checks aligned with tsnet's listen/resolveListenAddr when updating tsnet. +func validateListenArgs(network, addr string) error { + switch network { + case "", "tcp", "tcp4", "tcp6", "udp", "udp4", "udp6": + default: + return errors.New("unsupported network type") + } + host, portStr, err := net.SplitHostPort(addr) + if err != nil { + return fmt.Errorf("tsnet: %w", err) + } + port, err := net.LookupPort(network, portStr) + if err != nil || port < 0 || port > 65535 { + // LookupPort returns an error on out of range values so the bounds + // checks on port should be unnecessary, but harmless. If they do + // match, worst case this error message says "invalid port: ". + return fmt.Errorf("invalid port: %w", err) + } + if host == "" { + return nil + } + + bindHostOrZero, err := netip.ParseAddr(host) + if err != nil { + return fmt.Errorf("invalid Listen addr %q; host part must be empty or IP literal", host) + } + if strings.HasSuffix(network, "4") && !bindHostOrZero.Is4() { + return fmt.Errorf("invalid non-IPv4 addr %v for network %q", host, network) + } + if strings.HasSuffix(network, "6") && !bindHostOrZero.Is6() { + return fmt.Errorf("invalid non-IPv6 addr %v for network %q", host, network) + } + return nil +} + //export TsnetListen func TsnetListen(sd C.int, network, addr *C.char, listenerOut *C.int) C.int { - s := getServer(sd) + s := acquireServer(sd) if s == nil { return C.EBADF } + defer s.ops.Done() + networkStr, addrStr := C.GoString(network), C.GoString(addr) + if err := validateListenArgs(networkStr, addrStr); err != nil { + return s.recErr(err) + } + if err := s.start(); err != nil { + return s.recErr(err) + } - ln, err := s.s.Listen(C.GoString(network), C.GoString(addr)) + ln, err := s.s.Listen(networkStr, addrStr) if err != nil { return s.recErr(err) } - s.started = true // The tailscale_listener we return to C is one side of a socketpair(2). // We do this so we can proactively call ln.Accept in a goroutine and @@ -443,15 +525,18 @@ func extractIP(ipWithPort string) string { //export TsnetDial func TsnetDial(sd C.int, network, addr *C.char, connOut *C.int) C.int { - s := getServer(sd) + s := acquireServer(sd) if s == nil { return C.EBADF } - netConn, err := s.s.Dial(context.Background(), C.GoString(network), C.GoString(addr)) + defer s.ops.Done() + if err := s.start(); err != nil { + return s.recErr(err) + } + netConn, err := s.s.Dial(s.ctx, C.GoString(network), C.GoString(addr)) if err != nil { return s.recErr(err) } - s.started = true if err := newConn(s, netConn, connOut); err != nil { return s.recErr(err) } @@ -460,50 +545,55 @@ func TsnetDial(sd C.int, network, addr *C.char, connOut *C.int) C.int { //export TsnetSetDir func TsnetSetDir(sd C.int, str *C.char) C.int { - s := getServer(sd) + s := acquireServer(sd) if s == nil { return C.EBADF } + defer s.ops.Done() s.s.Dir = C.GoString(str) return 0 } //export TsnetSetHostname func TsnetSetHostname(sd C.int, str *C.char) C.int { - s := getServer(sd) + s := acquireServer(sd) if s == nil { return C.EBADF } + defer s.ops.Done() s.s.Hostname = C.GoString(str) return 0 } //export TsnetSetAuthKey func TsnetSetAuthKey(sd C.int, str *C.char) C.int { - s := getServer(sd) + s := acquireServer(sd) if s == nil { return C.EBADF } + defer s.ops.Done() s.s.AuthKey = C.GoString(str) return 0 } //export TsnetSetControlURL func TsnetSetControlURL(sd C.int, str *C.char) C.int { - s := getServer(sd) + s := acquireServer(sd) if s == nil { return C.EBADF } + defer s.ops.Done() s.s.ControlURL = C.GoString(str) return 0 } //export TsnetSetEphemeral func TsnetSetEphemeral(sd C.int, e int) C.int { - s := getServer(sd) + s := acquireServer(sd) if s == nil { return C.EBADF } + defer s.ops.Done() if e == 0 { s.s.Ephemeral = false } else { @@ -514,10 +604,11 @@ func TsnetSetEphemeral(sd C.int, e int) C.int { //export TsnetSetLogFD func TsnetSetLogFD(sd, fd C.int) C.int { - s := getServer(sd) + s := acquireServer(sd) if s == nil { return C.EBADF } + defer s.ops.Done() if fd == -1 { s.s.Logf = logger.Discard return 0 @@ -548,10 +639,14 @@ func TsnetLoopback(sd C.int, addrOut *C.char, addrLen C.size_t, proxyOut *C.char *localOut = '\x00' *proxyOut = '\x00' - s := getServer(sd) + s := acquireServer(sd) if s == nil { return C.EBADF } + defer s.ops.Done() + if err := s.start(); err != nil { + return s.recErr(err) + } addr, proxyCred, localAPICred, err := s.s.Loopback() if err != nil { return s.recErr(err) @@ -589,10 +684,14 @@ func TsnetStatusJSON(sd C.int, jsonOut **C.char) C.int { panic("status_json passed nil json_out") } *jsonOut = nil - s := getServer(sd) + s := acquireServer(sd) if s == nil { return C.EBADF } + defer s.ops.Done() + if err := s.start(); err != nil { + return s.recErr(err) + } // LocalClient rides tsnet's in-memory LocalAPI listener — unlike // Loopback()'s TCP listener it cannot be reclaimed by the OS while // the process is suspended (iOS), so status reads keep working on @@ -601,7 +700,7 @@ func TsnetStatusJSON(sd C.int, jsonOut **C.char) C.int { if err != nil { return s.recErr(err) } - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + ctx, cancel := context.WithTimeout(s.ctx, 10*time.Second) defer cancel() st, err := lc.Status(ctx) if err != nil { @@ -617,12 +716,16 @@ func TsnetStatusJSON(sd C.int, jsonOut **C.char) C.int { //export TsnetEnableFunnelToLocalhostPlaintextHttp1 func TsnetEnableFunnelToLocalhostPlaintextHttp1(sd C.int, localhostPort C.int) C.int { - s := getServer(sd) + s := acquireServer(sd) if s == nil { return C.EBADF } + defer s.ops.Done() + if err := s.start(); err != nil { + return s.recErr(err) + } - ctx := context.Background() + ctx := s.ctx lc, err := s.s.LocalClient() if err != nil { return s.recErr(err) diff --git a/tsnetctest/lifecycle.go b/tsnetctest/lifecycle.go new file mode 100644 index 0000000..6c0523f --- /dev/null +++ b/tsnetctest/lifecycle.go @@ -0,0 +1,58 @@ +// Copyright (c) Tailscale Inc & AUTHORS +// SPDX-License-Identifier: BSD-3-Clause + +package tsnetctest + +/* +#include +#include "../tailscale.h" +*/ +import "C" + +import "unsafe" + +// These helpers let Go tests exercise the C bridge without importing C in a +// _test.go file. Returned file descriptors belong to the caller. +func Dial(sd int32, network, addr string) (rc, fd int) { + n, a := C.CString(network), C.CString(addr) + defer C.free(unsafe.Pointer(n)) + defer C.free(unsafe.Pointer(a)) + out := C.int(-1) + r := C.tailscale_dial(C.int(sd), n, a, &out) + return int(r), int(out) +} + +func Listen(sd int32, network, addr string) (rc, fd int) { + n, a := C.CString(network), C.CString(addr) + defer C.free(unsafe.Pointer(n)) + defer C.free(unsafe.Pointer(a)) + out := C.int(-1) + r := C.tailscale_listen(C.int(sd), n, a, &out) + return int(r), int(out) +} + +func Loopback(sd int32) (rc int, addr, proxy, local string) { + var a [128]C.char + var p, l [33]C.char + r := C.tailscale_loopback(C.int(sd), &a[0], C.size_t(len(a)), &p[0], &l[0]) + return int(r), C.GoString(&a[0]), C.GoString(&p[0]), C.GoString(&l[0]) +} + +func StatusJSON(sd int32) (rc int, body string) { + var out *C.char + r := C.tailscale_status_json(C.int(sd), &out) + if out != nil { + defer C.free(unsafe.Pointer(out)) + body = C.GoString(out) + } + return int(r), body +} + +func Errmsg(sd int32, size int) (rc int, buf []byte) { + buf = make([]byte, size) + for i := range buf { + buf[i] = 0xff + } + r := C.tailscale_errmsg(C.int(sd), (*C.char)(unsafe.Pointer(&buf[0])), C.size_t(size)) + return int(r), buf +}