mirror of
https://github.com/juanfont/headscale.git
synced 2026-04-10 19:17:25 +02:00
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
258 lines
6.7 KiB
Go
258 lines
6.7 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"
|
|
"net/http"
|
|
"net/netip"
|
|
"testing"
|
|
"time"
|
|
|
|
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
|
|
URL string
|
|
|
|
memNet *memnet.Network
|
|
ln net.Listener
|
|
httpServer *http.Server
|
|
st *state.State
|
|
}
|
|
|
|
// ServerOption configures a TestServer.
|
|
type ServerOption func(*serverConfig)
|
|
|
|
type serverConfig struct {
|
|
batchDelay time.Duration
|
|
bufferedChanSize int
|
|
ephemeralTimeout time.Duration
|
|
nodeExpiry 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 }
|
|
}
|
|
|
|
// 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.
|
|
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 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{
|
|
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 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, serverURL)
|
|
|
|
return ts
|
|
}
|
|
|
|
// State returns the server's state manager for creating users,
|
|
// nodes, and pre-auth keys.
|
|
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()
|
|
|
|
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
|
|
}
|
|
|
|
// CreateTaggedPreAuthKey creates a reusable pre-auth key with ACL tags.
|
|
func (s *TestServer) CreateTaggedPreAuthKey(tb testing.TB, userID types.UserID, tags []string) string {
|
|
tb.Helper()
|
|
|
|
uid := userID
|
|
|
|
pak, err := s.st.CreatePreAuthKey(&uid, true, false, nil, tags)
|
|
if err != nil {
|
|
tb.Fatalf("servertest: CreateTaggedPreAuthKey: %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
|
|
}
|