auth, state: keep announced routes out of pending registrations

Routes are the one long list; the first MapRequest reports them.
A relogin keeps the node's own routes until then.
This commit is contained in:
Kristoffer Dalby committed 2026-10-28 07:15:54 +00:00
1 parent e4e2ea820b
commit 5aaa4e1d2b
5 files changed
+102 -20

No files matched your search

+14 -6
View File
@@ -392,24 +392,32 @@ func (h *Headscale) reqToNewRegisterResponse(
}
// registrationDataFromRequest builds the [types.RegistrationData] payload stored
// in the auth cache for a pending registration. The original [tailcfg.Hostinfo] is
// retained so that consumers (auth callback, observability) see the
// fields the client originally announced; the bounded-LRU cap on the
// cache is what bounds the unauthenticated cache-fill DoS surface.
// in the auth cache for a pending registration. The [tailcfg.Hostinfo] is kept
// so that consumers (auth callback, observability) see the fields the client
// announced, except its routes: they are the one list a real client makes
// long, and the node's first [tailcfg.MapRequest] reports them again.
func registrationDataFromRequest(
req tailcfg.RegisterRequest,
machineKey key.MachinePublic,
) *types.RegistrationData {
var hostname string
var (
hostname string
hostinfo *tailcfg.Hostinfo
)
if req.Hostinfo != nil {
hostname = req.Hostinfo.Hostname
hi := *req.Hostinfo
hi.RoutableIPs = nil
hostinfo = &hi
}
regData := &types.RegistrationData{
MachineKey: machineKey,
NodeKey: req.NodeKey,
Hostname: hostname,
Hostinfo: req.Hostinfo,
Hostinfo: hostinfo,
}
if !req.Expiry.IsZero() {
+32
View File
@@ -5,6 +5,7 @@ import (
"errors"
"fmt"
"net/http"
"net/netip"
"net/url"
"strings"
"testing"
@@ -4599,3 +4600,34 @@ func TestRegisterRejectsUnencodableExpiry(t *testing.T) {
require.NoError(t, err)
assert.True(t, latest.Equal(*stored.Expiry))
}
// TestPendingRegistrationKeepsNoRoutes pins that a pending registration caches
// the client's Hostinfo without its announced routes, the one list a client
// makes long; the node's first MapRequest reports them again.
func TestPendingRegistrationKeepsNoRoutes(t *testing.T) {
app := createTestApp(t)
hostinfo := &tailcfg.Hostinfo{
Hostname: "pending-router",
OSVersion: "6.12.0",
RoutableIPs: []netip.Prefix{netip.MustParsePrefix("10.0.0.0/24")},
Services: []tailcfg.Service{{Proto: tailcfg.PeerAPI4, Port: 41641}},
}
resp, err := app.handleRegister(context.Background(), tailcfg.RegisterRequest{
NodeKey: key.NewNode().Public(),
Hostinfo: hostinfo,
}, key.NewMachine().Public())
require.NoError(t, err)
authID, err := extractRegistrationIDFromAuthURL(resp.AuthURL)
require.NoError(t, err, "resp=%+v", resp)
entry, ok := app.state.GetAuthCacheEntry(authID)
require.True(t, ok)
want := hostinfo.Clone()
want.RoutableIPs = nil
assert.Equal(t, want, entry.RegistrationData().Hostinfo)
assert.Len(t, hostinfo.RoutableIPs, 1, "the request itself must be left alone")
}
+40
View File
@@ -2074,3 +2074,43 @@ func TestBootKeepsHostinfoTheMapPathStored(t *testing.T) {
osVersionLen := len(reloaded.Hostinfo().OSVersion())
assert.Equal(t, 60<<10, osVersionLen)
}
// TestReauthKeepsAnnouncedRoutes pins that an auth-path relogin keeps the
// routes a router announced. Pending registrations carry none, and dropping
// the node's own would withdraw its primary routes until the next MapRequest.
func TestReauthKeepsAnnouncedRoutes(t *testing.T) {
_, s, nodeID := persistTestSetup(t)
t.Cleanup(func() { _ = s.Close() })
route := netip.MustParsePrefix("10.0.0.0/24")
node, ok := s.nodeStore.UpdateNode(nodeID, func(n *types.Node) {
n.Hostinfo = &tailcfg.Hostinfo{
Hostname: n.Hostname, RoutableIPs: []netip.Prefix{route},
}
n.ApprovedRoutes = []netip.Prefix{route}
})
require.True(t, ok)
_, epoch := s.Connect(nodeID)
require.NotZero(t, epoch)
require.Equal(t, []netip.Prefix{route}, s.GetNodePrimaryRoutes(nodeID),
"precondition: the node serves its route")
authID := types.MustAuthID()
s.SetAuthCacheEntry(authID, types.NewRegisterAuthRequest(&types.RegistrationData{
MachineKey: node.MachineKey(),
NodeKey: key.NewNode().Public(),
Hostname: node.Hostname(),
Hostinfo: &tailcfg.Hostinfo{Hostname: node.Hostname()},
}))
relogged, _, err := s.HandleNodeFromAuthPath(
authID, node.TypedUserID(), nil, util.RegisterMethodOIDC,
)
require.NoError(t, err)
require.Equal(t, nodeID, relogged.ID(), "relogin must update the existing node")
assert.Equal(t, []netip.Prefix{route}, relogged.AnnouncedRoutes())
assert.Equal(t, []netip.Prefix{route}, s.GetNodePrimaryRoutes(nodeID))
}
+8 -4
View File
@@ -52,10 +52,9 @@ const (
registerCacheExpiration = time.Minute * 15
// defaultRegisterCacheMaxEntries is the default upper bound on the number
// of pending registration entries the auth cache will hold. With a 15-minute
// TTL and a stripped-down RegistrationData payload (~200 bytes per entry),
// 1024 entries cap the worst-case cache footprint at well under 1 MiB even
// under sustained unauthenticated cache-fill attempts.
// of pending registration entries the auth cache will hold. Each entry
// keeps a Hostinfo bounded at decode ([types.MaxHostinfoSize]) and
// without routes, so the entry cap bounds the cache's memory.
defaultRegisterCacheMaxEntries = 1024
// defaultNodeStoreBatchSize is the default number of write operations to batch
@@ -2244,6 +2243,10 @@ func (s *State) applyAuthNodeUpdate(params authNodeUpdateParams) (types.NodeView
node.DiscoKey = regData.DiscoKey
node.Hostname = params.Hostname
// Pending registrations do not cache announced routes; keep the
// node's own until its next MapRequest reports them.
routes := slices.Clone(node.AnnouncedRoutes())
// Preserve NetInfo from existing node when re-registering
node.Hostinfo = params.ValidHostinfo
node.Hostinfo.NetInfo = preserveNetInfo(
@@ -2251,6 +2254,7 @@ func (s *State) applyAuthNodeUpdate(params authNodeUpdateParams) (types.NodeView
params.ExistingNode.ID(),
params.ValidHostinfo,
)
node.Hostinfo.RoutableIPs = routes
// Preserve the node's live endpoints when the register request carried
// none. Web/OIDC relogins report endpoints via MapRequest, not register,
+8 -10
View File
@@ -13,11 +13,9 @@ import (
// only the fields the registration callback path actually consumes when
// promoting a pending registration to a real node.
//
// Combined with the bounded-LRU cache that holds these entries, this caps
// the worst-case memory footprint of unauthenticated cache-fill attempts
// at (max_entries × per_entry_size). The cache is sized so that the
// product is bounded to a few MiB even with attacker-supplied 1 MiB
// Hostinfos (the Noise body limit).
// Any client can open a pending registration. The bounded-LRU cache that
// holds these entries caps their number, and each entry stays small: its
// Hostinfo is bounded at decode ([BoundHostinfo]) and kept without routes.
type RegistrationData struct {
// MachineKey is the cryptographic identity of the machine being
// registered. Required.
@@ -34,11 +32,11 @@ type RegistrationData struct {
// Already validated/normalised by EnsureHostname at producer time.
Hostname string
// Hostinfo is the original [tailcfg.Hostinfo] from the [tailcfg.RegisterRequest],
// stored so that the auth callback can populate the new node's
// initial [tailcfg.Hostinfo] (and so that observability/CLI consumers see
// fields like OS, OSVersion, and IPNVersion before the first
// [tailcfg.MapRequest] restores the live set).
// Hostinfo is the [tailcfg.Hostinfo] from the [tailcfg.RegisterRequest]
// without its routes, stored so that the auth callback can populate the
// new node's initial [tailcfg.Hostinfo] (and so that observability/CLI
// consumers see fields like OS, OSVersion, and IPNVersion before the
// first [tailcfg.MapRequest] restores the live set, routes included).
//
// May be nil if the client did not send [tailcfg.Hostinfo] in the original
// [tailcfg.RegisterRequest].