From d5555c6b8c2502a067431fae0f6628ec1ef14dd0 Mon Sep 17 00:00:00 2001 From: Kristoffer Dalby Date: Sat, 20 Jun 2026 20:16:51 +0000 Subject: [PATCH] servertest: add real-listener option and node/key helpers Add WithRealListener for tests needing a real loopback port, plus CreateAPIKey and CreateRegisteredNode for the v2 roundtrip. --- hscontrol/servertest/server.go | 67 +++++++++++++++++++++++++++++++--- 1 file changed, 62 insertions(+), 5 deletions(-) diff --git a/hscontrol/servertest/server.go b/hscontrol/servertest/server.go index 71e3e6bb..9601694c 100644 --- a/hscontrol/servertest/server.go +++ b/hscontrol/servertest/server.go @@ -5,6 +5,7 @@ package servertest import ( + "context" "net" "net/http" "net/netip" @@ -43,6 +44,7 @@ type serverConfig struct { nodeExpiry time.Duration batcherWorkers int taildropEnabled bool + realListener bool } func defaultServerConfig() *serverConfig { @@ -82,6 +84,14 @@ func WithNodeExpiry(d time.Duration) ServerOption { return func(c *serverConfig) { c.nodeExpiry = d } } +// WithRealListener binds the HTTP API to a real loopback TCP port instead of +// the in-process memnet, so external processes — the Tailscale SDK over a real +// socket, tscli, OpenTofu — can reach the API. The Noise/[TestClient] control +// path still uses memnet, so this is for REST/API tests only. +func WithRealListener() ServerOption { + return func(c *serverConfig) { c.realListener = true } +} + // 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. @@ -164,13 +174,24 @@ func NewServer(tb testing.TB, opts ...ServerOption) *TestServer { 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 + // Start the HTTP server. By default it binds an in-memory network so all + // TCP connections stay in-process; WithRealListener swaps in a real loopback + // port so external binaries can connect. + var ( + memNetwork memnet.Network + ln net.Listener + ) + + if sc.realListener { + var lc net.ListenConfig + + ln, err = lc.Listen(context.Background(), "tcp", "127.0.0.1:0") + } else { + ln, err = memNetwork.Listen("tcp", "127.0.0.1:443") + } - ln, err := memNetwork.Listen("tcp", "127.0.0.1:443") if err != nil { - tb.Fatalf("servertest: memnet Listen: %v", err) + tb.Fatalf("servertest: Listen: %v", err) } httpServer := &http.Server{ @@ -233,6 +254,42 @@ func (s *TestServer) CreateUser(tb testing.TB, name string) *types.User { return u } +// CreateAPIKey mints an API key string (the Bearer/Basic credential). When +// owner is non-nil the key is owned by that user, so the v2 API mints +// user-owned (untagged) auth keys on its behalf. +func (s *TestServer) CreateAPIKey(tb testing.TB, owner *types.User) string { + tb.Helper() + + keyStr, key, err := s.st.CreateAPIKey(nil) + if err != nil { + tb.Fatalf("servertest: CreateAPIKey: %v", err) + } + + if owner != nil { + err := s.st.SetAPIKeyUser(key.ID, types.UserID(owner.ID)) + if err != nil { + tb.Fatalf("servertest: SetAPIKeyUser: %v", err) + } + } + + return keyStr +} + +// CreateRegisteredNode mints a registered node (allocated IPs, registered +// method) in BOTH the database and the in-memory NodeStore, then returns its +// view. The v2 device endpoints resolve nodes via State.GetNodeByID, which reads +// the NodeStore, so a DB-only node would be invisible to them. +func (s *TestServer) CreateRegisteredNode(tb testing.TB, owner *types.User, hostname ...string) types.NodeView { + tb.Helper() + + node := s.st.CreateRegisteredNodeForTest(owner, hostname...) + // CreateRegisteredNodeForTest sets UserID but leaves the User association + // unloaded; the device response renders the owner login, so attach it. + node.User = owner + + return s.st.PutNodeInStoreForTest(*node) +} + // CreatePreAuthKey creates a reusable pre-auth key for the given user. func (s *TestServer) CreatePreAuthKey(tb testing.TB, userID types.UserID) string { tb.Helper()