state: replace zcache with bounded LRU for auth cache

Replace zcache with golang-lru/v2/expirable for both the state auth
cache and the OIDC state cache. Add tuning.register_cache_max_entries
(default 1024) to cap the number of pending registration entries.

Introduce types.RegistrationData to replace caching a full *Node;
only the fields the registration callback path reads are retained.
Remove the dead HSDatabase.regCache field. Drop zgo.at/zcache/v2
from go.mod.
This commit is contained in:
Kristoffer Dalby 2026-04-09 17:27:42 +00:00
parent 3587225a88
commit 0d4f2293ff
21 changed files with 343 additions and 258 deletions

View file

@ -221,40 +221,65 @@ func (r AuthID) Validate() error {
return nil
}
// AuthRequest represent a pending authentication request from a user or a node.
// If it is a registration request, the node field will be populate with the node that is trying to register.
// When the authentication process is finished, the node that has been authenticated will be sent through the Finished channel.
// The closed field is used to ensure that the Finished channel is only closed once, and that no more nodes are sent through it after it has been closed.
// AuthRequest represents a pending authentication request from a user or a
// node. It carries the minimum data needed to either complete a node
// registration (regData populated) or signal the verdict of an interactive
// auth flow (no payload). Verdict delivery is via the finished channel; the
// closed flag guards FinishAuth against double-close.
//
// AuthRequest is always handled by pointer so the channel and atomic flag
// have a single canonical instance even when stored in caches that
// internally copy values.
type AuthRequest struct {
node *Node
// regData is populated for node-registration flows (interactive web
// or OIDC). It carries only the minimal subset of registration data
// the auth callback needs to promote this request into a real node;
// see RegistrationData for the rationale behind keeping the payload
// small.
//
// nil for non-registration flows (e.g. SSH check). Use
// RegistrationData() to read it safely.
regData *RegistrationData
finished chan AuthVerdict
closed *atomic.Bool
}
func NewAuthRequest() AuthRequest {
return AuthRequest{
// NewAuthRequest creates a pending auth request with no payload, suitable
// for non-registration flows that only need a verdict channel.
func NewAuthRequest() *AuthRequest {
return &AuthRequest{
finished: make(chan AuthVerdict, 1),
closed: &atomic.Bool{},
}
}
func NewRegisterAuthRequest(node Node) AuthRequest {
return AuthRequest{
node: &node,
// NewRegisterAuthRequest creates a pending auth request carrying the
// minimal RegistrationData for a node-registration flow. The data is
// stored by pointer; callers must not mutate it after handing it off.
func NewRegisterAuthRequest(data *RegistrationData) *AuthRequest {
return &AuthRequest{
regData: data,
finished: make(chan AuthVerdict, 1),
closed: &atomic.Bool{},
}
}
// Node returns the node that is trying to register.
// It will panic if the AuthRequest is not a registration request.
// Can _only_ be used in the registration path.
func (rn *AuthRequest) Node() NodeView {
if rn.node == nil {
panic("Node can only be used in registration requests")
// RegistrationData returns the cached registration payload. It panics if
// called on an AuthRequest that was not created via
// NewRegisterAuthRequest, mirroring the previous Node() contract.
func (rn *AuthRequest) RegistrationData() *RegistrationData {
if rn.regData == nil {
panic("RegistrationData can only be used in registration requests")
}
return rn.node.View()
return rn.regData
}
// IsRegistration reports whether this auth request carries registration
// data (i.e. it was created via NewRegisterAuthRequest).
func (rn *AuthRequest) IsRegistration() bool {
return rn.regData != nil
}
func (rn *AuthRequest) FinishAuth(verdict AuthVerdict) {

View file

@ -278,14 +278,16 @@ type Tuning struct {
// updates for connected clients.
BatcherWorkers int
// RegisterCacheCleanup is the interval between cleanup operations for
// expired registration cache entries.
RegisterCacheCleanup time.Duration
// RegisterCacheExpiration is how long registration cache entries remain
// valid before being eligible for cleanup.
// valid before being eligible for eviction.
RegisterCacheExpiration time.Duration
// RegisterCacheMaxEntries bounds the number of pending registration
// entries the auth cache will hold. Older entries are evicted (LRU)
// when the cap is reached, preventing unauthenticated cache-fill DoS.
// A value of 0 falls back to defaultRegisterCacheMaxEntries (1024).
RegisterCacheMaxEntries int
// NodeStoreBatchSize controls how many write operations are accumulated
// before rebuilding the in-memory node snapshot.
//
@ -1192,8 +1194,8 @@ func LoadServerConfig() (*Config, error) {
return DefaultBatcherWorkers()
}(),
RegisterCacheCleanup: viper.GetDuration("tuning.register_cache_cleanup"),
RegisterCacheExpiration: viper.GetDuration("tuning.register_cache_expiration"),
RegisterCacheMaxEntries: viper.GetInt("tuning.register_cache_max_entries"),
NodeStoreBatchSize: viper.GetInt("tuning.node_store_batch_size"),
NodeStoreBatchTimeout: viper.GetDuration("tuning.node_store_batch_timeout"),
},

View file

@ -0,0 +1,55 @@
package types
import (
"net/netip"
"time"
"tailscale.com/tailcfg"
"tailscale.com/types/key"
)
// RegistrationData is the payload cached for a pending node registration.
// It replaces the previous practice of caching a full *Node and carries
// 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).
type RegistrationData struct {
// MachineKey is the cryptographic identity of the machine being
// registered. Required.
MachineKey key.MachinePublic
// NodeKey is the cryptographic identity of the node session.
// Required.
NodeKey key.NodePublic
// DiscoKey is the disco public key for peer-to-peer connections.
DiscoKey key.DiscoPublic
// Hostname is the resolved hostname for the registering node.
// Already validated/normalised by EnsureHostname at producer time.
Hostname string
// Hostinfo is the original Hostinfo from the RegisterRequest,
// stored so that the auth callback can populate the new node's
// initial Hostinfo (and so that observability/CLI consumers see
// fields like OS, OSVersion, and IPNVersion before the first
// MapRequest restores the live set).
//
// May be nil if the client did not send Hostinfo in the original
// RegisterRequest.
Hostinfo *tailcfg.Hostinfo
// Endpoints is the initial set of WireGuard endpoints the node
// reported. The first MapRequest after registration overwrites
// this with the live set.
Endpoints []netip.AddrPort
// Expiry is the optional client-requested expiry for this node.
// May be nil if the client did not request a specific expiry.
Expiry *time.Time
}