From ff29af63f665869ffe4e9a35b3e8f8482b02c476 Mon Sep 17 00:00:00 2001 From: Kristoffer Dalby Date: Thu, 2 Apr 2026 09:19:05 +0000 Subject: [PATCH] servertest: use memnet networking and add WithNodeExpiry option Replace httptest (real TCP sockets) with tailscale.com/net/memnet so all connections stay in-process. Wire the client's tsdial.Dialer to the server's memnet.Network via SetSystemDialerForTest, preserving the full Noise protocol path. Also update servertest to use the new Node.Ephemeral.InactivityTimeout config path introduced in the types refactor, and add WithNodeExpiry server option for testing default node key expiry behaviour. Updates #1711 --- hscontrol/servertest/client.go | 4 ++ hscontrol/servertest/ephemeral_test.go | 18 +++++ hscontrol/servertest/server.go | 95 ++++++++++++++++++++------ 3 files changed, 96 insertions(+), 21 deletions(-) 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()