Files
headscale/hscontrol/servertest/server.go
Kristoffer Dalby ca7362e9aa hscontrol/servertest: add control plane lifecycle and consistency tests
Add three test files exercising the servertest harness:

- lifecycle_test.go: connection, disconnection, reconnection, session
  replacement, and mesh formation at various sizes.
- consistency_test.go: symmetric visibility, consistent peer state,
  address presence, concurrent join/leave convergence.
- weather_test.go: rapid reconnects, flapping stability, reconnect
  with various delays, concurrent reconnects, and scale tests.

All tests use table-driven patterns with subtests.
2026-03-19 07:05:58 +01:00

191 lines
5.1 KiB
Go

// Package servertest provides an in-process test harness for Headscale's
// control plane. It wires a real Headscale server to real Tailscale
// controlclient.Direct instances, enabling fast, deterministic tests
// of the full control protocol without Docker or separate processes.
package servertest
import (
"net/http/httptest"
"net/netip"
"testing"
"time"
hscontrol "github.com/juanfont/headscale/hscontrol"
"github.com/juanfont/headscale/hscontrol/state"
"github.com/juanfont/headscale/hscontrol/types"
"tailscale.com/tailcfg"
)
// TestServer is an in-process Headscale control server suitable for
// use with Tailscale's controlclient.Direct.
type TestServer struct {
App *hscontrol.Headscale
HTTPServer *httptest.Server
URL string
st *state.State
}
// ServerOption configures a TestServer.
type ServerOption func(*serverConfig)
type serverConfig struct {
batchDelay time.Duration
bufferedChanSize int
ephemeralTimeout time.Duration
batcherWorkers int
}
func defaultServerConfig() *serverConfig {
return &serverConfig{
batchDelay: 50 * time.Millisecond,
bufferedChanSize: 30,
batcherWorkers: 1,
ephemeralTimeout: 30 * time.Second,
}
}
// WithBatchDelay sets the batcher's change coalescing delay.
func WithBatchDelay(d time.Duration) ServerOption {
return func(c *serverConfig) { c.batchDelay = d }
}
// WithBufferedChanSize sets the per-node map session channel buffer.
func WithBufferedChanSize(n int) ServerOption {
return func(c *serverConfig) { c.bufferedChanSize = n }
}
// WithEphemeralTimeout sets the ephemeral node inactivity timeout.
func WithEphemeralTimeout(d time.Duration) ServerOption {
return func(c *serverConfig) { c.ephemeralTimeout = d }
}
// NewServer creates and starts a Headscale test server.
// The server is fully functional and accepts real Tailscale control
// protocol connections over Noise.
func NewServer(tb testing.TB, opts ...ServerOption) *TestServer {
tb.Helper()
sc := defaultServerConfig()
for _, o := range opts {
o(sc)
}
tmpDir := tb.TempDir()
prefixV4 := netip.MustParsePrefix("100.64.0.0/10")
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,
Database: types.DatabaseConfig{
Type: "sqlite3",
Sqlite: types.SqliteConfig{
Path: tmpDir + "/headscale_test.db",
},
},
Policy: types.PolicyConfig{
Mode: types.PolicyModeDB,
},
Tuning: types.Tuning{
BatchChangeDelay: sc.batchDelay,
BatcherWorkers: sc.batcherWorkers,
NodeMapSessionBufferedChanSize: sc.bufferedChanSize,
},
}
app, err := hscontrol.NewHeadscale(&cfg)
if err != nil {
tb.Fatalf("servertest: NewHeadscale: %v", err)
}
// Set a minimal DERP map so MapResponse generation works.
app.GetState().SetDERPMap(&tailcfg.DERPMap{
Regions: map[int]*tailcfg.DERPRegion{
900: {
RegionID: 900,
RegionCode: "test",
RegionName: "Test Region",
Nodes: []*tailcfg.DERPNode{{
Name: "test0",
RegionID: 900,
HostName: "127.0.0.1",
IPv4: "127.0.0.1",
DERPPort: -1, // not a real DERP, just needed for MapResponse
}},
},
},
})
// Start subsystems.
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())
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)
return &TestServer{
App: app,
HTTPServer: ts,
URL: ts.URL,
st: app.GetState(),
}
}
// State returns the server's state manager for creating users,
// nodes, and pre-auth keys.
func (s *TestServer) State() *state.State {
return s.st
}
// CreateUser creates a test user and returns it.
func (s *TestServer) CreateUser(tb testing.TB, name string) *types.User {
tb.Helper()
u, _, err := s.st.CreateUser(types.User{Name: name})
if err != nil {
tb.Fatalf("servertest: CreateUser(%q): %v", name, err)
}
return u
}
// CreatePreAuthKey creates a reusable pre-auth key for the given user.
func (s *TestServer) CreatePreAuthKey(tb testing.TB, userID types.UserID) string {
tb.Helper()
uid := userID
pak, err := s.st.CreatePreAuthKey(&uid, true, false, nil, nil)
if err != nil {
tb.Fatalf("servertest: CreatePreAuthKey: %v", err)
}
return pak.Key
}
// CreateEphemeralPreAuthKey creates an ephemeral pre-auth key.
func (s *TestServer) CreateEphemeralPreAuthKey(tb testing.TB, userID types.UserID) string {
tb.Helper()
uid := userID
pak, err := s.st.CreatePreAuthKey(&uid, false, true, nil, nil)
if err != nil {
tb.Fatalf("servertest: CreateEphemeralPreAuthKey: %v", err)
}
return pak.Key
}