diff --git a/hscontrol/app.go b/hscontrol/app.go index e37e1f5f5..fbafd041d 100644 --- a/hscontrol/app.go +++ b/hscontrol/app.go @@ -501,7 +501,7 @@ func (h *Headscale) createRouter(apiV1Mux, apiV2Mux http.Handler) *chi.Mux { r.HandleFunc("/derp", h.DERPServer.DERPHandler) r.HandleFunc("/derp/probe", derpServer.DERPProbeHandler) r.HandleFunc("/derp/latency-check", derpServer.DERPProbeHandler) - r.HandleFunc("/bootstrap-dns", derpServer.DERPBootstrapDNSHandler(h.state.DERPMap())) + r.HandleFunc("/bootstrap-dns", derpServer.DERPBootstrapDNSHandler(h.state.DERPMap)) } // Auth is enforced inside each Huma mux per-operation, so the whole API diff --git a/hscontrol/derp/server/derp_server.go b/hscontrol/derp/server/derp_server.go index fbb422c9d..3e244f7b2 100644 --- a/hscontrol/derp/server/derp_server.go +++ b/hscontrol/derp/server/derp_server.go @@ -334,8 +334,9 @@ func DERPProbeHandler( // They have a cache, but not clear if that is really necessary at Headscale, uh, scale. // An example implementation is found here https://derp.tailscale.com/bootstrap-dns // Coordination server is included automatically, since local DERP is using the same DNS Name in d.serverURL. +// derpMap is called per request so DERP map updates are served. func DERPBootstrapDNSHandler( - derpMap tailcfg.DERPMapView, + derpMap func() tailcfg.DERPMapView, ) func(http.ResponseWriter, *http.Request) { return func( writer http.ResponseWriter, @@ -348,7 +349,7 @@ func DERPBootstrapDNSHandler( var resolver net.Resolver - for _, region := range derpMap.Regions().All() { //nolint:unqueryvet // not SQLBoiler, tailcfg iterator + for _, region := range derpMap().Regions().All() { //nolint:unqueryvet // not SQLBoiler, tailcfg iterator for _, node := range region.Nodes().All() { //nolint:unqueryvet // not SQLBoiler, tailcfg iterator addrs, err := resolver.LookupIP(resolvCtx, "ip", node.HostName()) if err != nil { diff --git a/hscontrol/derp/server/derp_server_test.go b/hscontrol/derp/server/derp_server_test.go index d85bea710..c41e4ae5b 100644 --- a/hscontrol/derp/server/derp_server_test.go +++ b/hscontrol/derp/server/derp_server_test.go @@ -1,12 +1,18 @@ package server import ( + "encoding/json" + "net" + "net/http" + "net/http/httptest" + "sync/atomic" "testing" "github.com/juanfont/headscale/hscontrol/types" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "tailscale.com/envknob" + "tailscale.com/tailcfg" ) // TestGenerateRegionInsecureTLS pins the contract nix/testkit.nix relies on: @@ -58,3 +64,26 @@ func TestGenerateRegionInsecureTLS(t *testing.T) { }) } } + +// TestDERPBootstrapDNSHandlerFollowsDERPMapUpdates guards against the handler +// resolving a DERP map captured at startup: hostnames that a later +// auto-update adds must be served. +func TestDERPBootstrapDNSHandlerFollowsDERPMapUpdates(t *testing.T) { + var current atomic.Pointer[tailcfg.DERPMap] + current.Store(&tailcfg.DERPMap{}) + + handler := DERPBootstrapDNSHandler(func() tailcfg.DERPMapView { + return current.Load().View() + }) + + current.Store(&tailcfg.DERPMap{Regions: map[tailcfg.DERPRegionID]*tailcfg.DERPRegion{ + 1: {RegionID: 1, Nodes: []*tailcfg.DERPNode{{Name: "1a", RegionID: 1, HostName: "localhost"}}}, + }}) + + rec := httptest.NewRecorder() + handler(rec, httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/bootstrap-dns", nil)) + + var got map[string][]net.IP + require.NoError(t, json.NewDecoder(rec.Body).Decode(&got)) + assert.Contains(t, got, "localhost") +}