Files
headscale/hscontrol/servertest/server.go
Kristoffer Dalby 6fcff9e352 mapper, state: deliver nodeAttrs through MapResponse and harden nextdns DoH rewrite
WithSelfNode and buildTailPeers merge each node's policy CapMap
into the tailcfg.Node.CapMap they emit. State.NodeCapMap and
State.NodeCapMaps wrap the policy manager: NodeCapMap returns a
defensive clone per call; NodeCapMaps snapshots the full per-node
map once for batched callers, amortising pm.mu acquisition across
a peer build.

generateDNSConfig grew a per-node CapMap argument so it can apply
nodeAttr-driven DNS overlays. The nextdns DoH rewrite hardens against
policy-controlled inputs:

  - nextDNSDoHHost anchors the prefix match instead of substring,
    so a hostile resolver URL cannot smuggle a nextdns hostname in
    a path or query.
  - nextDNSProfileFromCapMap accepts only profile names matching
    [A-Za-z0-9._-]{1,64} and picks the lexicographically first when
    multiple are granted -- deterministic, no shell metacharacters
    or URL fragments through.
  - addNextDNSMetadata composes the rewritten URL via url.Parse +
    url.Values rather than fmt.Sprintf, so existing query strings
    on the resolver URL survive and metadata cannot inject a new
    component.

WithTaildropEnabled in servertest controls cfg.Taildrop.Enabled per
test so cap/file-sharing emission can be toggled in tests that need
to verify the off path.
2026-05-13 14:22:30 +02:00

270 lines
7.2 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
taildropEnabled bool
}
func defaultServerConfig() *serverConfig {
return &serverConfig{
batchDelay: 50 * time.Millisecond,
bufferedChanSize: 30,
batcherWorkers: 1,
ephemeralTimeout: 30 * time.Second,
taildropEnabled: true,
}
}
// 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 }
}
// WithTaildropEnabled toggles the Taildrop file-sharing feature.
// Defaults to true to match production. Pass false to verify
// behaviour when an operator has switched the toggle off — e.g.
// that [tailcfg.CapabilityFileSharing] is withheld from the
// always-on baseline.
func WithTaildropEnabled(enabled bool) ServerOption {
return func(c *serverConfig) { c.taildropEnabled = enabled }
}
// 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,
},
Taildrop: types.TaildropConfig{Enabled: sc.taildropEnabled},
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
}