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
This commit is contained in:
Kristoffer Dalby committed 2026-10-29 14:00:02 +01:00
1 parent 2e563635d0
commit 4ce928411e
3 files changed
+192

No files matched your search

+1
View File
@@ -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)
+156
View File
@@ -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)
}
+35
View File
@@ -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)