mirror of
https://github.com/juanfont/headscale.git
synced 2026-11-05 13:09:25 +09:00
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:
1 parent
e4e2ea820b
commit
5aaa4e1d2b
5 files changed
+102
-20
No files matched your search
+14
-6
@@ -392,24 +392,32 @@ func (h *Headscale) reqToNewRegisterResponse(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// registrationDataFromRequest builds the [types.RegistrationData] payload stored
|
// registrationDataFromRequest builds the [types.RegistrationData] payload stored
|
||||||
// in the auth cache for a pending registration. The original [tailcfg.Hostinfo] is
|
// in the auth cache for a pending registration. The [tailcfg.Hostinfo] is kept
|
||||||
// retained so that consumers (auth callback, observability) see the
|
// so that consumers (auth callback, observability) see the fields the client
|
||||||
// fields the client originally announced; the bounded-LRU cap on the
|
// announced, except its routes: they are the one list a real client makes
|
||||||
// cache is what bounds the unauthenticated cache-fill DoS surface.
|
// long, and the node's first [tailcfg.MapRequest] reports them again.
|
||||||
func registrationDataFromRequest(
|
func registrationDataFromRequest(
|
||||||
req tailcfg.RegisterRequest,
|
req tailcfg.RegisterRequest,
|
||||||
machineKey key.MachinePublic,
|
machineKey key.MachinePublic,
|
||||||
) *types.RegistrationData {
|
) *types.RegistrationData {
|
||||||
var hostname string
|
var (
|
||||||
|
hostname string
|
||||||
|
hostinfo *tailcfg.Hostinfo
|
||||||
|
)
|
||||||
|
|
||||||
if req.Hostinfo != nil {
|
if req.Hostinfo != nil {
|
||||||
hostname = req.Hostinfo.Hostname
|
hostname = req.Hostinfo.Hostname
|
||||||
|
|
||||||
|
hi := *req.Hostinfo
|
||||||
|
hi.RoutableIPs = nil
|
||||||
|
hostinfo = &hi
|
||||||
}
|
}
|
||||||
|
|
||||||
regData := &types.RegistrationData{
|
regData := &types.RegistrationData{
|
||||||
MachineKey: machineKey,
|
MachineKey: machineKey,
|
||||||
NodeKey: req.NodeKey,
|
NodeKey: req.NodeKey,
|
||||||
Hostname: hostname,
|
Hostname: hostname,
|
||||||
Hostinfo: req.Hostinfo,
|
Hostinfo: hostinfo,
|
||||||
}
|
}
|
||||||
|
|
||||||
if !req.Expiry.IsZero() {
|
if !req.Expiry.IsZero() {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/netip"
|
||||||
"net/url"
|
"net/url"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
@@ -4599,3 +4600,34 @@ func TestRegisterRejectsUnencodableExpiry(t *testing.T) {
|
|||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.True(t, latest.Equal(*stored.Expiry))
|
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")
|
||||||
|
}
|
||||||
@@ -2074,3 +2074,43 @@ func TestBootKeepsHostinfoTheMapPathStored(t *testing.T) {
|
|||||||
osVersionLen := len(reloaded.Hostinfo().OSVersion())
|
osVersionLen := len(reloaded.Hostinfo().OSVersion())
|
||||||
assert.Equal(t, 60<<10, osVersionLen)
|
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))
|
||||||
|
}
|
||||||
@@ -52,10 +52,9 @@ const (
|
|||||||
registerCacheExpiration = time.Minute * 15
|
registerCacheExpiration = time.Minute * 15
|
||||||
|
|
||||||
// defaultRegisterCacheMaxEntries is the default upper bound on the number
|
// defaultRegisterCacheMaxEntries is the default upper bound on the number
|
||||||
// of pending registration entries the auth cache will hold. With a 15-minute
|
// of pending registration entries the auth cache will hold. Each entry
|
||||||
// TTL and a stripped-down RegistrationData payload (~200 bytes per entry),
|
// keeps a Hostinfo bounded at decode ([types.MaxHostinfoSize]) and
|
||||||
// 1024 entries cap the worst-case cache footprint at well under 1 MiB even
|
// without routes, so the entry cap bounds the cache's memory.
|
||||||
// under sustained unauthenticated cache-fill attempts.
|
|
||||||
defaultRegisterCacheMaxEntries = 1024
|
defaultRegisterCacheMaxEntries = 1024
|
||||||
|
|
||||||
// defaultNodeStoreBatchSize is the default number of write operations to batch
|
// 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.DiscoKey = regData.DiscoKey
|
||||||
node.Hostname = params.Hostname
|
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
|
// Preserve NetInfo from existing node when re-registering
|
||||||
node.Hostinfo = params.ValidHostinfo
|
node.Hostinfo = params.ValidHostinfo
|
||||||
node.Hostinfo.NetInfo = preserveNetInfo(
|
node.Hostinfo.NetInfo = preserveNetInfo(
|
||||||
@@ -2251,6 +2254,7 @@ func (s *State) applyAuthNodeUpdate(params authNodeUpdateParams) (types.NodeView
|
|||||||
params.ExistingNode.ID(),
|
params.ExistingNode.ID(),
|
||||||
params.ValidHostinfo,
|
params.ValidHostinfo,
|
||||||
)
|
)
|
||||||
|
node.Hostinfo.RoutableIPs = routes
|
||||||
|
|
||||||
// Preserve the node's live endpoints when the register request carried
|
// Preserve the node's live endpoints when the register request carried
|
||||||
// none. Web/OIDC relogins report endpoints via MapRequest, not register,
|
// none. Web/OIDC relogins report endpoints via MapRequest, not register,
|
||||||
|
|||||||
@@ -13,11 +13,9 @@ import (
|
|||||||
// only the fields the registration callback path actually consumes when
|
// only the fields the registration callback path actually consumes when
|
||||||
// promoting a pending registration to a real node.
|
// promoting a pending registration to a real node.
|
||||||
//
|
//
|
||||||
// Combined with the bounded-LRU cache that holds these entries, this caps
|
// Any client can open a pending registration. The bounded-LRU cache that
|
||||||
// the worst-case memory footprint of unauthenticated cache-fill attempts
|
// holds these entries caps their number, and each entry stays small: its
|
||||||
// at (max_entries × per_entry_size). The cache is sized so that the
|
// Hostinfo is bounded at decode ([BoundHostinfo]) and kept without routes.
|
||||||
// product is bounded to a few MiB even with attacker-supplied 1 MiB
|
|
||||||
// Hostinfos (the Noise body limit).
|
|
||||||
type RegistrationData struct {
|
type RegistrationData struct {
|
||||||
// MachineKey is the cryptographic identity of the machine being
|
// MachineKey is the cryptographic identity of the machine being
|
||||||
// registered. Required.
|
// registered. Required.
|
||||||
@@ -34,11 +32,11 @@ type RegistrationData struct {
|
|||||||
// Already validated/normalised by EnsureHostname at producer time.
|
// Already validated/normalised by EnsureHostname at producer time.
|
||||||
Hostname string
|
Hostname string
|
||||||
|
|
||||||
// Hostinfo is the original [tailcfg.Hostinfo] from the [tailcfg.RegisterRequest],
|
// Hostinfo is the [tailcfg.Hostinfo] from the [tailcfg.RegisterRequest]
|
||||||
// stored so that the auth callback can populate the new node's
|
// without its routes, stored so that the auth callback can populate the
|
||||||
// initial [tailcfg.Hostinfo] (and so that observability/CLI consumers see
|
// new node's initial [tailcfg.Hostinfo] (and so that observability/CLI
|
||||||
// fields like OS, OSVersion, and IPNVersion before the first
|
// consumers see fields like OS, OSVersion, and IPNVersion before the
|
||||||
// [tailcfg.MapRequest] restores the live set).
|
// first [tailcfg.MapRequest] restores the live set, routes included).
|
||||||
//
|
//
|
||||||
// May be nil if the client did not send [tailcfg.Hostinfo] in the original
|
// May be nil if the client did not send [tailcfg.Hostinfo] in the original
|
||||||
// [tailcfg.RegisterRequest].
|
// [tailcfg.RegisterRequest].
|
||||||
|
|||||||
Reference in new issue
Block a user