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
This commit is contained in:
Kristoffer Dalby
2026-04-02 09:19:05 +00:00
parent 7e8930c507
commit ff29af63f6
3 changed files with 96 additions and 21 deletions

View File

@@ -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()