From 4ce928411e63f92f2b72bf288d9f8b41063b82b1 Mon Sep 17 00:00:00 2001 From: Kristoffer Dalby Date: Wed, 28 Oct 2026 13:35:02 +0000 Subject: [PATCH] state: bound client reports of nodes loaded at startup Rows stored before the bounds are trimmed the same way at load; a Hostinfo still over budget is dropped until the node reports again. Fixes ANT-2026-RQS7JAPP Fixes ANT-2026-5B6KE87K Fixes ANT-2026-KFCFFM34 --- CHANGELOG.md | 1 + hscontrol/state/persist_test.go | 156 ++++++++++++++++++++++++++++++++ hscontrol/state/state.go | 35 +++++++ 3 files changed, 192 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8482c3e70..e70c4538b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -159,6 +159,7 @@ the ACME DNS-01 challenge through a built-in DNS provider: `acmedns`, - Fix one node with a stored key expiry outside the years 0 to 9999 in UTC failing the map of every node that can see it; it is now left out of its peers' maps and the startup check names it with the fix command - Refuse a key expiry outside the years 0 to 9999 in UTC on registration, on the OIDC token-expiry path and in `headscale nodes expire` / `POST /api/v1/node/{nodeId}/expire` (400); the latest accepted expiry no longer fails node listings on PostgreSQL in a zone east of UTC - Keep the first 256 endpoints and 4096 announced routes from client reports; oversized hostnames, requested tags or other metadata return a registration error or HTTP 413 on map updates. Zoned IPv6 endpoints are refused +- At startup, trim stored hostnames, endpoints and routes to the client-report limits and remove zoned endpoints. Hostinfo exceeding its size or tag limit is dropped until the next valid report ## 0.29.7 (202x-xx-xx) diff --git a/hscontrol/state/persist_test.go b/hscontrol/state/persist_test.go index 4b9d69e7b..104322856 100644 --- a/hscontrol/state/persist_test.go +++ b/hscontrol/state/persist_test.go @@ -4,6 +4,7 @@ import ( "errors" "net/netip" "slices" + "strings" "sync" "sync/atomic" "testing" @@ -1368,3 +1369,158 @@ func TestLoadedExpiryIsUTC(t *testing.T) { assert.True(t, expiry.Equal(got)) assert.Equal(t, time.UTC, got.Location()) } + +// TestNewStateBoundsStoredClientReport covers legacy reports before they reach peers. +func TestNewStateBoundsStoredClientReport(t *testing.T) { + endpoints := make([]netip.AddrPort, types.MaxEndpoints+1) + for i := range endpoints { + endpoints[i] = netip.AddrPortFrom(netip.MustParseAddr("192.0.2.1"), uint16(i)) + } + + routes := make([]netip.Prefix, types.MaxRoutableIPs+1) + for i := range routes { + routes[i] = netip.PrefixFrom(netip.AddrFrom16([16]byte{0xfd, byte(i >> 8), byte(i)}), 64) + } + + for _, tt := range []struct { + name string + hostname string + endpoints []netip.AddrPort + routes []netip.Prefix + wantHostname string + wantEndpoints []netip.AddrPort + wantRoutes []netip.Prefix + }{ + { + name: "at limits", + hostname: strings.Repeat("h", types.MaxHostnameLength), + endpoints: endpoints[:types.MaxEndpoints], + routes: routes[:types.MaxRoutableIPs], + wantHostname: strings.Repeat("h", types.MaxHostnameLength), + wantEndpoints: endpoints[:types.MaxEndpoints], + wantRoutes: routes[:types.MaxRoutableIPs], + }, + { + name: "over limits", + hostname: strings.Repeat("h", types.MaxHostnameLength+1), + endpoints: endpoints, + routes: routes, + wantHostname: strings.Repeat("h", types.MaxHostnameLength), + wantEndpoints: endpoints[:types.MaxEndpoints], + wantRoutes: routes[:types.MaxRoutableIPs], + }, + { + name: "multibyte hostname and zoned endpoint", + hostname: strings.Repeat("h", types.MaxHostnameLength-1) + "é", + endpoints: []netip.AddrPort{ + netip.MustParseAddrPort("[fe80::1%eth0]:41641"), endpoints[0], + }, + wantHostname: strings.Repeat("h", types.MaxHostnameLength-1), + wantEndpoints: endpoints[:1], + }, + } { + t.Run(tt.name, func(t *testing.T) { + dbPath := t.TempDir() + "/headscale.db" + cfg := persistTestConfig(dbPath) + database, err := db.NewHeadscaleDatabase(cfg) + require.NoError(t, err) + + user := database.CreateUserForTest("bound-user") + node := database.CreateRegisteredNodeForTest(user, "bounded") + err = database.DB.Model(node).Select("Hostname", "Endpoints", "Hostinfo"). + Updates(&types.Node{ + Hostname: tt.hostname, + Endpoints: tt.endpoints, + Hostinfo: &tailcfg.Hostinfo{ + Hostname: tt.hostname, RoutableIPs: tt.routes, + NetInfo: &tailcfg.NetInfo{PreferredDERP: 1, DERPLatency: map[string]float64{"1-v4": 0.01}}, + }, + }).Error + require.NoError(t, err) + require.NoError(t, database.Close()) + + s, err := NewState(cfg) + require.NoError(t, err) + t.Cleanup(func() { _ = s.Close() }) + + nv, ok := s.GetNodeByID(node.ID) + require.True(t, ok) + require.True(t, nv.Hostinfo().Valid()) + assert.Equal(t, tailcfg.DERPRegionID(1), nv.Hostinfo().NetInfo().PreferredDERP()) + assert.Nil(t, nv.Hostinfo().AsStruct().NetInfo.DERPLatency) + assert.Equal(t, tt.wantHostname, nv.Hostname()) + assert.Equal(t, tt.wantHostname, nv.Hostinfo().Hostname()) + assert.Equal(t, tt.wantEndpoints, nv.Endpoints().AsSlice()) + assert.Equal(t, tt.wantRoutes, nv.Hostinfo().RoutableIPs().AsSlice()) + + c, err := s.UpdateNodeFromMapRequest(node.ID, tailcfg.MapRequest{ + NodeKey: nv.NodeKey(), DiscoKey: key.NewDisco().Public(), + }) + require.NoError(t, err) + require.Len(t, c.PeerPatches, 1) + assert.Equal(t, tt.wantEndpoints, c.PeerPatches[0].Endpoints) + + row, err := s.DB().GetNodeByID(node.ID) + require.NoError(t, err) + assert.Equal(t, tt.wantHostname, row.Hostname, "the next persist rewrites the row") + assert.True(t, slices.Equal(tt.wantEndpoints, row.Endpoints), "persisted endpoints match the bounded report") + assert.Equal(t, tt.wantRoutes, row.Hostinfo.RoutableIPs) + }) + } +} + +func TestNewStateDiscardsOversizedHostinfo(t *testing.T) { + dbPath, s, nodeID := persistTestSetup(t) + err := s.DB().DB.Model(&types.Node{ID: nodeID}).Select("Hostinfo"). + Updates(&types.Node{Hostinfo: &tailcfg.Hostinfo{ + OSVersion: strings.Repeat("v", types.MaxHostinfoSize), + }}).Error + require.NoError(t, err) + require.NoError(t, s.Close()) + + nv, ok := persistTestReopen(t, dbPath).GetNodeByID(nodeID) + require.True(t, ok) + assert.False(t, nv.Hostinfo().Valid(), + "oversized Hostinfo is dropped until the node reports again") +} + +// TestBootKeepsHostinfoTheMapPathStored pins that the startup bound keeps +// what the map path accepted. A report without NetInfo keeps the stored one, +// so a stored Hostinfo can combine fields from two reports. +func TestBootKeepsHostinfoTheMapPathStored(t *testing.T) { + dbPath, s, nodeID := persistTestSetup(t) + + nv, ok := s.GetNodeByID(nodeID) + require.True(t, ok) + + for _, hi := range []*tailcfg.Hostinfo{ + { + Hostname: nv.Hostname(), + NetInfo: &tailcfg.NetInfo{ + PreferredDERP: 1, + LinkType: strings.Repeat("l", 60<<10), + }, + }, + {Hostname: nv.Hostname(), OSVersion: strings.Repeat("v", 60<<10)}, + } { + req := tailcfg.MapRequest{ + NodeKey: nv.NodeKey(), DiscoKey: nv.DiscoKey(), Hostinfo: hi, + } + require.NoError(t, types.ValidateHostinfo(req.Hostinfo)) + + _, err := s.UpdateNodeFromMapRequest(nodeID, req) + require.NoError(t, err) + } + + require.NoError(t, s.Close()) + + reloaded, ok := persistTestReopen(t, dbPath).GetNodeByID(nodeID) + require.True(t, ok) + require.True(t, reloaded.Hostinfo().Valid(), + "the startup bound must keep a Hostinfo the map path stored") + assert.Equal(t, tailcfg.DERPRegionID(1), + reloaded.Hostinfo().NetInfo().PreferredDERP()) + + osVersionLen := len(reloaded.Hostinfo().OSVersion()) + assert.Equal(t, 60<<10, osVersionLen) +} diff --git a/hscontrol/state/state.go b/hscontrol/state/state.go index cd1589d6d..f2fd8013c 100644 --- a/hscontrol/state/state.go +++ b/hscontrol/state/state.go @@ -43,6 +43,7 @@ import ( "tailscale.com/types/key" "tailscale.com/types/views" "tailscale.com/util/dnsname" + "tailscale.com/util/truncate" ) const ( @@ -259,6 +260,40 @@ func NewState(cfg *types.Config) (*State, error) { if node.Expiry != nil { node.Expiry = new(node.Expiry.UTC()) } + + // Legacy reports must pass the same checks before entering the NodeStore. + // The next persist rewrites trimmed or discarded metadata. + if len(node.Hostname) > types.MaxHostnameLength { + log.Warn().Uint64(zf.NodeID, node.ID.Uint64()).Msg("trimming oversized stored hostname") + node.Hostname = strings.Clone(truncate.String(node.Hostname, types.MaxHostnameLength)) + } + + node.Endpoints = slices.DeleteFunc(node.Endpoints, func(ep netip.AddrPort) bool { + return ep.Addr().Zone() != "" + }) + node.Endpoints = slices.Clone(node.Endpoints[:min(len(node.Endpoints), types.MaxEndpoints)]) + + if hi := node.Hostinfo; hi != nil { + if hi.NetInfo != nil { + hi.NetInfo.DERPLatency = nil + } + + if len(hi.Hostname) > types.MaxHostnameLength { + hi.Hostname = strings.Clone(truncate.String(hi.Hostname, types.MaxHostnameLength)) + } + + if len(hi.RoutableIPs) > types.MaxRoutableIPs { + log.Warn().Uint64(zf.NodeID, node.ID.Uint64()).Msgf( + "too many stored routes, keeping the first %d", types.MaxRoutableIPs) + hi.RoutableIPs = slices.Clone(hi.RoutableIPs[:types.MaxRoutableIPs]) + } + } + + err := types.ValidateHostinfo(node.Hostinfo) + if err != nil { + log.Warn().Uint64(zf.NodeID, node.ID.Uint64()).Err(err).Msg("discarding stored Hostinfo until the node reports again") + node.Hostinfo = nil + } } users, err := db.ListUsers(nil)