diff --git a/hscontrol/servertest/client.go b/hscontrol/servertest/client.go index f315594a..3ff73cf3 100644 --- a/hscontrol/servertest/client.go +++ b/hscontrol/servertest/client.go @@ -121,6 +121,10 @@ func NewClient(tb testing.TB, server *TestServer, name string, opts ...ClientOpt dialer := tsdial.NewDialer(netmon.NewStatic()) dialer.SetBus(bus) + // Route all connections through the server's in-memory network + // so that no real TCP sockets are used. + dialer.SetSystemDialerForTest(server.MemNet().Dial) + machineKey := key.NewMachine() direct, err := controlclient.NewDirect(controlclient.Options{ diff --git a/hscontrol/servertest/ephemeral_test.go b/hscontrol/servertest/ephemeral_test.go index 9ab77657..d5a49832 100644 --- a/hscontrol/servertest/ephemeral_test.go +++ b/hscontrol/servertest/ephemeral_test.go @@ -12,6 +12,24 @@ import ( // TestEphemeralNodes tests the lifecycle of ephemeral nodes, // which should be automatically cleaned up when they disconnect. +// +// TODO(kradalby): These tests wait for real-time grace periods and +// GC intervals (up to 60s). testing/synctest would allow instant +// fake-clock advancement, but three blockers prevent adoption +// as of Go 1.26: +// +// 1. zcache janitor goroutine: No Close() method; stopped only via +// runtime.SetFinalizer which runs outside synctest bubbles. +// - https://github.com/patrickmn/go-cache/issues/185 +// - https://github.com/golang/go/issues/75113 (Go1.27: finalizers inside bubble) +// +// 2. database/sql internal goroutines: Uses sync.RWMutex which is not +// durably blocking in synctest, causing hangs. +// - https://github.com/golang/go/issues/77687 (mutex as durably blocking) +// +// 3. net/http server goroutines: I/O-blocked goroutines are not durably +// blocking, preventing bubble termination. +// - https://github.com/golang/go/issues/76608 (httptest synctest support) func TestEphemeralNodes(t *testing.T) { t.Parallel() diff --git a/hscontrol/servertest/server.go b/hscontrol/servertest/server.go index 0f1504af..69a7ec5c 100644 --- a/hscontrol/servertest/server.go +++ b/hscontrol/servertest/server.go @@ -5,7 +5,8 @@ package servertest import ( - "net/http/httptest" + "net" + "net/http" "net/netip" "testing" "time" @@ -13,15 +14,22 @@ import ( hscontrol "github.com/juanfont/headscale/hscontrol" "github.com/juanfont/headscale/hscontrol/state" "github.com/juanfont/headscale/hscontrol/types" + "tailscale.com/net/memnet" "tailscale.com/tailcfg" ) // TestServer is an in-process Headscale control server suitable for // use with Tailscale's controlclient.Direct. +// +// Networking uses tailscale.com/net/memnet so that all TCP +// connections stay in-process — no real sockets are opened. type TestServer struct { - App *hscontrol.Headscale - HTTPServer *httptest.Server - URL string + App *hscontrol.Headscale + URL string + + memNet *memnet.Network + ln net.Listener + httpServer *http.Server st *state.State } @@ -32,6 +40,7 @@ type serverConfig struct { batchDelay time.Duration bufferedChanSize int ephemeralTimeout time.Duration + nodeExpiry time.Duration batcherWorkers int } @@ -59,6 +68,11 @@ func WithEphemeralTimeout(d time.Duration) ServerOption { return func(c *serverConfig) { c.ephemeralTimeout = d } } +// WithNodeExpiry sets the default node key expiry duration. +func WithNodeExpiry(d time.Duration) ServerOption { + return func(c *serverConfig) { c.nodeExpiry = d } +} + // NewServer creates and starts a Headscale test server. // The server is fully functional and accepts real Tailscale control // protocol connections over Noise. @@ -76,13 +90,18 @@ func NewServer(tb testing.TB, opts ...ServerOption) *TestServer { prefixV6 := netip.MustParsePrefix("fd7a:115c:a1e0::/48") cfg := types.Config{ - // Placeholder; updated below once httptest server starts. - ServerURL: "http://localhost:0", - NoisePrivateKeyPath: tmpDir + "/noise_private.key", - EphemeralNodeInactivityTimeout: sc.ephemeralTimeout, - PrefixV4: &prefixV4, - PrefixV6: &prefixV6, - IPAllocation: types.IPAllocationStrategySequential, + // Placeholder; updated below once the in-memory server starts. + ServerURL: "http://localhost:0", + NoisePrivateKeyPath: tmpDir + "/noise_private.key", + Node: types.NodeConfig{ + Expiry: sc.nodeExpiry, + Ephemeral: types.EphemeralConfig{ + InactivityTimeout: sc.ephemeralTimeout, + }, + }, + PrefixV4: &prefixV4, + PrefixV6: &prefixV6, + IPAllocation: types.IPAllocationStrategySequential, Database: types.DatabaseConfig{ Type: "sqlite3", Sqlite: types.SqliteConfig{ @@ -126,21 +145,40 @@ func NewServer(tb testing.TB, opts ...ServerOption) *TestServer { app.StartBatcherForTest(tb) app.StartEphemeralGCForTest(tb) - // Start the HTTP server with Headscale's full handler (including - // /key and /ts2021 Noise upgrade). - ts := httptest.NewServer(app.HTTPHandler()) + // Start the HTTP server over an in-memory network so that all + // TCP connections stay in-process. + var memNetwork memnet.Network + + ln, err := memNetwork.Listen("tcp", "127.0.0.1:443") + if err != nil { + tb.Fatalf("servertest: memnet Listen: %v", err) + } + + httpServer := &http.Server{ + Handler: app.HTTPHandler(), + ReadHeaderTimeout: 10 * time.Second, + } + + go httpServer.Serve(ln) //nolint:errcheck // will return on Close + + serverURL := "http://" + ln.Addr().String() + + ts := &TestServer{ + App: app, + URL: serverURL, + memNet: &memNetwork, + ln: ln, + httpServer: httpServer, + st: app.GetState(), + } + tb.Cleanup(ts.Close) // Now update the config to point at the real URL so that // MapResponse.ControlURL etc. are correct. - app.SetServerURLForTest(tb, ts.URL) + app.SetServerURLForTest(tb, serverURL) - return &TestServer{ - App: app, - HTTPServer: ts, - URL: ts.URL, - st: app.GetState(), - } + return ts } // State returns the server's state manager for creating users, @@ -149,6 +187,21 @@ func (s *TestServer) State() *state.State { return s.st } +// Close shuts down the in-memory HTTP server and listener. +// Subsystem cleanup (batcher, ephemeral GC) is handled by +// tb.Cleanup callbacks registered in StartBatcherForTest and +// StartEphemeralGCForTest. +func (s *TestServer) Close() { + s.httpServer.Close() + s.ln.Close() +} + +// MemNet returns the in-memory network used by this server, +// so that TestClient dialers can be wired to it. +func (s *TestServer) MemNet() *memnet.Network { + return s.memNet +} + // CreateUser creates a test user and returns it. func (s *TestServer) CreateUser(tb testing.TB, name string) *types.User { tb.Helper()