mirror of
https://github.com/juanfont/headscale.git
synced 2026-11-01 03:04:07 +09:00
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:
1 parent
2e563635d0
commit
4ce928411e
3 files changed
+192
No files matched your search
@@ -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
|
- 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
|
- 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
|
- 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)
|
## 0.29.7 (202x-xx-xx)
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
"slices"
|
"slices"
|
||||||
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
"testing"
|
"testing"
|
||||||
@@ -1368,3 +1369,158 @@ func TestLoadedExpiryIsUTC(t *testing.T) {
|
|||||||
assert.True(t, expiry.Equal(got))
|
assert.True(t, expiry.Equal(got))
|
||||||
assert.Equal(t, time.UTC, got.Location())
|
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)
|
||||||
|
}
|
||||||
@@ -43,6 +43,7 @@ import (
|
|||||||
"tailscale.com/types/key"
|
"tailscale.com/types/key"
|
||||||
"tailscale.com/types/views"
|
"tailscale.com/types/views"
|
||||||
"tailscale.com/util/dnsname"
|
"tailscale.com/util/dnsname"
|
||||||
|
"tailscale.com/util/truncate"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -259,6 +260,40 @@ func NewState(cfg *types.Config) (*State, error) {
|
|||||||
if node.Expiry != nil {
|
if node.Expiry != nil {
|
||||||
node.Expiry = new(node.Expiry.UTC())
|
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)
|
users, err := db.ListUsers(nil)
|
||||||
|
|||||||
Reference in new issue
Block a user