From 6474ac6dd3479f4b49b8d680b0f3edfcaff86bc7 Mon Sep 17 00:00:00 2001 From: Kristoffer Dalby Date: Thu, 22 Oct 2026 00:54:50 +0000 Subject: [PATCH] api/v2: return a device name as its FQDN --- hscontrol/api/v2/devices.go | 14 +++++++++---- hscontrol/apiv2_devices_test.go | 17 +++++++++++++++ hscontrol/servertest/names_compat_test.go | 25 ----------------------- 3 files changed, 27 insertions(+), 29 deletions(-) diff --git a/hscontrol/api/v2/devices.go b/hscontrol/api/v2/devices.go index f3498fb19..b8bc25290 100644 --- a/hscontrol/api/v2/devices.go +++ b/hscontrol/api/v2/devices.go @@ -143,7 +143,7 @@ func registerDevices(api huma.API, b Backend) { return nil, err } - return &deviceOutput{Body: deviceFromView(node, in.Fields == "all")}, nil + return &deviceOutput{Body: deviceFromView(node, in.Fields == "all", b.Cfg.BaseDomain)}, nil }) huma.Register(api, requireScope(huma.Operation{ @@ -167,7 +167,7 @@ func registerDevices(api huma.API, b Backend) { out.Body.Devices = make([]Device, 0, nodes.Len()) for _, node := range nodes.All() { - out.Body.Devices = append(out.Body.Devices, deviceFromView(node, allFields)) + out.Body.Devices = append(out.Body.Devices, deviceFromView(node, allFields, b.Cfg.BaseDomain)) } return out, nil @@ -429,12 +429,18 @@ func parseRoutes(routes []string) ([]netip.Prefix, error) { // deviceFromView maps a Headscale node onto the Tailscale Device, reading // through the [types.NodeView] accessors. allFields gates the route slices, // which Tailscale only returns for fields=all. -func deviceFromView(view types.NodeView, allFields bool) Device { +func deviceFromView(view types.NodeView, allFields bool, baseDomain string) Device { id := view.StringID() + // Tailscale names a device by its MagicDNS FQDN, without the root dot. + name := view.GivenName() + if baseDomain != "" { + name += "." + baseDomain + } + d := Device{ Addresses: emptyIfNil(view.IPsAsString()), - Name: view.GivenName(), + Name: name, ID: id, NodeID: id, Authorized: true, // Headscale has no post-registration de-auth state. diff --git a/hscontrol/apiv2_devices_test.go b/hscontrol/apiv2_devices_test.go index 7864fb9fe..50d312f90 100644 --- a/hscontrol/apiv2_devices_test.go +++ b/hscontrol/apiv2_devices_test.go @@ -146,6 +146,23 @@ func TestAPIv2Device_Get(t *testing.T) { assert.Equal(t, n.IPsAsString(), dev.Addresses) } +// TestAPIv2Device_NameIsFQDN: with a base domain, a device's name is its +// MagicDNS FQDN, in a read and in the list. +func TestAPIv2Device_NameIsFQDN(t *testing.T) { + e := newDeviceTestEnv(t) + e.app.cfg.BaseDomain = "tailnet.example.com" + + assert.Equal(t, "contract-dut.tailnet.example.com", getDevice(t, e.api, e.deviceID).Name) + + resp := e.api.Get("/api/v2/tailnet/-/devices") + require.Equalf(t, http.StatusOK, resp.Code, "body: %s", resp.Body) + + var list struct{ Devices []apiv2.Device } + require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &list)) + require.Len(t, list.Devices, 1) + assert.Equal(t, "contract-dut.tailnet.example.com", list.Devices[0].Name) +} + func TestAPIv2Device_Get_UnknownID_404(t *testing.T) { e := newDeviceTestEnv(t) diff --git a/hscontrol/servertest/names_compat_test.go b/hscontrol/servertest/names_compat_test.go index d7846a504..b34012c09 100644 --- a/hscontrol/servertest/names_compat_test.go +++ b/hscontrol/servertest/names_compat_test.go @@ -31,30 +31,6 @@ import ( // ///. A listed check that // matches fails, so a fix must delete its entry. var nameSkipReasons = map[string]string{ - "names/N1/domain": v2NameBare, - "names/N2/domain": v2NameBare, - "names/N3/domain": v2NameBare, - "names/N4/domain": v2NameBare, - "names/N5/domain": v2NameBare, - "names/N6/domain": v2NameBare, - "names/N7/domain": v2NameBare, - "names/N8/domain": v2NameBare, - "names/N9a/domain": v2NameBare, - "names/N9b/domain": v2NameBare, - "names/N10/domain": v2NameBare, - "names/N11/domain": v2NameBare, - "names/N11b/domain": v2NameBare, - "names/N12a/domain": v2NameBare, - "names/N12b/domain": v2NameBare, - "names/N13/domain": v2NameBare, - "names-svc/S1/domain": v2NameBare, - "names-svc/S2/domain": v2NameBare, - "names-svc/S3/domain": v2NameBare, - "names-svc/S4/domain": v2NameBare, - "names-svc/S5/domain": v2NameBare, - "names-svc/S6/domain": v2NameBare, - "names-svc/S7/domain": v2NameBare, - "names/N11/pdb/label": adminNameFollowsHostname, "names/N11b/pdb/label": adminNameFollowsHostname, "names/N12a/pdb/label": adminNameFollowsHostname, @@ -64,7 +40,6 @@ var nameSkipReasons = map[string]string{ const ( adminNameFollowsHostname = "ADMIN_NAME_FOLLOWS_HOSTNAME: an admin name equal to the hostname's label counts as derived from it, so a hostname change replaces it; SaaS keeps an admin name across hostname changes" - v2NameBare = "V2_NAME_BARE: headscale's v2 device name is the bare label; SaaS's is the FQDN under the tailnet domain" ) // nameViewer is the node whose netmap every step captured.