mirror of
https://github.com/juanfont/headscale.git
synced 2026-11-01 19:24:10 +09:00
hscontrol: bound pending authentication without evicting sessions
(cherry picked from commit c0925962c2, adapted: gRPC DebugCreateNode instead of REST v1)
This commit is contained in:
1 parent
514979b8ea
commit
37b4237ef6
21 files changed
+1736
-172
No files matched your search
+4
-1
@@ -482,7 +482,10 @@ auto_update:
|
|||||||
#
|
#
|
||||||
# tuning:
|
# tuning:
|
||||||
# # Maximum number of pending registration entries in the auth cache.
|
# # Maximum number of pending registration entries in the auth cache.
|
||||||
# # Oldest entries are evicted when the cap is reached.
|
# # At capacity, new registrations retry without displacing pending approvals.
|
||||||
|
# # SSH checks can use 64 additional reserved entries. Each registration
|
||||||
|
# # retains at most 64 KiB of serialized data, so the default registration
|
||||||
|
# # payload budget is 64 MiB plus record overhead.
|
||||||
# #
|
# #
|
||||||
# # register_cache_max_entries: 1024
|
# # register_cache_max_entries: 1024
|
||||||
#
|
#
|
||||||
|
|||||||
@@ -91,6 +91,34 @@ headscale users create <USER>
|
|||||||
listed as "online" in the output of `headscale nodes list`. The "User" column displays `tagged-devices` as the owner
|
listed as "online" in the output of `headscale nodes list`. The "User" column displays `tagged-devices` as the owner
|
||||||
of the node. See the "Tags" column for the list of assigned tags.
|
of the node. See the "Tags" column for the list of assigned tags.
|
||||||
|
|
||||||
|
### Pending authentication limits
|
||||||
|
|
||||||
|
Headscale keeps admitted authentication requests until they complete or expire.
|
||||||
|
When registration capacity is reached, new requests receive HTTP 503 with
|
||||||
|
`Retry-After` instead of invalidating someone else's pending approval. Existing
|
||||||
|
approvals and pre-authenticated key registration remain available. SSH checks
|
||||||
|
have 64 reserved entries beyond the registration allocation, including in the
|
||||||
|
OIDC state cache.
|
||||||
|
|
||||||
|
The default registration allocation is 1024 entries. The advanced
|
||||||
|
`tuning.register_cache_max_entries` setting changes that allocation. Pending
|
||||||
|
registration retains only hostname, OS and requested tags from Hostinfo; other
|
||||||
|
metadata arrives with the node's first MapRequest. The full cached registration
|
||||||
|
must fit within 64 KiB when serialized; larger payloads are rejected without
|
||||||
|
truncating authentication metadata. Noise
|
||||||
|
registrations return HTTP 200 with `RegisterResponse.Error` so the Tailscale
|
||||||
|
client displays the permanent size error. The admin API reports this size error
|
||||||
|
as HTTP 413.
|
||||||
|
At the default allocation, retained registration payloads use at most 64 MiB
|
||||||
|
plus record overhead. This is a cache-storage bound, not a bound on total server
|
||||||
|
memory or concurrent request processing.
|
||||||
|
|
||||||
|
OIDC login URLs must reference an existing pending authentication of the matching
|
||||||
|
type. OIDC callback state is single-use, including when the identity provider
|
||||||
|
exchange fails; restart the browser login flow after such an error. Capacity
|
||||||
|
limits protect admitted requests but cannot guarantee admission during sustained
|
||||||
|
overload.
|
||||||
|
|
||||||
### Pre authenticated key
|
### Pre authenticated key
|
||||||
|
|
||||||
Registration with a pre authenticated key (or auth key) is a non-interactive way to register a new node. The Headscale
|
Registration with a pre authenticated key (or auth key) is a non-interactive way to register a new node. The Headscale
|
||||||
|
|||||||
+23
-2
@@ -10,6 +10,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/juanfont/headscale/hscontrol/state"
|
||||||
"github.com/juanfont/headscale/hscontrol/types"
|
"github.com/juanfont/headscale/hscontrol/types"
|
||||||
"github.com/rs/zerolog/log"
|
"github.com/rs/zerolog/log"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
@@ -24,6 +25,17 @@ type AuthProvider interface {
|
|||||||
AuthURL(authID types.AuthID) string
|
AuthURL(authID types.AuthID) string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func authAdmissionError(err error) HTTPError {
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, types.ErrRegistrationPayloadTooLarge):
|
||||||
|
return NewHTTPError(http.StatusRequestEntityTooLarge, err.Error(), err)
|
||||||
|
case errors.Is(err, state.ErrAuthCacheFull), errors.Is(err, state.ErrAuthCacheClosed):
|
||||||
|
return NewHTTPError(http.StatusServiceUnavailable, "cannot start authentication; retry later", err)
|
||||||
|
default:
|
||||||
|
return NewHTTPError(http.StatusInternalServerError, "cannot start authentication", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// machineKeyMismatch fails closed when a node looked up by NodeKey was started
|
// machineKeyMismatch fails closed when a node looked up by NodeKey was started
|
||||||
// in a Noise session with a different machine key. Without this anyone holding a
|
// in a Noise session with a different machine key. Without this anyone holding a
|
||||||
// target's NodeKey could open a session with a throwaway machine key and act on
|
// target's NodeKey could open a session with a throwaway machine key and act on
|
||||||
@@ -371,6 +383,8 @@ func (h *Headscale) waitForFollowup(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
h.state.DeleteAuthCacheEntry(followupReg)
|
||||||
|
|
||||||
if verdict.Accept() {
|
if verdict.Accept() {
|
||||||
if !verdict.Node.Valid() {
|
if !verdict.Node.Valid() {
|
||||||
// registration is expired in the cache, instruct the client to try a new registration
|
// registration is expired in the cache, instruct the client to try a new registration
|
||||||
@@ -399,7 +413,11 @@ func (h *Headscale) reqToNewRegisterResponse(
|
|||||||
authRegReq := types.NewRegisterAuthRequest(regData)
|
authRegReq := types.NewRegisterAuthRequest(regData)
|
||||||
|
|
||||||
log.Info().Msgf("new followup node registration using auth id: %s", newAuthID)
|
log.Info().Msgf("new followup node registration using auth id: %s", newAuthID)
|
||||||
h.state.SetAuthCacheEntry(newAuthID, authRegReq)
|
|
||||||
|
err = h.state.SetAuthCacheEntry(newAuthID, authRegReq)
|
||||||
|
if err != nil {
|
||||||
|
return nil, authAdmissionError(err)
|
||||||
|
}
|
||||||
|
|
||||||
return &tailcfg.RegisterResponse{
|
return &tailcfg.RegisterResponse{
|
||||||
AuthURL: h.authProvider.RegisterURL(newAuthID),
|
AuthURL: h.authProvider.RegisterURL(newAuthID),
|
||||||
@@ -531,7 +549,10 @@ func (h *Headscale) handleRegisterInteractive(
|
|||||||
registrationDataFromRequest(req, machineKey),
|
registrationDataFromRequest(req, machineKey),
|
||||||
)
|
)
|
||||||
|
|
||||||
h.state.SetAuthCacheEntry(authID, authRegReq)
|
err = h.state.SetAuthCacheEntry(authID, authRegReq)
|
||||||
|
if err != nil {
|
||||||
|
return nil, authAdmissionError(err)
|
||||||
|
}
|
||||||
|
|
||||||
log.Info().Msgf("starting node registration using auth id: %s", authID)
|
log.Info().Msgf("starting node registration using auth id: %s", authID)
|
||||||
|
|
||||||
|
|||||||
@@ -848,7 +848,7 @@ func TestExpiryDuringPersonalToTaggedConversion(t *testing.T) {
|
|||||||
},
|
},
|
||||||
Expiry: &clientExpiry,
|
Expiry: &clientExpiry,
|
||||||
})
|
})
|
||||||
app.state.SetAuthCacheEntry(registrationID1, regEntry1)
|
require.NoError(t, app.state.SetAuthCacheEntry(registrationID1, regEntry1))
|
||||||
|
|
||||||
node, _, err := app.state.HandleNodeFromAuthPath(
|
node, _, err := app.state.HandleNodeFromAuthPath(
|
||||||
registrationID1, types.UserID(user.ID), nil, "webauth",
|
registrationID1, types.UserID(user.ID), nil, "webauth",
|
||||||
@@ -870,7 +870,7 @@ func TestExpiryDuringPersonalToTaggedConversion(t *testing.T) {
|
|||||||
},
|
},
|
||||||
Expiry: &clientExpiry, // Client still sends expiry
|
Expiry: &clientExpiry, // Client still sends expiry
|
||||||
})
|
})
|
||||||
app.state.SetAuthCacheEntry(registrationID2, regEntry2)
|
require.NoError(t, app.state.SetAuthCacheEntry(registrationID2, regEntry2))
|
||||||
|
|
||||||
nodeAfter, _, err := app.state.HandleNodeFromAuthPath(
|
nodeAfter, _, err := app.state.HandleNodeFromAuthPath(
|
||||||
registrationID2, types.UserID(user.ID), nil, "webauth",
|
registrationID2, types.UserID(user.ID), nil, "webauth",
|
||||||
@@ -919,7 +919,7 @@ func TestExpiryDuringTaggedToPersonalConversion(t *testing.T) {
|
|||||||
RequestTags: []string{"tag:server"}, // Tagged node
|
RequestTags: []string{"tag:server"}, // Tagged node
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
app.state.SetAuthCacheEntry(registrationID1, regEntry1)
|
require.NoError(t, app.state.SetAuthCacheEntry(registrationID1, regEntry1))
|
||||||
|
|
||||||
node, _, err := app.state.HandleNodeFromAuthPath(
|
node, _, err := app.state.HandleNodeFromAuthPath(
|
||||||
registrationID1, types.UserID(user.ID), nil, "webauth",
|
registrationID1, types.UserID(user.ID), nil, "webauth",
|
||||||
@@ -942,7 +942,7 @@ func TestExpiryDuringTaggedToPersonalConversion(t *testing.T) {
|
|||||||
},
|
},
|
||||||
Expiry: &clientExpiry, // Client requests expiry
|
Expiry: &clientExpiry, // Client requests expiry
|
||||||
})
|
})
|
||||||
app.state.SetAuthCacheEntry(registrationID2, regEntry2)
|
require.NoError(t, app.state.SetAuthCacheEntry(registrationID2, regEntry2))
|
||||||
|
|
||||||
nodeAfter, _, err := app.state.HandleNodeFromAuthPath(
|
nodeAfter, _, err := app.state.HandleNodeFromAuthPath(
|
||||||
registrationID2, types.UserID(user.ID), nil, "webauth",
|
registrationID2, types.UserID(user.ID), nil, "webauth",
|
||||||
|
|||||||
+124
-18
@@ -12,8 +12,11 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
v1 "github.com/juanfont/headscale/gen/go/headscale/v1"
|
||||||
"github.com/juanfont/headscale/hscontrol/mapper"
|
"github.com/juanfont/headscale/hscontrol/mapper"
|
||||||
|
"github.com/juanfont/headscale/hscontrol/state"
|
||||||
"github.com/juanfont/headscale/hscontrol/types"
|
"github.com/juanfont/headscale/hscontrol/types"
|
||||||
|
"github.com/rs/zerolog"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
"tailscale.com/tailcfg"
|
"tailscale.com/tailcfg"
|
||||||
@@ -688,7 +691,7 @@ func TestAuthenticationFlows(t *testing.T) {
|
|||||||
MachineKey: machineKey1.Public(),
|
MachineKey: machineKey1.Public(),
|
||||||
Hostname: "followup-success-node",
|
Hostname: "followup-success-node",
|
||||||
})
|
})
|
||||||
app.state.SetAuthCacheEntry(regID, nodeToRegister)
|
require.NoError(t, app.state.SetAuthCacheEntry(regID, nodeToRegister))
|
||||||
|
|
||||||
// Simulate successful registration
|
// Simulate successful registration
|
||||||
// [Headscale.handleRegister] will receive the value when it starts waiting
|
// [Headscale.handleRegister] will receive the value when it starts waiting
|
||||||
@@ -736,7 +739,7 @@ func TestAuthenticationFlows(t *testing.T) {
|
|||||||
MachineKey: machineKey1.Public(),
|
MachineKey: machineKey1.Public(),
|
||||||
Hostname: "followup-timeout-node",
|
Hostname: "followup-timeout-node",
|
||||||
})
|
})
|
||||||
app.state.SetAuthCacheEntry(regID, nodeToRegister)
|
require.NoError(t, app.state.SetAuthCacheEntry(regID, nodeToRegister))
|
||||||
// Don't call FinishRegistration - will timeout
|
// Don't call FinishRegistration - will timeout
|
||||||
|
|
||||||
return fmt.Sprintf("http://localhost:8080/register/%s", regID), nil
|
return fmt.Sprintf("http://localhost:8080/register/%s", regID), nil
|
||||||
@@ -1357,7 +1360,7 @@ func TestAuthenticationFlows(t *testing.T) {
|
|||||||
MachineKey: machineKey1.Public(),
|
MachineKey: machineKey1.Public(),
|
||||||
Hostname: "nil-response-node",
|
Hostname: "nil-response-node",
|
||||||
})
|
})
|
||||||
app.state.SetAuthCacheEntry(regID, nodeToRegister)
|
require.NoError(t, app.state.SetAuthCacheEntry(regID, nodeToRegister))
|
||||||
|
|
||||||
// Simulate registration that returns empty NodeView (cache expired during auth)
|
// Simulate registration that returns empty NodeView (cache expired during auth)
|
||||||
go func() {
|
go func() {
|
||||||
@@ -3136,7 +3139,7 @@ func TestWebFlowReauthDifferentUser(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Helper function to create test app.
|
// Helper function to create test app.
|
||||||
func createTestApp(t *testing.T) *Headscale {
|
func createTestApp(t *testing.T, options ...func(*types.Config)) *Headscale {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
tmpDir := t.TempDir()
|
tmpDir := t.TempDir()
|
||||||
@@ -3160,6 +3163,10 @@ func createTestApp(t *testing.T) *Headscale {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for _, option := range options {
|
||||||
|
option(&cfg)
|
||||||
|
}
|
||||||
|
|
||||||
app, err := NewHeadscale(&cfg)
|
app, err := NewHeadscale(&cfg)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
@@ -3177,6 +3184,105 @@ func createTestApp(t *testing.T) *Headscale {
|
|||||||
return app
|
return app
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestRegistrationFollowupReleasesRejectedSession(t *testing.T) {
|
||||||
|
app := createTestApp(t, func(cfg *types.Config) {
|
||||||
|
cfg.Tuning.RegisterCacheMaxEntries = 1
|
||||||
|
})
|
||||||
|
defer func() { require.NoError(t, app.state.Close()) }()
|
||||||
|
|
||||||
|
machineKey := key.NewMachine().Public()
|
||||||
|
req := tailcfg.RegisterRequest{
|
||||||
|
NodeKey: key.NewNode().Public(),
|
||||||
|
Hostinfo: &tailcfg.Hostinfo{Hostname: "rejected-registration"},
|
||||||
|
}
|
||||||
|
initial, err := app.handleRegisterInteractive(req, machineKey)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
req.Followup = initial.AuthURL
|
||||||
|
initialURL, err := url.Parse(initial.AuthURL)
|
||||||
|
require.NoError(t, err)
|
||||||
|
authID, err := types.AuthIDFromString(strings.TrimPrefix(initialURL.Path, "/register/"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(t.Context())
|
||||||
|
cancel()
|
||||||
|
|
||||||
|
_, err = app.waitForFollowup(ctx, req.Followup, registrationDataFromRequest(req, machineKey))
|
||||||
|
require.ErrorIs(t, err, context.Canceled)
|
||||||
|
|
||||||
|
_, present := app.state.GetAuthCacheEntry(authID)
|
||||||
|
require.True(t, present, "a canceled poll must leave the pending session available")
|
||||||
|
|
||||||
|
_, err = newHeadscaleV1APIServer(app).AuthReject(t.Context(), &v1.AuthRejectRequest{AuthId: authID.String()})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
_, present = app.state.GetAuthCacheEntry(authID)
|
||||||
|
require.True(t, present, "the verdict must remain available until follow-up consumes it")
|
||||||
|
|
||||||
|
followup, err := app.waitForFollowup(t.Context(), req.Followup, registrationDataFromRequest(req, machineKey))
|
||||||
|
require.NoError(t, err, "consuming the rejection must free capacity for the replacement")
|
||||||
|
require.NotEmpty(t, followup.AuthURL)
|
||||||
|
require.NotEqual(t, initial.AuthURL, followup.AuthURL)
|
||||||
|
|
||||||
|
_, present = app.state.GetAuthCacheEntry(authID)
|
||||||
|
require.False(t, present, "the consumed session must release its slot")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAuthAdmissionErrors(t *testing.T) {
|
||||||
|
for _, flow := range []string{"interactive", "followup", "ssh"} {
|
||||||
|
t.Run(flow, func(t *testing.T) {
|
||||||
|
app := createTestApp(t, func(cfg *types.Config) {
|
||||||
|
cfg.Tuning.RegisterCacheMaxEntries = 1
|
||||||
|
})
|
||||||
|
defer func() { require.NoError(t, app.state.Close()) }()
|
||||||
|
|
||||||
|
id := types.MustAuthID()
|
||||||
|
pending := types.NewAuthRequest()
|
||||||
|
require.NoError(t, app.state.SetAuthCacheEntry(id, pending))
|
||||||
|
|
||||||
|
if flow == "ssh" {
|
||||||
|
for range 64 {
|
||||||
|
require.NoError(t, app.state.SetAuthCacheEntry(types.MustAuthID(), types.NewSSHCheckAuthRequest(1, 2)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
req := tailcfg.RegisterRequest{
|
||||||
|
NodeKey: key.NewNode().Public(),
|
||||||
|
Hostinfo: &tailcfg.Hostinfo{Hostname: "pending-node"},
|
||||||
|
}
|
||||||
|
machineKey := key.NewMachine().Public()
|
||||||
|
|
||||||
|
var err error
|
||||||
|
|
||||||
|
switch flow {
|
||||||
|
case "interactive":
|
||||||
|
var resp *tailcfg.RegisterResponse
|
||||||
|
|
||||||
|
resp, err = app.handleRegisterInteractive(req, machineKey)
|
||||||
|
require.Nil(t, resp, "refused admission must not publish an auth URL")
|
||||||
|
case "followup":
|
||||||
|
var resp *tailcfg.RegisterResponse
|
||||||
|
|
||||||
|
resp, err = app.reqToNewRegisterResponse(registrationDataFromRequest(req, machineKey))
|
||||||
|
require.Nil(t, resp, "refused admission must not publish an auth URL")
|
||||||
|
case "ssh":
|
||||||
|
ns := &noiseServer{headscale: app}
|
||||||
|
|
||||||
|
var action *tailcfg.SSHAction
|
||||||
|
|
||||||
|
action, err = ns.sshActionHoldAndDelegate(zerolog.Nop(), &tailcfg.SSHAction{}, 1, 2, "alice")
|
||||||
|
require.Nil(t, action, "refused admission must not publish a delegation URL")
|
||||||
|
}
|
||||||
|
|
||||||
|
require.ErrorIs(t, err, state.ErrAuthCacheFull)
|
||||||
|
|
||||||
|
got, ok := app.state.GetAuthCacheEntry(id)
|
||||||
|
require.True(t, ok)
|
||||||
|
require.Same(t, pending, got)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// TestGitHubIssue2830_NodeRestartWithUsedPreAuthKey tests the scenario reported in
|
// TestGitHubIssue2830_NodeRestartWithUsedPreAuthKey tests the scenario reported in
|
||||||
// https://github.com/juanfont/headscale/issues/2830
|
// https://github.com/juanfont/headscale/issues/2830
|
||||||
//
|
//
|
||||||
@@ -3599,7 +3705,7 @@ func TestWebAuthRejectsUnauthorizedRequestTags(t *testing.T) {
|
|||||||
RequestTags: []string{"tag:unauthorized"}, // This tag is not in policy
|
RequestTags: []string{"tag:unauthorized"}, // This tag is not in policy
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
app.state.SetAuthCacheEntry(registrationID, regEntry)
|
require.NoError(t, app.state.SetAuthCacheEntry(registrationID, regEntry))
|
||||||
|
|
||||||
// Complete the web auth - should fail because tag is unauthorized
|
// Complete the web auth - should fail because tag is unauthorized
|
||||||
_, _, err := app.state.HandleNodeFromAuthPath(
|
_, _, err := app.state.HandleNodeFromAuthPath(
|
||||||
@@ -3662,7 +3768,7 @@ func TestWebAuthReauthWithEmptyTagsRemovesAllTags(t *testing.T) {
|
|||||||
RequestTags: []string{"tag:valid-owned", "tag:second"},
|
RequestTags: []string{"tag:valid-owned", "tag:second"},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
app.state.SetAuthCacheEntry(registrationID1, regEntry1)
|
require.NoError(t, app.state.SetAuthCacheEntry(registrationID1, regEntry1))
|
||||||
|
|
||||||
// Complete initial registration with tags
|
// Complete initial registration with tags
|
||||||
node, _, err := app.state.HandleNodeFromAuthPath(
|
node, _, err := app.state.HandleNodeFromAuthPath(
|
||||||
@@ -3689,7 +3795,7 @@ func TestWebAuthReauthWithEmptyTagsRemovesAllTags(t *testing.T) {
|
|||||||
RequestTags: []string{}, // EMPTY - should untag
|
RequestTags: []string{}, // EMPTY - should untag
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
app.state.SetAuthCacheEntry(registrationID2, regEntry2)
|
require.NoError(t, app.state.SetAuthCacheEntry(registrationID2, regEntry2))
|
||||||
|
|
||||||
// Complete reauth with empty tags
|
// Complete reauth with empty tags
|
||||||
nodeAfterReauth, _, err := app.state.HandleNodeFromAuthPath(
|
nodeAfterReauth, _, err := app.state.HandleNodeFromAuthPath(
|
||||||
@@ -3775,7 +3881,7 @@ func TestAuthKeyTaggedToUserOwnedViaReauth(t *testing.T) {
|
|||||||
RequestTags: []string{}, // EMPTY - should untag
|
RequestTags: []string{}, // EMPTY - should untag
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
app.state.SetAuthCacheEntry(registrationID, regEntry)
|
require.NoError(t, app.state.SetAuthCacheEntry(registrationID, regEntry))
|
||||||
|
|
||||||
// Complete reauth with empty tags
|
// Complete reauth with empty tags
|
||||||
nodeAfterReauth, _, err := app.state.HandleNodeFromAuthPath(
|
nodeAfterReauth, _, err := app.state.HandleNodeFromAuthPath(
|
||||||
@@ -3975,7 +4081,7 @@ func TestTaggedNodeWithoutUserToDifferentUser(t *testing.T) {
|
|||||||
RequestTags: []string{}, // Empty - transition to user-owned
|
RequestTags: []string{}, // Empty - transition to user-owned
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
app.state.SetAuthCacheEntry(registrationID, regEntry)
|
require.NoError(t, app.state.SetAuthCacheEntry(registrationID, regEntry))
|
||||||
|
|
||||||
// This should NOT panic - before the fix, this would panic with:
|
// This should NOT panic - before the fix, this would panic with:
|
||||||
// panic: runtime error: invalid memory address or nil pointer dereference
|
// panic: runtime error: invalid memory address or nil pointer dereference
|
||||||
@@ -4109,7 +4215,7 @@ func TestHandleNodeFromAuthPath_OldUserNil_NoPanic(t *testing.T) {
|
|||||||
Hostname: "authpath-orphan-newuser",
|
Hostname: "authpath-orphan-newuser",
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
app.state.SetAuthCacheEntry(authID, regEntry)
|
require.NoError(t, app.state.SetAuthCacheEntry(authID, regEntry))
|
||||||
|
|
||||||
node, _, err := app.state.HandleNodeFromAuthPath(
|
node, _, err := app.state.HandleNodeFromAuthPath(
|
||||||
authID,
|
authID,
|
||||||
@@ -4167,14 +4273,14 @@ func TestHandleNodeFromAuthPath_ReloginWholeNode(t *testing.T) {
|
|||||||
|
|
||||||
newNodeKey := key.NewNode()
|
newNodeKey := key.NewNode()
|
||||||
authID := types.MustAuthID()
|
authID := types.MustAuthID()
|
||||||
app.state.SetAuthCacheEntry(authID, types.NewRegisterAuthRequest(&types.RegistrationData{
|
require.NoError(t, app.state.SetAuthCacheEntry(authID, types.NewRegisterAuthRequest(&types.RegistrationData{
|
||||||
MachineKey: node.MachineKey,
|
MachineKey: node.MachineKey,
|
||||||
NodeKey: newNodeKey.Public(),
|
NodeKey: newNodeKey.Public(),
|
||||||
Hostname: hostname,
|
Hostname: hostname,
|
||||||
Hostinfo: &tailcfg.Hostinfo{
|
Hostinfo: &tailcfg.Hostinfo{
|
||||||
Hostname: hostname,
|
Hostname: hostname,
|
||||||
},
|
},
|
||||||
}))
|
})))
|
||||||
|
|
||||||
relogged, c, err := app.state.HandleNodeFromAuthPath(
|
relogged, c, err := app.state.HandleNodeFromAuthPath(
|
||||||
authID,
|
authID,
|
||||||
@@ -4226,7 +4332,7 @@ func TestWaitForFollowupMachineKeyMismatch(t *testing.T) {
|
|||||||
NodeKey: key.NewNode().Public(),
|
NodeKey: key.NewNode().Public(),
|
||||||
Hostname: hostname,
|
Hostname: hostname,
|
||||||
})
|
})
|
||||||
app.state.SetAuthCacheEntry(authID, regEntry)
|
require.NoError(t, app.state.SetAuthCacheEntry(authID, regEntry))
|
||||||
|
|
||||||
user := app.state.CreateUserForTest(hostname + "-user")
|
user := app.state.CreateUserForTest(hostname + "-user")
|
||||||
node := app.state.CreateNodeForTest(user, hostname)
|
node := app.state.CreateNodeForTest(user, hostname)
|
||||||
@@ -4303,7 +4409,7 @@ func TestWaitForFollowupMachineKeyMismatch(t *testing.T) {
|
|||||||
NodeKey: key.NewNode().Public(),
|
NodeKey: key.NewNode().Public(),
|
||||||
Hostname: "followup-pending",
|
Hostname: "followup-pending",
|
||||||
})
|
})
|
||||||
app.state.SetAuthCacheEntry(authID, regEntry)
|
require.NoError(t, app.state.SetAuthCacheEntry(authID, regEntry))
|
||||||
|
|
||||||
url := fmt.Sprintf("http://localhost:8080/register/%s", authID)
|
url := fmt.Sprintf("http://localhost:8080/register/%s", authID)
|
||||||
refusedAtOnce(t, url, foreignMachineKey.Public())
|
refusedAtOnce(t, url, foreignMachineKey.Public())
|
||||||
@@ -4322,7 +4428,7 @@ func TestWaitForFollowupMachineKeyMismatch(t *testing.T) {
|
|||||||
|
|
||||||
t.Run("ssh check session is not a registration", func(t *testing.T) {
|
t.Run("ssh check session is not a registration", func(t *testing.T) {
|
||||||
authID := types.MustAuthID()
|
authID := types.MustAuthID()
|
||||||
app.state.SetAuthCacheEntry(authID, types.NewSSHCheckAuthRequest(1, 2))
|
require.NoError(t, app.state.SetAuthCacheEntry(authID, types.NewSSHCheckAuthRequest(1, 2)))
|
||||||
|
|
||||||
refusedAtOnce(t, fmt.Sprintf("http://localhost:8080/register/%s", authID),
|
refusedAtOnce(t, fmt.Sprintf("http://localhost:8080/register/%s", authID),
|
||||||
victimMachineKey.Public())
|
victimMachineKey.Public())
|
||||||
@@ -4366,7 +4472,7 @@ func TestFollowupWaitPrefersCompletedAuthOverExpiredContext(t *testing.T) {
|
|||||||
MachineKey: machineKey,
|
MachineKey: machineKey,
|
||||||
Hostname: "followup-race-node",
|
Hostname: "followup-race-node",
|
||||||
})
|
})
|
||||||
app.state.SetAuthCacheEntry(regID, authReq)
|
require.NoError(t, app.state.SetAuthCacheEntry(regID, authReq))
|
||||||
|
|
||||||
// Registration completes BEFORE we wait: verdict is buffered.
|
// Registration completes BEFORE we wait: verdict is buffered.
|
||||||
user := app.state.CreateUserForTest(fmt.Sprintf("followup-race-user-%d", i))
|
user := app.state.CreateUserForTest(fmt.Sprintf("followup-race-user-%d", i))
|
||||||
@@ -4494,7 +4600,7 @@ func TestFollowupParksOneRequestPerRegistration(t *testing.T) {
|
|||||||
MachineKey: machineKey,
|
MachineKey: machineKey,
|
||||||
Hostname: "followup-park-node",
|
Hostname: "followup-park-node",
|
||||||
})
|
})
|
||||||
app.state.SetAuthCacheEntry(authID, reg)
|
require.NoError(t, app.state.SetAuthCacheEntry(authID, reg))
|
||||||
|
|
||||||
regData := &types.RegistrationData{
|
regData := &types.RegistrationData{
|
||||||
MachineKey: machineKey,
|
MachineKey: machineKey,
|
||||||
@@ -4616,7 +4722,7 @@ func TestFollowupRefreshKeepsAuthMetadata(t *testing.T) {
|
|||||||
reg := types.NewRegisterAuthRequest(&types.RegistrationData{
|
reg := types.NewRegisterAuthRequest(&types.RegistrationData{
|
||||||
MachineKey: machineKey, Hostname: "previous-request",
|
MachineKey: machineKey, Hostname: "previous-request",
|
||||||
})
|
})
|
||||||
app.state.SetAuthCacheEntry(authID, reg)
|
require.NoError(t, app.state.SetAuthCacheEntry(authID, reg))
|
||||||
reg.FinishAuth(types.AuthVerdict{})
|
reg.FinishAuth(types.AuthVerdict{})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+12
-1
@@ -854,7 +854,18 @@ func (api headscaleV1APIServer) DebugCreateNode(
|
|||||||
Msg("adding debug machine via CLI, appending to registration cache")
|
Msg("adding debug machine via CLI, appending to registration cache")
|
||||||
|
|
||||||
authRegReq := types.NewRegisterAuthRequest(regData)
|
authRegReq := types.NewRegisterAuthRequest(regData)
|
||||||
api.h.state.SetAuthCacheEntry(registrationId, authRegReq)
|
|
||||||
|
err = api.h.state.SetAuthCacheEntry(registrationId, authRegReq)
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, state.ErrAuthCacheFull), errors.Is(err, state.ErrAuthCacheClosed):
|
||||||
|
return nil, status.Error(codes.Unavailable, err.Error())
|
||||||
|
case errors.Is(err, state.ErrAuthCacheEntryExists):
|
||||||
|
return nil, status.Error(codes.AlreadyExists, err.Error())
|
||||||
|
case errors.Is(err, types.ErrRegistrationPayloadTooLarge):
|
||||||
|
return nil, status.Error(codes.InvalidArgument, err.Error())
|
||||||
|
case err != nil:
|
||||||
|
return nil, fmt.Errorf("starting authentication: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
// Echo back a synthetic [types.Node] so the debug response surface stays
|
// Echo back a synthetic [types.Node] so the debug response surface stays
|
||||||
// stable. The actual node is created later by [headscaleV1APIServer.AuthApprove] via
|
// stable. The actual node is created later by [headscaleV1APIServer.AuthApprove] via
|
||||||
|
|||||||
@@ -851,3 +851,22 @@ func TestExpireNode_RefusesUnencodableExpiry(t *testing.T) {
|
|||||||
})
|
})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestDebugCreateNode_AdmissionFailure(t *testing.T) {
|
||||||
|
app := createTestApp(t, func(cfg *types.Config) {
|
||||||
|
cfg.Tuning.RegisterCacheMaxEntries = 1
|
||||||
|
})
|
||||||
|
defer func() { require.NoError(t, app.state.Close()) }()
|
||||||
|
|
||||||
|
app.state.CreateUserForTest("alice")
|
||||||
|
require.NoError(t, app.state.SetAuthCacheEntry(types.MustAuthID(), types.NewAuthRequest()))
|
||||||
|
|
||||||
|
id := types.MustAuthID()
|
||||||
|
_, err := newHeadscaleV1APIServer(app).DebugCreateNode(t.Context(), &v1.DebugCreateNodeRequest{
|
||||||
|
User: "alice", Key: id.String(), Name: "node",
|
||||||
|
})
|
||||||
|
require.Equal(t, codes.Unavailable, status.Code(err))
|
||||||
|
|
||||||
|
_, ok := app.state.GetAuthCacheEntry(id)
|
||||||
|
require.False(t, ok)
|
||||||
|
}
|
||||||
+15
-2
@@ -588,10 +588,13 @@ func (ns *noiseServer) sshActionHoldAndDelegate(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
ns.headscale.state.SetAuthCacheEntry(
|
err = ns.headscale.state.SetAuthCacheEntry(
|
||||||
authID,
|
authID,
|
||||||
types.NewSSHCheckAuthRequest(srcNodeID, dstNodeID),
|
types.NewSSHCheckAuthRequest(srcNodeID, dstNodeID),
|
||||||
)
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, authAdmissionError(err)
|
||||||
|
}
|
||||||
|
|
||||||
authURL := ns.headscale.authProvider.AuthURL(authID)
|
authURL := ns.headscale.authProvider.AuthURL(authID)
|
||||||
|
|
||||||
@@ -713,6 +716,8 @@ func (ns *noiseServer) sshActionFollowUp(
|
|||||||
case verdict, verdictOK = <-auth.WaitForAuth():
|
case verdict, verdictOK = <-auth.WaitForAuth():
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ns.headscale.state.DeleteAuthCacheEntry(authID)
|
||||||
|
|
||||||
// FinishAuth buffers one verdict, then closes the channel. A closed
|
// FinishAuth buffers one verdict, then closes the channel. A closed
|
||||||
// receive yields the zero verdict, which Accept() reports as success,
|
// receive yields the zero verdict, which Accept() reports as success,
|
||||||
// so a replayed follow-up would be approved even after a Reject.
|
// so a replayed follow-up would be approved even after a Reject.
|
||||||
@@ -869,6 +874,7 @@ func (ns *noiseServer) RegistrationHandler(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
statusCode := http.StatusOK
|
||||||
registerRequest, registerResponse := func() (*tailcfg.RegisterRequest, *tailcfg.RegisterResponse) { //nolint:contextcheck
|
registerRequest, registerResponse := func() (*tailcfg.RegisterRequest, *tailcfg.RegisterResponse) { //nolint:contextcheck
|
||||||
var resp *tailcfg.RegisterResponse
|
var resp *tailcfg.RegisterResponse
|
||||||
|
|
||||||
@@ -918,6 +924,13 @@ func (ns *noiseServer) RegistrationHandler(
|
|||||||
resp, err = ns.headscale.handleRegister(req.Context(), regReq, ns.machineKey)
|
resp, err = ns.headscale.handleRegister(req.Context(), regReq, ns.machineKey)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if httpErr, ok := errors.AsType[HTTPError](err); ok {
|
if httpErr, ok := errors.AsType[HTTPError](err); ok {
|
||||||
|
// Keep permanent errors in HTTP 200 so controlclient surfaces RegisterResponse.Error.
|
||||||
|
if httpErr.Code == http.StatusServiceUnavailable {
|
||||||
|
statusCode = http.StatusServiceUnavailable
|
||||||
|
|
||||||
|
writer.Header().Set("Retry-After", "10")
|
||||||
|
}
|
||||||
|
|
||||||
resp = &tailcfg.RegisterResponse{
|
resp = &tailcfg.RegisterResponse{
|
||||||
Error: httpErr.Msg,
|
Error: httpErr.Msg,
|
||||||
}
|
}
|
||||||
@@ -937,7 +950,7 @@ func (ns *noiseServer) RegistrationHandler(
|
|||||||
}
|
}
|
||||||
|
|
||||||
writer.Header().Set("Content-Type", "application/json; charset=utf-8")
|
writer.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||||
writer.WriteHeader(http.StatusOK)
|
writer.WriteHeader(statusCode)
|
||||||
|
|
||||||
err := json.NewEncoder(writer).Encode(registerResponse)
|
err := json.NewEncoder(writer).Encode(registerResponse)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
+70
-5
@@ -22,6 +22,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
|
"github.com/juanfont/headscale/hscontrol/capver"
|
||||||
"github.com/juanfont/headscale/hscontrol/types"
|
"github.com/juanfont/headscale/hscontrol/types"
|
||||||
"github.com/juanfont/headscale/hscontrol/util"
|
"github.com/juanfont/headscale/hscontrol/util"
|
||||||
"github.com/rs/zerolog"
|
"github.com/rs/zerolog"
|
||||||
@@ -513,10 +514,10 @@ func TestSSHActionFollowUp_RejectsBindingMismatch(t *testing.T) {
|
|||||||
|
|
||||||
// Mint an SSH-check auth request bound to (srcCached, dstCached).
|
// Mint an SSH-check auth request bound to (srcCached, dstCached).
|
||||||
authID := types.MustAuthID()
|
authID := types.MustAuthID()
|
||||||
app.state.SetAuthCacheEntry(
|
require.NoError(t, app.state.SetAuthCacheEntry(
|
||||||
authID,
|
authID,
|
||||||
types.NewSSHCheckAuthRequest(srcCached.ID, dstCached.ID),
|
types.NewSSHCheckAuthRequest(srcCached.ID, dstCached.ID),
|
||||||
)
|
))
|
||||||
|
|
||||||
// Build a follow-up that claims to be for (srcOther, dstOther) but
|
// Build a follow-up that claims to be for (srcOther, dstOther) but
|
||||||
// reuses the bound auth_id. The Noise machineKey matches dstOther so
|
// reuses the bound auth_id. The Noise machineKey matches dstOther so
|
||||||
@@ -673,7 +674,7 @@ func TestSSHActionFollowUp_RejectsVerdictAfterCheckRemoved(t *testing.T) {
|
|||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
authID := types.MustAuthID()
|
authID := types.MustAuthID()
|
||||||
app.state.SetAuthCacheEntry(authID, types.NewSSHCheckAuthRequest(src.ID, dst.ID))
|
require.NoError(t, app.state.SetAuthCacheEntry(authID, types.NewSSHCheckAuthRequest(src.ID, dst.ID)))
|
||||||
|
|
||||||
auth, ok := app.state.GetAuthCacheEntry(authID)
|
auth, ok := app.state.GetAuthCacheEntry(authID)
|
||||||
require.True(t, ok)
|
require.True(t, ok)
|
||||||
@@ -1404,7 +1405,8 @@ func TestPollNetMapHandler_UnknownNodeBeforeReportValidation(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// serveRegister guards against panics so a handler that reaches a nil
|
// serveRegister guards against panics so a handler that reaches a nil
|
||||||
// dependency fails its own row instead of the whole test binary.
|
// dependency fails its own row instead of the whole test binary. body is
|
||||||
|
// any so a [json.RawMessage] can carry a request that fails to decode.
|
||||||
func serveRegister(t *testing.T, ns *noiseServer, body any) *httptest.ResponseRecorder {
|
func serveRegister(t *testing.T, ns *noiseServer, body any) *httptest.ResponseRecorder {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
@@ -1745,7 +1747,7 @@ func TestSSHActionFollowUp_ParksOneRequestPerSession(t *testing.T) {
|
|||||||
|
|
||||||
authID := types.MustAuthID()
|
authID := types.MustAuthID()
|
||||||
auth := types.NewSSHCheckAuthRequest(src.ID, dst.ID)
|
auth := types.NewSSHCheckAuthRequest(src.ID, dst.ID)
|
||||||
app.state.SetAuthCacheEntry(authID, auth)
|
require.NoError(t, app.state.SetAuthCacheEntry(authID, auth))
|
||||||
|
|
||||||
ns := &noiseServer{headscale: app, machineKey: dst.MachineKey}
|
ns := &noiseServer{headscale: app, machineKey: dst.MachineKey}
|
||||||
|
|
||||||
@@ -1800,3 +1802,66 @@ func TestSSHActionFollowUp_ParksOneRequestPerSession(t *testing.T) {
|
|||||||
require.NoError(t, last.err)
|
require.NoError(t, last.err)
|
||||||
assert.True(t, last.action.Accept, "accept, got %+v", last.action)
|
assert.True(t, last.action.Accept, "accept, got %+v", last.action)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestRegistrationHandler_AdmissionRetry(t *testing.T) {
|
||||||
|
app := createTestApp(t, func(cfg *types.Config) {
|
||||||
|
cfg.Tuning.RegisterCacheMaxEntries = 1
|
||||||
|
})
|
||||||
|
defer func() { require.NoError(t, app.state.Close()) }()
|
||||||
|
|
||||||
|
require.NoError(t, app.state.SetAuthCacheEntry(types.MustAuthID(), types.NewAuthRequest()))
|
||||||
|
|
||||||
|
ns := &noiseServer{headscale: app, machineKey: key.NewMachine().Public()}
|
||||||
|
rec := serveRegister(t, ns, tailcfg.RegisterRequest{
|
||||||
|
Version: capver.MinSupportedCapabilityVersion,
|
||||||
|
NodeKey: key.NewNode().Public(),
|
||||||
|
Hostinfo: &tailcfg.Hostinfo{Hostname: "node"},
|
||||||
|
})
|
||||||
|
require.Equal(t, http.StatusServiceUnavailable, rec.Code)
|
||||||
|
require.NotEmpty(t, rec.Header().Get("Retry-After"))
|
||||||
|
|
||||||
|
var response tailcfg.RegisterResponse
|
||||||
|
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &response))
|
||||||
|
require.Empty(t, response.AuthURL)
|
||||||
|
require.NotEmpty(t, response.Error)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRegistrationHandler_RegistrationPayloadLimit(t *testing.T) {
|
||||||
|
app := createTestApp(t)
|
||||||
|
defer func() { require.NoError(t, app.state.Close()) }()
|
||||||
|
|
||||||
|
hostinfo := &tailcfg.Hostinfo{Hostname: "node", OS: strings.Repeat("x", types.MaxHostinfoSize-128)}
|
||||||
|
require.NoError(t, types.ValidateHostinfo(hostinfo), "the full registration must reach cache admission")
|
||||||
|
|
||||||
|
ns := &noiseServer{headscale: app, machineKey: key.NewMachine().Public()}
|
||||||
|
rec := serveRegister(t, ns, tailcfg.RegisterRequest{
|
||||||
|
Version: capver.MinSupportedCapabilityVersion,
|
||||||
|
NodeKey: key.NewNode().Public(),
|
||||||
|
Hostinfo: hostinfo,
|
||||||
|
})
|
||||||
|
require.Equal(t, http.StatusOK, rec.Code, "controlclient only surfaces RegisterResponse.Error with HTTP 200")
|
||||||
|
require.Empty(t, rec.Header().Get("Retry-After"))
|
||||||
|
|
||||||
|
var response tailcfg.RegisterResponse
|
||||||
|
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &response))
|
||||||
|
require.Contains(t, response.Error, types.ErrRegistrationPayloadTooLarge.Error())
|
||||||
|
require.Contains(t, response.Error, strconv.Itoa(types.MaxRegistrationPayloadBytes))
|
||||||
|
require.Empty(t, response.AuthURL)
|
||||||
|
require.False(t, response.MachineAuthorized)
|
||||||
|
require.Zero(t, app.state.DebugRegistrationCache()["current_len"], "oversized registrations must not enter the auth cache")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSSHActionFollowUp_ReleasesConsumedSession(t *testing.T) {
|
||||||
|
f := newSSHVerdictFixture(t)
|
||||||
|
authID, auth := f.mint(t)
|
||||||
|
auth.FinishAuth(types.AuthVerdict{})
|
||||||
|
|
||||||
|
_, ok := f.ns.headscale.state.GetAuthCacheEntry(authID)
|
||||||
|
require.True(t, ok, "approval must remain available until the client consumes it")
|
||||||
|
|
||||||
|
action := sshActionFromRecorder(t, f.followUp(t, authID))
|
||||||
|
require.True(t, action.Accept)
|
||||||
|
|
||||||
|
_, ok = f.ns.headscale.state.GetAuthCacheEntry(authID)
|
||||||
|
require.False(t, ok, "a consumed verdict must release its admission slot")
|
||||||
|
}
|
||||||
+113
-57
@@ -10,6 +10,8 @@ import (
|
|||||||
"net/url"
|
"net/url"
|
||||||
"slices"
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/coreos/go-oidc/v3/oidc"
|
"github.com/coreos/go-oidc/v3/oidc"
|
||||||
@@ -29,10 +31,10 @@ const (
|
|||||||
defaultOAuthOptionsCount = 3
|
defaultOAuthOptionsCount = 3
|
||||||
authCacheExpiration = time.Minute * 15
|
authCacheExpiration = time.Minute * 15
|
||||||
|
|
||||||
// authCacheMaxEntries bounds the OIDC state→[AuthInfo] cache to prevent
|
// Registrations may use authCacheMaxEntries slots; SSH checks may also use
|
||||||
// unauthenticated cache-fill DoS via repeated /register/{auth_id} or
|
// the reserve so new registrations cannot fill all pending OIDC capacity.
|
||||||
// /auth/{auth_id} GETs that mint OIDC state cookies.
|
|
||||||
authCacheMaxEntries = 1024
|
authCacheMaxEntries = 1024
|
||||||
|
authCacheSSHReserve = 64
|
||||||
|
|
||||||
// cookieNamePrefixLen is the number of leading characters from a
|
// cookieNamePrefixLen is the number of leading characters from a
|
||||||
// state/nonce value that [getCookieName] splices into the cookie name.
|
// state/nonce value that [getCookieName] splices into the cookie name.
|
||||||
@@ -47,7 +49,6 @@ var errOIDCStateTooShort = errors.New("oidc state parameter is too short")
|
|||||||
var (
|
var (
|
||||||
errEmptyOIDCCallbackParams = errors.New("empty OIDC callback params")
|
errEmptyOIDCCallbackParams = errors.New("empty OIDC callback params")
|
||||||
errNoOIDCIDToken = errors.New("extracting ID token")
|
errNoOIDCIDToken = errors.New("extracting ID token")
|
||||||
errNoOIDCRegistrationInfo = errors.New("registration info not in cache")
|
|
||||||
errOIDCAllowedDomains = errors.New(
|
errOIDCAllowedDomains = errors.New(
|
||||||
"authenticated principal does not match any allowed domain",
|
"authenticated principal does not match any allowed domain",
|
||||||
)
|
)
|
||||||
@@ -70,10 +71,11 @@ type AuthProviderOIDC struct {
|
|||||||
serverURL string
|
serverURL string
|
||||||
cfg *types.OIDCConfig
|
cfg *types.OIDCConfig
|
||||||
|
|
||||||
// authCache holds auth information between the auth and the callback
|
// authCacheMu serializes non-evicting admission and single-use consumption.
|
||||||
// steps. It is a bounded [expirable.LRU] keyed by OIDC state, evicting oldest
|
// Reads use Peek to preserve insertion order, so the oldest entry expires first.
|
||||||
// entries to keep the cache footprint constant under attack.
|
authCacheMu sync.Mutex
|
||||||
authCache *expirable.LRU[string, AuthInfo]
|
authCacheRegistrations atomic.Int64
|
||||||
|
authCache *expirable.LRU[string, AuthInfo]
|
||||||
|
|
||||||
oidcProvider *oidc.Provider
|
oidcProvider *oidc.Provider
|
||||||
oauth2Config *oauth2.Config
|
oauth2Config *oauth2.Config
|
||||||
@@ -100,21 +102,25 @@ func NewAuthProviderOIDC(
|
|||||||
Scopes: cfg.Scope,
|
Scopes: cfg.Scope,
|
||||||
}
|
}
|
||||||
|
|
||||||
authCache := expirable.NewLRU[string, AuthInfo](
|
a := &AuthProviderOIDC{
|
||||||
authCacheMaxEntries,
|
|
||||||
nil,
|
|
||||||
authCacheExpiration,
|
|
||||||
)
|
|
||||||
|
|
||||||
return &AuthProviderOIDC{
|
|
||||||
h: h,
|
h: h,
|
||||||
serverURL: serverURL,
|
serverURL: serverURL,
|
||||||
cfg: cfg,
|
cfg: cfg,
|
||||||
authCache: authCache,
|
|
||||||
|
|
||||||
oidcProvider: oidcProvider,
|
oidcProvider: oidcProvider,
|
||||||
oauth2Config: oauth2Config,
|
oauth2Config: oauth2Config,
|
||||||
}, nil
|
}
|
||||||
|
a.authCache = expirable.NewLRU[string, AuthInfo](
|
||||||
|
authCacheMaxEntries+authCacheSSHReserve,
|
||||||
|
func(_ string, info AuthInfo) {
|
||||||
|
if info.Registration {
|
||||||
|
a.authCacheRegistrations.Add(-1)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
authCacheExpiration,
|
||||||
|
)
|
||||||
|
|
||||||
|
return a, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// cookiesSecure reports whether the OIDC cookies should carry the Secure flag.
|
// cookiesSecure reports whether the OIDC cookies should carry the Secure flag.
|
||||||
@@ -182,11 +188,24 @@ func (a *AuthProviderOIDC) authHandler(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set the state and nonce cookies to protect against CSRF attacks
|
authReq, ok := a.h.state.GetAuthCacheEntry(authID)
|
||||||
state := a.setCSRFCookie(writer, req, "state")
|
if !ok {
|
||||||
|
httpUserError(writer, NewHTTPError(http.StatusGone, "login session expired, try again", nil))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// Set the state and nonce cookies to protect against CSRF attacks
|
if registration && !authReq.IsRegistration() {
|
||||||
nonce := a.setCSRFCookie(writer, req, "nonce")
|
httpUserError(writer, NewHTTPError(http.StatusBadRequest, "auth session is not for node registration", nil))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if !registration && !authReq.IsSSHCheck() {
|
||||||
|
httpUserError(writer, NewHTTPError(http.StatusBadRequest, "auth session is not for SSH check", nil))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
state := rands.HexString(64)
|
||||||
|
nonce := rands.HexString(64)
|
||||||
|
|
||||||
registrationInfo := AuthInfo{
|
registrationInfo := AuthInfo{
|
||||||
AuthID: authID,
|
AuthID: authID,
|
||||||
@@ -217,8 +236,15 @@ func (a *AuthProviderOIDC) authHandler(
|
|||||||
|
|
||||||
extras = append(extras, oidc.Nonce(nonce))
|
extras = append(extras, oidc.Nonce(nonce))
|
||||||
|
|
||||||
// Cache the registration info
|
if !a.addAuthInfoToState(state, registrationInfo) {
|
||||||
a.authCache.Add(state, registrationInfo)
|
writer.Header().Set("Retry-After", "10")
|
||||||
|
httpUserError(writer, NewHTTPError(http.StatusServiceUnavailable, "too many pending OIDC logins, try again later", nil))
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
a.setCSRFCookie(writer, req, "state", state)
|
||||||
|
a.setCSRFCookie(writer, req, "nonce", nonce)
|
||||||
|
|
||||||
authURL := a.oauth2Config.AuthCodeURL(state, extras...)
|
authURL := a.oauth2Config.AuthCodeURL(state, extras...)
|
||||||
log.Debug().Caller().Msgf("redirecting to %s for authentication", authURL)
|
log.Debug().Caller().Msgf("redirecting to %s for authentication", authURL)
|
||||||
@@ -254,7 +280,20 @@ func (a *AuthProviderOIDC) OIDCCallbackHandler(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
oauth2Token, err := a.getOauth2Token(req.Context(), code, state)
|
// A cookie-bound callback spends its state even if authentication fails;
|
||||||
|
// retrying then requires a fresh login flow rather than reusing the callback.
|
||||||
|
authInfo := a.getAuthInfoFromState(state)
|
||||||
|
if authInfo == nil {
|
||||||
|
httpUserError(writer, NewHTTPError(http.StatusGone, "login session expired, try again", nil))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.URL.Query().Get("error") != "" {
|
||||||
|
httpUserError(writer, NewHTTPError(http.StatusForbidden, "OIDC authentication failed", nil))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
oauth2Token, err := a.getOauth2Token(req.Context(), code, authInfo)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
httpUserError(writer, err)
|
httpUserError(writer, err)
|
||||||
return
|
return
|
||||||
@@ -344,18 +383,6 @@ func (a *AuthProviderOIDC) OIDCCallbackHandler(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO(kradalby): Is this comment right?
|
|
||||||
// If the node exists, then the node should be reauthenticated,
|
|
||||||
// if the node does not exist, and the machine key exists, then
|
|
||||||
// this is a new node that should be registered.
|
|
||||||
authInfo := a.getAuthInfoFromState(state)
|
|
||||||
if authInfo == nil {
|
|
||||||
log.Debug().Caller().Str("state", state).Msg("state not found in cache, login session may have expired")
|
|
||||||
httpUserError(writer, NewHTTPError(http.StatusGone, "login session expired, try again", nil))
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// If this is a registration flow, render the confirmation
|
// If this is a registration flow, render the confirmation
|
||||||
// interstitial instead of finalising the registration immediately.
|
// interstitial instead of finalising the registration immediately.
|
||||||
// Without an explicit user click, a single GET to
|
// Without an explicit user click, a single GET to
|
||||||
@@ -472,10 +499,11 @@ func (a *AuthProviderOIDC) determineNodeExpiry(idTokenExpiration time.Time) *tim
|
|||||||
func extractCodeAndStateParamFromRequest(
|
func extractCodeAndStateParamFromRequest(
|
||||||
req *http.Request,
|
req *http.Request,
|
||||||
) (string, string, error) {
|
) (string, string, error) {
|
||||||
code := req.URL.Query().Get("code")
|
query := req.URL.Query()
|
||||||
state := req.URL.Query().Get("state")
|
code := query.Get("code")
|
||||||
|
state := query.Get("state")
|
||||||
|
|
||||||
if code == "" || state == "" {
|
if (code == "" && query.Get("error") == "") || state == "" {
|
||||||
return "", "", NewHTTPError(http.StatusBadRequest, "missing code or state parameter", errEmptyOIDCCallbackParams)
|
return "", "", NewHTTPError(http.StatusBadRequest, "missing code or state parameter", errEmptyOIDCCallbackParams)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -494,19 +522,12 @@ func extractCodeAndStateParamFromRequest(
|
|||||||
func (a *AuthProviderOIDC) getOauth2Token(
|
func (a *AuthProviderOIDC) getOauth2Token(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
code string,
|
code string,
|
||||||
state string,
|
authInfo *AuthInfo,
|
||||||
) (*oauth2.Token, error) {
|
) (*oauth2.Token, error) {
|
||||||
var exchangeOpts []oauth2.AuthCodeOption
|
var exchangeOpts []oauth2.AuthCodeOption
|
||||||
|
|
||||||
if a.cfg.PKCE.Enabled {
|
if a.cfg.PKCE.Enabled && authInfo.Verifier != nil {
|
||||||
regInfo, ok := a.authCache.Get(state)
|
exchangeOpts = []oauth2.AuthCodeOption{oauth2.VerifierOption(*authInfo.Verifier)}
|
||||||
if !ok {
|
|
||||||
return nil, NewHTTPError(http.StatusNotFound, "registration not found", errNoOIDCRegistrationInfo)
|
|
||||||
}
|
|
||||||
|
|
||||||
if regInfo.Verifier != nil {
|
|
||||||
exchangeOpts = []oauth2.AuthCodeOption{oauth2.VerifierOption(*regInfo.Verifier)}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
oauth2Token, err := a.oauth2Config.Exchange(ctx, code, exchangeOpts...)
|
oauth2Token, err := a.oauth2Config.Exchange(ctx, code, exchangeOpts...)
|
||||||
@@ -632,17 +653,55 @@ func doOIDCAuthorization(
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (a *AuthProviderOIDC) addAuthInfoToState(state string, info AuthInfo) bool {
|
||||||
|
a.authCacheMu.Lock()
|
||||||
|
defer a.authCacheMu.Unlock()
|
||||||
|
|
||||||
|
// Len includes expired entries until the sweeper removes their bucket.
|
||||||
|
for {
|
||||||
|
oldest, _, ok := a.authCache.GetOldest()
|
||||||
|
if !ok {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, live := a.authCache.Peek(oldest); live {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
a.authCache.Remove(oldest)
|
||||||
|
}
|
||||||
|
|
||||||
|
if a.authCache.Contains(state) || a.authCache.Len() >= a.authCache.Cap() {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if info.Registration && a.authCacheRegistrations.Load() >= int64(a.authCache.Cap()-authCacheSSHReserve) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Increment before Add: an expiry callback may run as soon as it is stored.
|
||||||
|
if info.Registration {
|
||||||
|
a.authCacheRegistrations.Add(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
a.authCache.Add(state, info)
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
// getAuthInfoFromState retrieves and consumes the auth info for a state. The
|
// getAuthInfoFromState retrieves and consumes the auth info for a state. The
|
||||||
// entry is removed on read so a state is single-use: a replayed callback cannot
|
// entry is removed on read so a state is single-use: a replayed callback cannot
|
||||||
// resolve the same auth session twice, even within the cache TTL.
|
// resolve the same auth session twice, even within the cache TTL.
|
||||||
func (a *AuthProviderOIDC) getAuthInfoFromState(state string) *AuthInfo {
|
func (a *AuthProviderOIDC) getAuthInfoFromState(state string) *AuthInfo {
|
||||||
authInfo, ok := a.authCache.Get(state)
|
a.authCacheMu.Lock()
|
||||||
|
defer a.authCacheMu.Unlock()
|
||||||
|
|
||||||
|
authInfo, ok := a.authCache.Peek(state)
|
||||||
|
a.authCache.Remove(state)
|
||||||
if !ok {
|
if !ok {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
a.authCache.Remove(state)
|
|
||||||
|
|
||||||
return &authInfo
|
return &authInfo
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1104,9 +1163,8 @@ func (a *AuthProviderOIDC) setCSRFCookie(
|
|||||||
w http.ResponseWriter,
|
w http.ResponseWriter,
|
||||||
r *http.Request,
|
r *http.Request,
|
||||||
name string,
|
name string,
|
||||||
) string {
|
val string,
|
||||||
val := rands.HexString(64)
|
) {
|
||||||
|
|
||||||
//nolint:gosec // G124: Secure from server_url scheme or req.TLS; HttpOnly + SameSite set below
|
//nolint:gosec // G124: Secure from server_url scheme or req.TLS; HttpOnly + SameSite set below
|
||||||
c := &http.Cookie{
|
c := &http.Cookie{
|
||||||
Path: a.oidcCallbackPath(),
|
Path: a.oidcCallbackPath(),
|
||||||
@@ -1123,6 +1181,4 @@ func (a *AuthProviderOIDC) setCSRFCookie(
|
|||||||
SameSite: http.SameSiteLaxMode,
|
SameSite: http.SameSiteLaxMode,
|
||||||
}
|
}
|
||||||
http.SetCookie(w, c)
|
http.SetCookie(w, c)
|
||||||
|
|
||||||
return val
|
|
||||||
}
|
}
|
||||||
@@ -57,7 +57,7 @@ func TestRegisterConfirmHandler_RejectsCSRFMismatch(t *testing.T) {
|
|||||||
UserID: 1,
|
UserID: 1,
|
||||||
CSRF: "expected-csrf",
|
CSRF: "expected-csrf",
|
||||||
})
|
})
|
||||||
app.state.SetAuthCacheEntry(authID, regReq)
|
require.NoError(t, app.state.SetAuthCacheEntry(authID, regReq))
|
||||||
|
|
||||||
rec := httptest.NewRecorder()
|
rec := httptest.NewRecorder()
|
||||||
provider.RegisterConfirmHandler(rec,
|
provider.RegisterConfirmHandler(rec,
|
||||||
@@ -89,9 +89,9 @@ func TestRegisterConfirmHandler_RejectsWithoutPending(t *testing.T) {
|
|||||||
authID := types.MustAuthID()
|
authID := types.MustAuthID()
|
||||||
// Cached registration with NO pending confirmation set — i.e. the
|
// Cached registration with NO pending confirmation set — i.e. the
|
||||||
// OIDC callback has not run yet.
|
// OIDC callback has not run yet.
|
||||||
app.state.SetAuthCacheEntry(authID, types.NewRegisterAuthRequest(
|
require.NoError(t, app.state.SetAuthCacheEntry(authID, types.NewRegisterAuthRequest(
|
||||||
&types.RegistrationData{Hostname: "no-oidc-yet"},
|
&types.RegistrationData{Hostname: "no-oidc-yet"},
|
||||||
))
|
)))
|
||||||
|
|
||||||
rec := httptest.NewRecorder()
|
rec := httptest.NewRecorder()
|
||||||
provider.RegisterConfirmHandler(rec,
|
provider.RegisterConfirmHandler(rec,
|
||||||
|
|||||||
+744
-5
@@ -3,6 +3,7 @@ package hscontrol
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/cookiejar"
|
"net/http/cookiejar"
|
||||||
@@ -10,15 +11,20 @@ import (
|
|||||||
"net/url"
|
"net/url"
|
||||||
"regexp"
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
"testing"
|
"testing"
|
||||||
|
"testing/synctest"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
grpcRuntime "github.com/grpc-ecosystem/grpc-gateway/v2/runtime"
|
grpcRuntime "github.com/grpc-ecosystem/grpc-gateway/v2/runtime"
|
||||||
"github.com/hashicorp/golang-lru/v2/expirable"
|
"github.com/hashicorp/golang-lru/v2/expirable"
|
||||||
"github.com/juanfont/headscale/hscontrol/types"
|
"github.com/juanfont/headscale/hscontrol/types"
|
||||||
"github.com/oauth2-proxy/mockoidc"
|
"github.com/oauth2-proxy/mockoidc"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
|
"golang.org/x/oauth2"
|
||||||
"tailscale.com/tailcfg"
|
"tailscale.com/tailcfg"
|
||||||
"tailscale.com/types/key"
|
"tailscale.com/types/key"
|
||||||
)
|
)
|
||||||
@@ -200,7 +206,7 @@ func TestSetCSRFCookieSameSite(t *testing.T) {
|
|||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
r := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/auth/abcdef0123456789", nil)
|
r := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/auth/abcdef0123456789", nil)
|
||||||
|
|
||||||
a.setCSRFCookie(w, r, "state")
|
a.setCSRFCookie(w, r, "state", "state-value")
|
||||||
|
|
||||||
cookies := w.Result().Cookies()
|
cookies := w.Result().Cookies()
|
||||||
require.Len(t, cookies, 1)
|
require.Len(t, cookies, 1)
|
||||||
@@ -266,14 +272,14 @@ func TestSetCSRFCookieSecure(t *testing.T) {
|
|||||||
|
|
||||||
secureRec := httptest.NewRecorder()
|
secureRec := httptest.NewRecorder()
|
||||||
secureProvider := &AuthProviderOIDC{serverURL: "https://hs.example.com"}
|
secureProvider := &AuthProviderOIDC{serverURL: "https://hs.example.com"}
|
||||||
secureProvider.setCSRFCookie(secureRec, r, "state")
|
secureProvider.setCSRFCookie(secureRec, r, "state", "state-value")
|
||||||
require.Len(t, secureRec.Result().Cookies(), 1)
|
require.Len(t, secureRec.Result().Cookies(), 1)
|
||||||
assert.True(t, secureRec.Result().Cookies()[0].Secure,
|
assert.True(t, secureRec.Result().Cookies()[0].Secure,
|
||||||
"https server_url must set Secure even when req.TLS is nil (proxy case)")
|
"https server_url must set Secure even when req.TLS is nil (proxy case)")
|
||||||
|
|
||||||
plainRec := httptest.NewRecorder()
|
plainRec := httptest.NewRecorder()
|
||||||
plainProvider := &AuthProviderOIDC{serverURL: "http://hs.example.com"}
|
plainProvider := &AuthProviderOIDC{serverURL: "http://hs.example.com"}
|
||||||
plainProvider.setCSRFCookie(plainRec, r, "state")
|
plainProvider.setCSRFCookie(plainRec, r, "state", "state-value")
|
||||||
require.Len(t, plainRec.Result().Cookies(), 1)
|
require.Len(t, plainRec.Result().Cookies(), 1)
|
||||||
assert.False(t, plainRec.Result().Cookies()[0].Secure,
|
assert.False(t, plainRec.Result().Cookies()[0].Secure,
|
||||||
"plain-http server_url without req.TLS must not set Secure")
|
"plain-http server_url without req.TLS must not set Secure")
|
||||||
@@ -638,10 +644,10 @@ func TestOIDCRegistrationRejectsOversizedMergedHostinfo(t *testing.T) {
|
|||||||
|
|
||||||
hostname := strings.Repeat("h", types.MaxHostnameLength)
|
hostname := strings.Repeat("h", types.MaxHostnameLength)
|
||||||
authID := types.MustAuthID()
|
authID := types.MustAuthID()
|
||||||
app.state.SetAuthCacheEntry(authID, types.NewRegisterAuthRequest(&types.RegistrationData{
|
require.NoError(t, app.state.SetAuthCacheEntry(authID, types.NewRegisterAuthRequest(&types.RegistrationData{
|
||||||
MachineKey: node.MachineKey, NodeKey: key.NewNode().Public(),
|
MachineKey: node.MachineKey, NodeKey: key.NewNode().Public(),
|
||||||
Hostname: hostname, Hostinfo: &tailcfg.Hostinfo{Hostname: hostname},
|
Hostname: hostname, Hostinfo: &tailcfg.Hostinfo{Hostname: hostname},
|
||||||
}))
|
})))
|
||||||
|
|
||||||
provider := &AuthProviderOIDC{h: app}
|
provider := &AuthProviderOIDC{h: app}
|
||||||
changed, err := provider.handleRegistration(user, authID, nil)
|
changed, err := provider.handleRegistration(user, authID, nil)
|
||||||
@@ -734,3 +740,736 @@ func TestMergeEmailClaims(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func newOIDCPendingProvider(t *testing.T, capacity int) *AuthProviderOIDC {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
a := &AuthProviderOIDC{
|
||||||
|
h: createTestApp(t),
|
||||||
|
serverURL: "https://headscale.example",
|
||||||
|
cfg: &types.OIDCConfig{},
|
||||||
|
oauth2Config: &oauth2.Config{
|
||||||
|
ClientID: "headscale-test",
|
||||||
|
Endpoint: oauth2.Endpoint{
|
||||||
|
AuthURL: "https://issuer.example/authorize",
|
||||||
|
AuthStyle: oauth2.AuthStyleInParams,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { require.NoError(t, a.h.state.Close()) })
|
||||||
|
|
||||||
|
a.authCache = expirable.NewLRU[string, AuthInfo](capacity+authCacheSSHReserve, func(_ string, info AuthInfo) {
|
||||||
|
if info.Registration {
|
||||||
|
a.authCacheRegistrations.Add(-1)
|
||||||
|
}
|
||||||
|
}, time.Minute)
|
||||||
|
t.Cleanup(a.authCache.Close)
|
||||||
|
|
||||||
|
return a
|
||||||
|
}
|
||||||
|
|
||||||
|
func oidcPendingAuthRequest(t *testing.T, authID types.AuthID) *http.Request {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/register/"+authID.String(), nil)
|
||||||
|
rctx := chi.NewRouteContext()
|
||||||
|
rctx.URLParams.Add("auth_id", authID.String())
|
||||||
|
|
||||||
|
return req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx))
|
||||||
|
}
|
||||||
|
|
||||||
|
func startOIDCPendingAuth(t *testing.T, a *AuthProviderOIDC, registration bool) (*httptest.ResponseRecorder, types.AuthID) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
authID := types.MustAuthID()
|
||||||
|
|
||||||
|
authReq := types.NewSSHCheckAuthRequest(1, 2)
|
||||||
|
if registration {
|
||||||
|
authReq = types.NewRegisterAuthRequest(&types.RegistrationData{Hostname: "pending-oidc"})
|
||||||
|
}
|
||||||
|
|
||||||
|
require.NoError(t, a.h.state.SetAuthCacheEntry(authID, authReq))
|
||||||
|
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
a.authHandler(rec, oidcPendingAuthRequest(t, authID), registration)
|
||||||
|
|
||||||
|
return rec, authID
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOIDCPendingAdmissionPreservesSessions(t *testing.T) {
|
||||||
|
a := newOIDCPendingProvider(t, 1)
|
||||||
|
a.cfg.PKCE.Enabled = true
|
||||||
|
a.cfg.PKCE.Method = types.PKCEMethodS256
|
||||||
|
|
||||||
|
states := make(map[string]AuthInfo)
|
||||||
|
|
||||||
|
for _, registration := range []bool{true, false} {
|
||||||
|
rec, authID := startOIDCPendingAuth(t, a, registration)
|
||||||
|
require.Equal(t, http.StatusFound, rec.Code)
|
||||||
|
location, err := rec.Result().Location()
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
state := location.Query().Get("state")
|
||||||
|
info, ok := a.authCache.Peek(state)
|
||||||
|
require.True(t, ok)
|
||||||
|
require.NotNil(t, info.Verifier)
|
||||||
|
assert.Equal(t, authID, info.AuthID)
|
||||||
|
assert.Equal(t, registration, info.Registration)
|
||||||
|
assert.Equal(t, "S256", location.Query().Get("code_challenge_method"))
|
||||||
|
|
||||||
|
states[state] = info
|
||||||
|
}
|
||||||
|
|
||||||
|
rejected, _ := startOIDCPendingAuth(t, a, true)
|
||||||
|
assert.Equal(t, http.StatusServiceUnavailable, rejected.Code,
|
||||||
|
"a full pending-state cache must reject admission instead of evicting an in-flight login")
|
||||||
|
assert.Equal(t, "10", rejected.Header().Get("Retry-After"))
|
||||||
|
assert.Empty(t, rejected.Header().Get("Location"))
|
||||||
|
assert.Empty(t, rejected.Header().Values("Set-Cookie"))
|
||||||
|
|
||||||
|
for state, want := range states {
|
||||||
|
assert.Equal(t, &want, a.getAuthInfoFromState(state), "an admitted session must survive a rejected admission")
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.Zero(t, a.authCache.Len())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOIDCPendingAdmissionReservesSSHCapacity(t *testing.T) {
|
||||||
|
a := newOIDCPendingProvider(t, 2)
|
||||||
|
registrations := make(map[string]types.AuthID)
|
||||||
|
|
||||||
|
for range 2 {
|
||||||
|
rec, authID := startOIDCPendingAuth(t, a, true)
|
||||||
|
require.Equal(t, http.StatusFound, rec.Code)
|
||||||
|
location, err := rec.Result().Location()
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
registrations[location.Query().Get("state")] = authID
|
||||||
|
}
|
||||||
|
|
||||||
|
rejected, _ := startOIDCPendingAuth(t, a, true)
|
||||||
|
assert.Equal(t, http.StatusServiceUnavailable, rejected.Code)
|
||||||
|
assert.Empty(t, rejected.Header().Values("Set-Cookie"))
|
||||||
|
|
||||||
|
for range 64 {
|
||||||
|
rec, authID := startOIDCPendingAuth(t, a, false)
|
||||||
|
require.Equal(t, http.StatusFound, rec.Code, "registration load must leave the SSH reserve available")
|
||||||
|
location, err := rec.Result().Location()
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
info, ok := a.authCache.Peek(location.Query().Get("state"))
|
||||||
|
require.True(t, ok)
|
||||||
|
assert.Equal(t, authID, info.AuthID)
|
||||||
|
assert.False(t, info.Registration)
|
||||||
|
}
|
||||||
|
|
||||||
|
full, _ := startOIDCPendingAuth(t, a, false)
|
||||||
|
assert.Equal(t, http.StatusServiceUnavailable, full.Code, "the SSH reserve must also be bounded")
|
||||||
|
assert.Empty(t, full.Header().Get("Location"))
|
||||||
|
assert.Empty(t, full.Header().Values("Set-Cookie"))
|
||||||
|
assert.Equal(t, 66, a.authCache.Len())
|
||||||
|
|
||||||
|
for state, authID := range registrations {
|
||||||
|
info := a.getAuthInfoFromState(state)
|
||||||
|
require.NotNil(t, info, "admission at the total bound must not evict an earlier registration")
|
||||||
|
assert.Equal(t, authID, info.AuthID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOIDCPendingAdmissionConcurrent(t *testing.T) {
|
||||||
|
a := newOIDCPendingProvider(t, 2)
|
||||||
|
|
||||||
|
type result struct {
|
||||||
|
authID types.AuthID
|
||||||
|
rec *httptest.ResponseRecorder
|
||||||
|
}
|
||||||
|
|
||||||
|
results := make(chan result, 4)
|
||||||
|
start := make(chan struct{})
|
||||||
|
|
||||||
|
var workers sync.WaitGroup
|
||||||
|
|
||||||
|
for range 4 {
|
||||||
|
authID := types.MustAuthID()
|
||||||
|
require.NoError(t, a.h.state.SetAuthCacheEntry(authID, types.NewRegisterAuthRequest(&types.RegistrationData{Hostname: "concurrent-oidc"})))
|
||||||
|
req := oidcPendingAuthRequest(t, authID)
|
||||||
|
|
||||||
|
workers.Go(func() {
|
||||||
|
<-start
|
||||||
|
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
a.RegisterHandler(rec, req)
|
||||||
|
|
||||||
|
results <- result{authID: authID, rec: rec}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
close(start)
|
||||||
|
workers.Wait()
|
||||||
|
close(results)
|
||||||
|
|
||||||
|
accepted := make(map[string]types.AuthID)
|
||||||
|
rejected := 0
|
||||||
|
|
||||||
|
for result := range results {
|
||||||
|
if result.rec.Code == http.StatusFound {
|
||||||
|
location, err := result.rec.Result().Location()
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
accepted[location.Query().Get("state")] = result.authID
|
||||||
|
} else {
|
||||||
|
assert.Equal(t, http.StatusServiceUnavailable, result.rec.Code)
|
||||||
|
assert.Empty(t, result.rec.Header().Get("Location"))
|
||||||
|
assert.Empty(t, result.rec.Header().Values("Set-Cookie"))
|
||||||
|
|
||||||
|
rejected++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
require.Len(t, accepted, 2, "concurrent admission must respect the pending-state bound")
|
||||||
|
assert.Equal(t, 2, rejected)
|
||||||
|
assert.Equal(t, 2, a.authCache.Len())
|
||||||
|
|
||||||
|
for state, authID := range accepted {
|
||||||
|
info := a.getAuthInfoFromState(state)
|
||||||
|
require.NotNil(t, info, "every successful admission must remain usable")
|
||||||
|
assert.Equal(t, authID, info.AuthID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOIDCPendingAdmissionDuplicateState(t *testing.T) {
|
||||||
|
a := newOIDCPendingProvider(t, 2)
|
||||||
|
verifier := "original-verifier"
|
||||||
|
info := AuthInfo{AuthID: types.MustAuthID(), Verifier: &verifier, Registration: true}
|
||||||
|
require.True(t, a.addAuthInfoToState("same-state", info))
|
||||||
|
assert.False(t, a.addAuthInfoToState("same-state", AuthInfo{AuthID: types.MustAuthID()}),
|
||||||
|
"a duplicate state must not replace the auth ID, flow type, or PKCE verifier")
|
||||||
|
assert.Equal(t, &info, a.getAuthInfoFromState("same-state"))
|
||||||
|
assert.Zero(t, a.authCacheRegistrations.Load())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOIDCPendingAdmissionSSHUsesNormalSlots(t *testing.T) {
|
||||||
|
a := newOIDCPendingProvider(t, 1)
|
||||||
|
for i := range 65 {
|
||||||
|
require.True(t, a.addAuthInfoToState(fmt.Sprintf("ssh-state-%d", i), AuthInfo{AuthID: types.MustAuthID()}),
|
||||||
|
"SSH checks may use the regular slots as well as their reserve")
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.False(t, a.addAuthInfoToState("one-too-many", AuthInfo{AuthID: types.MustAuthID()}))
|
||||||
|
assert.Equal(t, 65, a.authCache.Len())
|
||||||
|
assert.Zero(t, a.authCacheRegistrations.Load())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOIDCPendingAdmissionRequiresAuthRequest(t *testing.T) {
|
||||||
|
for _, registration := range []bool{true, false} {
|
||||||
|
for _, existing := range []bool{false, true} {
|
||||||
|
t.Run(fmt.Sprintf("registration=%t/existing=%t", registration, existing), func(t *testing.T) {
|
||||||
|
a := newOIDCPendingProvider(t, 1)
|
||||||
|
authID := types.MustAuthID()
|
||||||
|
wantStatus := http.StatusGone
|
||||||
|
|
||||||
|
if existing {
|
||||||
|
// A registration cannot authorize an SSH check, or vice versa.
|
||||||
|
authReq := types.NewRegisterAuthRequest(&types.RegistrationData{Hostname: "wrong-flow"})
|
||||||
|
if registration {
|
||||||
|
authReq = types.NewSSHCheckAuthRequest(1, 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
require.NoError(t, a.h.state.SetAuthCacheEntry(authID, authReq))
|
||||||
|
|
||||||
|
wantStatus = http.StatusBadRequest
|
||||||
|
}
|
||||||
|
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
a.authHandler(rec, oidcPendingAuthRequest(t, authID), registration)
|
||||||
|
assert.Equal(t, wantStatus, rec.Code)
|
||||||
|
assert.Empty(t, rec.Header().Get("Location"))
|
||||||
|
assert.Empty(t, rec.Header().Values("Set-Cookie"))
|
||||||
|
assert.Zero(t, a.authCache.Len(), "invalid auth requests must not reserve OIDC capacity")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOIDCPendingAdmissionReclaimsExpired(t *testing.T) {
|
||||||
|
synctest.Test(t, func(t *testing.T) {
|
||||||
|
a := newOIDCPendingProvider(t, 2)
|
||||||
|
a.authCache.Close()
|
||||||
|
|
||||||
|
a.authCache = expirable.NewLRU[string, AuthInfo](66, func(_ string, info AuthInfo) {
|
||||||
|
if info.Registration {
|
||||||
|
a.authCacheRegistrations.Add(-1)
|
||||||
|
}
|
||||||
|
}, time.Second)
|
||||||
|
defer a.authCache.Close()
|
||||||
|
|
||||||
|
old, _ := startOIDCPendingAuth(t, a, true)
|
||||||
|
require.Equal(t, http.StatusFound, old.Code)
|
||||||
|
oldURL, err := old.Result().Location()
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
<-time.After(time.Millisecond)
|
||||||
|
|
||||||
|
live, liveID := startOIDCPendingAuth(t, a, true)
|
||||||
|
require.Equal(t, http.StatusFound, live.Code)
|
||||||
|
liveURL, err := live.Result().Location()
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Both entries share a cleanup bucket, which waits for the newer expiry.
|
||||||
|
<-time.After(time.Second - time.Millisecond + time.Nanosecond)
|
||||||
|
require.Equal(t, 2, a.authCache.Len(), "exercise expired entries that the sweeper has not removed")
|
||||||
|
_, ok := a.authCache.Peek(oldURL.Query().Get("state"))
|
||||||
|
require.False(t, ok)
|
||||||
|
|
||||||
|
next, _ := startOIDCPendingAuth(t, a, true)
|
||||||
|
assert.Equal(t, http.StatusFound, next.Code, "expired entries must not cause a false capacity rejection")
|
||||||
|
assert.Nil(t, a.getAuthInfoFromState(oldURL.Query().Get("state")))
|
||||||
|
info := a.getAuthInfoFromState(liveURL.Query().Get("state"))
|
||||||
|
require.NotNil(t, info, "reclaiming expiry must preserve the live session")
|
||||||
|
assert.Equal(t, liveID, info.AuthID)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOIDCPendingAdmissionReclaimsSweptCapacity(t *testing.T) {
|
||||||
|
a := newOIDCPendingProvider(t, 1)
|
||||||
|
a.authCache.Close()
|
||||||
|
|
||||||
|
synctest.Test(t, func(t *testing.T) {
|
||||||
|
a.authCache = expirable.NewLRU[string, AuthInfo](65, func(_ string, info AuthInfo) {
|
||||||
|
if info.Registration {
|
||||||
|
a.authCacheRegistrations.Add(-1)
|
||||||
|
}
|
||||||
|
}, time.Second)
|
||||||
|
defer a.authCache.Close()
|
||||||
|
|
||||||
|
require.True(t, a.addAuthInfoToState("expires", AuthInfo{Registration: true}))
|
||||||
|
|
||||||
|
<-time.After(time.Second + 10*time.Millisecond)
|
||||||
|
synctest.Wait()
|
||||||
|
assert.Zero(t, a.authCache.Len())
|
||||||
|
assert.Zero(t, a.authCacheRegistrations.Load(), "background expiry must return the registration budget")
|
||||||
|
assert.True(t, a.addAuthInfoToState("replacement", AuthInfo{Registration: true}))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOIDCPendingCallbackReleasesOnError(t *testing.T) {
|
||||||
|
for _, pkce := range []bool{false, true} {
|
||||||
|
for _, missingToken := range []bool{false, true} {
|
||||||
|
t.Run(fmt.Sprintf("pkce=%t/missingToken=%t", pkce, missingToken), func(t *testing.T) {
|
||||||
|
a := newOIDCPendingProvider(t, 1)
|
||||||
|
a.cfg.PKCE.Enabled = pkce
|
||||||
|
a.cfg.PKCE.Method = types.PKCEMethodS256
|
||||||
|
|
||||||
|
var exchanges atomic.Int32
|
||||||
|
|
||||||
|
tokenServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
exchanges.Add(1)
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
if missingToken {
|
||||||
|
_, _ = io.WriteString(w, `{"access_token":"test-token","token_type":"Bearer"}`)
|
||||||
|
} else {
|
||||||
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
|
_, _ = io.WriteString(w, `{"error":"invalid_grant"}`)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
t.Cleanup(tokenServer.Close)
|
||||||
|
a.oauth2Config.Endpoint.TokenURL = tokenServer.URL
|
||||||
|
|
||||||
|
started, authID := startOIDCPendingAuth(t, a, true)
|
||||||
|
require.Equal(t, http.StatusFound, started.Code)
|
||||||
|
location, err := started.Result().Location()
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
state := location.Query().Get("state")
|
||||||
|
callbackURL := "/oidc/callback?code=test-code&state=" + state
|
||||||
|
callback := httptest.NewRequestWithContext(t.Context(), http.MethodGet, callbackURL, nil)
|
||||||
|
//nolint:gosec // G124: request cookies carry only name and value on the wire.
|
||||||
|
callback.AddCookie(&http.Cookie{Name: getCookieName("state", state), Value: state})
|
||||||
|
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
a.OIDCCallbackHandler(rec, callback)
|
||||||
|
|
||||||
|
wantStatus := http.StatusForbidden
|
||||||
|
if missingToken {
|
||||||
|
wantStatus = http.StatusBadRequest
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.Equal(t, wantStatus, rec.Code)
|
||||||
|
assert.Zero(t, a.authCache.Len(), "a callback error must release its pending-state slot")
|
||||||
|
_, pending := a.h.state.GetAuthCacheEntry(authID)
|
||||||
|
assert.True(t, pending, "consuming OIDC state must preserve the underlying auth request")
|
||||||
|
|
||||||
|
replay := httptest.NewRecorder()
|
||||||
|
a.OIDCCallbackHandler(replay, callback)
|
||||||
|
assert.Equal(t, http.StatusGone, replay.Code, "a failed callback must still consume its state")
|
||||||
|
assert.EqualValues(t, 1, exchanges.Load(), "a consumed state must not reach token exchange again")
|
||||||
|
|
||||||
|
next := httptest.NewRecorder()
|
||||||
|
a.RegisterHandler(next, oidcPendingAuthRequest(t, authID))
|
||||||
|
assert.Equal(t, http.StatusFound, next.Code, "the original auth request must support a fresh browser flow")
|
||||||
|
nextURL, err := next.Result().Location()
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.NotEqual(t, state, nextURL.Query().Get("state"))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOIDCPendingCallbackProviderError(t *testing.T) {
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name string
|
||||||
|
providerError string
|
||||||
|
code string
|
||||||
|
}{
|
||||||
|
{name: "cancelled", providerError: "access_denied"},
|
||||||
|
{name: "error with code", providerError: "access_denied", code: "unused-code"},
|
||||||
|
{name: "unknown error", providerError: strings.Repeat("untrusted-provider-error-", 2048)},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
a := newOIDCPendingProvider(t, 1)
|
||||||
|
a.cfg.PKCE.Enabled = true
|
||||||
|
a.cfg.PKCE.Method = types.PKCEMethodS256
|
||||||
|
|
||||||
|
var exchanges atomic.Int32
|
||||||
|
|
||||||
|
tokenServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
exchanges.Add(1)
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
|
_, _ = io.WriteString(w, `{"error":"invalid_grant"}`)
|
||||||
|
}))
|
||||||
|
t.Cleanup(tokenServer.Close)
|
||||||
|
a.oauth2Config.Endpoint.TokenURL = tokenServer.URL
|
||||||
|
|
||||||
|
started, authID := startOIDCPendingAuth(t, a, true)
|
||||||
|
require.Equal(t, http.StatusFound, started.Code)
|
||||||
|
location, err := started.Result().Location()
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
state := location.Query().Get("state")
|
||||||
|
|
||||||
|
full := httptest.NewRecorder()
|
||||||
|
a.RegisterHandler(full, oidcPendingAuthRequest(t, authID))
|
||||||
|
require.Equal(t, http.StatusServiceUnavailable, full.Code)
|
||||||
|
|
||||||
|
query := url.Values{
|
||||||
|
"error": {tc.providerError},
|
||||||
|
"error_description": {strings.Repeat("untrusted-provider-description-", 2048)},
|
||||||
|
"state": {state},
|
||||||
|
"code": {tc.code},
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
callback := httptest.NewRequestWithContext(ctx, http.MethodGet, "/oidc/callback?"+query.Encode(), nil)
|
||||||
|
//nolint:gosec // G124: request cookies carry only name and value on the wire.
|
||||||
|
callback.AddCookie(&http.Cookie{Name: getCookieName("state", state), Value: state})
|
||||||
|
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
a.OIDCCallbackHandler(rec, callback)
|
||||||
|
assert.Equal(t, http.StatusForbidden, rec.Code)
|
||||||
|
assert.NotContains(t, rec.Body.String(), "untrusted-provider-description")
|
||||||
|
assert.NotContains(t, rec.Body.String(), "untrusted-provider-error")
|
||||||
|
assert.Less(t, rec.Body.Len(), 16*1024, "provider input must not inflate the error response")
|
||||||
|
assert.Zero(t, a.authCache.Len(), "provider cancellation must release its pending-state slot")
|
||||||
|
assert.Zero(t, a.authCacheRegistrations.Load())
|
||||||
|
_, pending := a.h.state.GetAuthCacheEntry(authID)
|
||||||
|
assert.True(t, pending, "provider cancellation must preserve the underlying auth request")
|
||||||
|
|
||||||
|
replay := httptest.NewRecorder()
|
||||||
|
a.OIDCCallbackHandler(replay, callback)
|
||||||
|
assert.Equal(t, http.StatusGone, replay.Code, "a provider-error callback must consume its state exactly once")
|
||||||
|
assert.Zero(t, exchanges.Load(), "provider-error callbacks must never exchange a code")
|
||||||
|
|
||||||
|
next := httptest.NewRecorder()
|
||||||
|
a.RegisterHandler(next, oidcPendingAuthRequest(t, authID))
|
||||||
|
require.Equal(t, http.StatusFound, next.Code, "cancellation must free capacity for a fresh login")
|
||||||
|
nextURL, err := next.Result().Location()
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.NotEqual(t, state, nextURL.Query().Get("state"))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOIDCPendingCallbackProviderErrorValidation(t *testing.T) {
|
||||||
|
a := newOIDCPendingProvider(t, 1)
|
||||||
|
started, authID := startOIDCPendingAuth(t, a, true)
|
||||||
|
require.Equal(t, http.StatusFound, started.Code)
|
||||||
|
location, err := started.Result().Location()
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
state := location.Query().Get("state")
|
||||||
|
cookieName := getCookieName("state", state)
|
||||||
|
cookie := cookieName + "=" + state
|
||||||
|
providerError := "error=access_denied&state=" + state
|
||||||
|
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name string
|
||||||
|
query string
|
||||||
|
cookie string
|
||||||
|
status int
|
||||||
|
}{
|
||||||
|
{name: "missing state", query: "error=access_denied", cookie: cookie, status: http.StatusBadRequest},
|
||||||
|
{name: "short state", query: "error=access_denied&state=abc", cookie: "state_abc=abc", status: http.StatusBadRequest},
|
||||||
|
{name: "missing code and error", query: "state=" + state, cookie: cookie, status: http.StatusBadRequest},
|
||||||
|
{name: "empty error", query: "error=&state=" + state, cookie: cookie, status: http.StatusBadRequest},
|
||||||
|
{name: "description only", query: "error_description=cancelled&state=" + state, cookie: cookie, status: http.StatusBadRequest},
|
||||||
|
{name: "missing cookie", query: providerError, status: http.StatusBadRequest},
|
||||||
|
{name: "invalid cookie", query: providerError, cookie: cookieName + "=\"unterminated", status: http.StatusBadRequest},
|
||||||
|
{name: "empty cookie", query: providerError, cookie: cookieName + "=", status: http.StatusForbidden},
|
||||||
|
{name: "wrong cookie value", query: providerError, cookie: cookieName + "=different-state", status: http.StatusForbidden},
|
||||||
|
{name: "wrong cookie name", query: providerError, cookie: "state_other=" + state, status: http.StatusBadRequest},
|
||||||
|
{name: "unknown state", query: "error=access_denied&state=unknown-state", cookie: "state_unknow=unknown-state", status: http.StatusGone},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
callback := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/oidc/callback?"+tc.query, nil)
|
||||||
|
callback.Header.Set("Cookie", tc.cookie)
|
||||||
|
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
a.OIDCCallbackHandler(rec, callback)
|
||||||
|
assert.Equal(t, tc.status, rec.Code)
|
||||||
|
|
||||||
|
info, ok := a.authCache.Peek(state)
|
||||||
|
require.True(t, ok, "invalid callbacks must preserve the pending state")
|
||||||
|
assert.Equal(t, authID, info.AuthID)
|
||||||
|
|
||||||
|
full := httptest.NewRecorder()
|
||||||
|
a.RegisterHandler(full, oidcPendingAuthRequest(t, authID))
|
||||||
|
assert.Equal(t, http.StatusServiceUnavailable, full.Code, "invalid callbacks must not free another browser's slot")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOIDCPendingCallbackRequiresState(t *testing.T) {
|
||||||
|
a := newOIDCPendingProvider(t, 1)
|
||||||
|
|
||||||
|
var exchanges atomic.Int32
|
||||||
|
|
||||||
|
tokenServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
exchanges.Add(1)
|
||||||
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
|
}))
|
||||||
|
t.Cleanup(tokenServer.Close)
|
||||||
|
a.oauth2Config.Endpoint.TokenURL = tokenServer.URL
|
||||||
|
|
||||||
|
const state = "unknown-oidc-state"
|
||||||
|
|
||||||
|
callback := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/oidc/callback?code=test-code&state="+state, nil)
|
||||||
|
//nolint:gosec // G124: request cookies carry only name and value on the wire.
|
||||||
|
callback.AddCookie(&http.Cookie{Name: getCookieName("state", state), Value: state})
|
||||||
|
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
a.OIDCCallbackHandler(rec, callback)
|
||||||
|
assert.Equal(t, http.StatusGone, rec.Code)
|
||||||
|
assert.Zero(t, exchanges.Load(), "unknown state must be rejected before token exchange even without PKCE")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOIDCPendingCallbackConcurrent(t *testing.T) {
|
||||||
|
a := newOIDCPendingProvider(t, 1)
|
||||||
|
a.cfg.PKCE.Enabled = true
|
||||||
|
a.cfg.PKCE.Method = types.PKCEMethodS256
|
||||||
|
entered := make(chan struct{}, 2)
|
||||||
|
release := make(chan struct{})
|
||||||
|
|
||||||
|
var releaseOnce sync.Once
|
||||||
|
|
||||||
|
unblock := func() { releaseOnce.Do(func() { close(release) }) }
|
||||||
|
|
||||||
|
var exchanges atomic.Int32
|
||||||
|
|
||||||
|
tokenServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
exchanges.Add(1)
|
||||||
|
|
||||||
|
entered <- struct{}{}
|
||||||
|
|
||||||
|
<-release
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
|
_, _ = io.WriteString(w, `{"error":"invalid_grant"}`)
|
||||||
|
}))
|
||||||
|
t.Cleanup(tokenServer.Close)
|
||||||
|
t.Cleanup(unblock)
|
||||||
|
|
||||||
|
a.oauth2Config.Endpoint.TokenURL = tokenServer.URL
|
||||||
|
|
||||||
|
started, _ := startOIDCPendingAuth(t, a, true)
|
||||||
|
require.Equal(t, http.StatusFound, started.Code)
|
||||||
|
location, err := started.Result().Location()
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
state := location.Query().Get("state")
|
||||||
|
callbackURL := "/oidc/callback?code=test-code&state=" + state
|
||||||
|
first := httptest.NewRequestWithContext(t.Context(), http.MethodGet, callbackURL, nil)
|
||||||
|
//nolint:gosec // G124: request cookies carry only name and value on the wire.
|
||||||
|
first.AddCookie(&http.Cookie{Name: getCookieName("state", state), Value: state})
|
||||||
|
second := first.Clone(t.Context())
|
||||||
|
firstRec, secondRec := httptest.NewRecorder(), httptest.NewRecorder()
|
||||||
|
firstDone, secondDone := make(chan struct{}), make(chan struct{})
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
a.OIDCCallbackHandler(firstRec, first)
|
||||||
|
close(firstDone)
|
||||||
|
}()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-entered:
|
||||||
|
case <-firstDone:
|
||||||
|
t.Fatal("the first callback must reach token exchange")
|
||||||
|
case <-time.After(5 * time.Second):
|
||||||
|
t.Fatal("the first callback did not reach token exchange")
|
||||||
|
}
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
a.OIDCCallbackHandler(secondRec, second)
|
||||||
|
close(secondDone)
|
||||||
|
}()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-secondDone:
|
||||||
|
case <-entered:
|
||||||
|
// Release both requests before asserting the single-exchange invariant.
|
||||||
|
case <-time.After(5 * time.Second):
|
||||||
|
t.Fatal("the duplicate callback did not finish or reach token exchange")
|
||||||
|
}
|
||||||
|
|
||||||
|
unblock()
|
||||||
|
<-firstDone
|
||||||
|
<-secondDone
|
||||||
|
assert.Equal(t, http.StatusForbidden, firstRec.Code)
|
||||||
|
assert.Equal(t, http.StatusGone, secondRec.Code)
|
||||||
|
assert.EqualValues(t, 1, exchanges.Load(), "only one callback may claim a pending state")
|
||||||
|
assert.Zero(t, a.authCache.Len())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOIDCPendingCallbackCookieBinding(t *testing.T) {
|
||||||
|
a := newOIDCPendingProvider(t, 1)
|
||||||
|
started, _ := startOIDCPendingAuth(t, a, true)
|
||||||
|
require.Equal(t, http.StatusFound, started.Code)
|
||||||
|
location, err := started.Result().Location()
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
state := location.Query().Get("state")
|
||||||
|
|
||||||
|
for _, value := range []string{"", "different-state"} {
|
||||||
|
callback := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/oidc/callback?code=test-code&state="+state, nil)
|
||||||
|
wantStatus := http.StatusBadRequest
|
||||||
|
|
||||||
|
if value != "" {
|
||||||
|
//nolint:gosec // G124: request cookies carry only name and value on the wire.
|
||||||
|
callback.AddCookie(&http.Cookie{Name: getCookieName("state", state), Value: value})
|
||||||
|
|
||||||
|
wantStatus = http.StatusForbidden
|
||||||
|
}
|
||||||
|
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
a.OIDCCallbackHandler(rec, callback)
|
||||||
|
assert.Equal(t, wantStatus, rec.Code)
|
||||||
|
|
||||||
|
_, ok := a.authCache.Peek(state)
|
||||||
|
assert.True(t, ok, "an unbound callback must not consume another browser's pending state")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOIDCPendingCallbackPKCEFlow(t *testing.T) {
|
||||||
|
for _, method := range []string{"", types.PKCEMethodPlain, types.PKCEMethodS256} {
|
||||||
|
t.Run("method="+method, func(t *testing.T) {
|
||||||
|
b := newOIDCBrowser(t)
|
||||||
|
a, ok := b.app.authProvider.(*AuthProviderOIDC)
|
||||||
|
require.True(t, ok)
|
||||||
|
|
||||||
|
a.cfg.PKCE.Enabled = method != ""
|
||||||
|
a.cfg.PKCE.Method = method
|
||||||
|
a.authCache.Resize(65)
|
||||||
|
|
||||||
|
authID, registerURL := b.pendingNode(t)
|
||||||
|
|
||||||
|
status, _, body := b.get(t, registerURL)
|
||||||
|
require.Equal(t, http.StatusOK, status)
|
||||||
|
assert.Contains(t, body, "Confirm node registration")
|
||||||
|
assert.Zero(t, a.authCache.Len(), "successful callbacks must release pending OIDC capacity")
|
||||||
|
|
||||||
|
authReq, ok := b.app.state.GetAuthCacheEntry(authID)
|
||||||
|
require.True(t, ok, "consuming OIDC state must preserve the registration for confirmation")
|
||||||
|
assert.NotNil(t, authReq.PendingConfirmation())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOIDCPendingCallbackPreservesSSHVerdict(t *testing.T) {
|
||||||
|
b := newOIDCBrowser(t)
|
||||||
|
a, ok := b.app.authProvider.(*AuthProviderOIDC)
|
||||||
|
require.True(t, ok)
|
||||||
|
|
||||||
|
principal := mockoidc.DefaultUser()
|
||||||
|
user, _, err := a.createOrUpdateUserFromClaim(&types.OIDCClaims{
|
||||||
|
Iss: b.idp.Issuer(),
|
||||||
|
Sub: principal.Subject,
|
||||||
|
Username: principal.PreferredUsername,
|
||||||
|
Email: principal.Email,
|
||||||
|
EmailVerified: true,
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
src := b.app.state.CreateNodeForTest(user, "oidc-ssh-source")
|
||||||
|
b.app.state.PutNodeInStoreForTest(*src)
|
||||||
|
|
||||||
|
authID := types.MustAuthID()
|
||||||
|
authReq := types.NewSSHCheckAuthRequest(src.ID, 2)
|
||||||
|
require.NoError(t, b.app.state.SetAuthCacheEntry(authID, authReq))
|
||||||
|
|
||||||
|
status, _, body := b.get(t, a.AuthURL(authID))
|
||||||
|
require.Equal(t, http.StatusOK, status)
|
||||||
|
assert.Contains(t, body, "You may return to your terminal.")
|
||||||
|
assert.Zero(t, a.authCache.Len())
|
||||||
|
|
||||||
|
cached, ok := b.app.state.GetAuthCacheEntry(authID)
|
||||||
|
require.True(t, ok, "completing OIDC must preserve the SSH verdict for its polling client")
|
||||||
|
assert.Same(t, authReq, cached)
|
||||||
|
|
||||||
|
select {
|
||||||
|
case verdict := <-cached.WaitForAuth():
|
||||||
|
assert.True(t, verdict.Accept())
|
||||||
|
default:
|
||||||
|
t.Fatal("successful SSH authentication must make its verdict available")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOIDCPendingCallbackNonceBinding(t *testing.T) {
|
||||||
|
b := newOIDCBrowser(t)
|
||||||
|
a, ok := b.app.authProvider.(*AuthProviderOIDC)
|
||||||
|
require.True(t, ok)
|
||||||
|
|
||||||
|
b.client.CheckRedirect = func(req *http.Request, _ []*http.Request) error {
|
||||||
|
if req.URL.Path == "/oidc/callback" {
|
||||||
|
return http.ErrUseLastResponse
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
_, registerURL := b.pendingNode(t)
|
||||||
|
//nolint:noctx,bodyclose // local test browser; closed below
|
||||||
|
response, err := b.client.Get(registerURL)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
defer response.Body.Close()
|
||||||
|
|
||||||
|
callbackURL, err := response.Location()
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, "/oidc/callback", callbackURL.Path)
|
||||||
|
|
||||||
|
callback := httptest.NewRequestWithContext(t.Context(), http.MethodGet, callbackURL.String(), nil)
|
||||||
|
for _, cookie := range b.client.Jar.Cookies(callbackURL) {
|
||||||
|
if strings.HasPrefix(cookie.Name, "nonce_") {
|
||||||
|
cookie.Value = "wrong-nonce"
|
||||||
|
}
|
||||||
|
|
||||||
|
callback.AddCookie(cookie)
|
||||||
|
}
|
||||||
|
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
a.OIDCCallbackHandler(rec, callback)
|
||||||
|
assert.Equal(t, http.StatusForbidden, rec.Code)
|
||||||
|
assert.Zero(t, a.authCache.Len(), "nonce rejection must release the consumed pending state")
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"runtime"
|
"runtime"
|
||||||
"runtime/pprof"
|
"runtime/pprof"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -45,10 +46,8 @@ func requireExpiredVerdict(t *testing.T, entry *types.AuthRequest) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestAuthCacheBoundedLRU verifies that the registration auth cache is
|
// TestAuthCacheBoundedLRU checks the underlying library's eviction callback.
|
||||||
// bounded by a maximum entry count, that exceeding the maxEntries evicts the
|
// State admission prevents this eviction behavior for pending auth requests.
|
||||||
// oldest entry, and that the eviction callback resolves the parked
|
|
||||||
// AuthRequest with ErrRegistrationExpired so any waiting goroutine wakes.
|
|
||||||
func TestAuthCacheBoundedLRU(t *testing.T) {
|
func TestAuthCacheBoundedLRU(t *testing.T) {
|
||||||
const maxEntries = 4
|
const maxEntries = 4
|
||||||
|
|
||||||
@@ -97,6 +96,171 @@ func TestAuthCacheTTLExpiry(t *testing.T) {
|
|||||||
assert.False(t, ok, "expired entry must be gone from the cache")
|
assert.False(t, ok, "expired entry must be gone from the cache")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestAuthCacheAdmissionPreservesPending(t *testing.T) {
|
||||||
|
cfg := persistTestConfig(t.TempDir() + "/headscale.db")
|
||||||
|
cfg.Tuning.RegisterCacheMaxEntries = 1
|
||||||
|
s, err := NewState(cfg)
|
||||||
|
require.NoError(t, err)
|
||||||
|
t.Cleanup(func() { require.NoError(t, s.Close()) })
|
||||||
|
|
||||||
|
id := types.MustAuthID()
|
||||||
|
pending := types.NewAuthRequest()
|
||||||
|
require.NoError(t, s.SetAuthCacheEntry(id, pending))
|
||||||
|
|
||||||
|
otherID := types.MustAuthID()
|
||||||
|
require.ErrorIs(t, s.SetAuthCacheEntry(otherID, types.NewAuthRequest()), ErrAuthCacheFull)
|
||||||
|
|
||||||
|
got, ok := s.GetAuthCacheEntry(id)
|
||||||
|
require.True(t, ok, "new work must not displace an admitted authentication")
|
||||||
|
require.Same(t, pending, got)
|
||||||
|
|
||||||
|
_, ok = s.GetAuthCacheEntry(otherID)
|
||||||
|
require.False(t, ok, "new work must be refused at capacity")
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-pending.WaitForAuth():
|
||||||
|
t.Fatal("capacity refusal must not finish an existing authentication")
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAuthCacheAdmissionPreservesIdentity(t *testing.T) {
|
||||||
|
cfg := persistTestConfig(t.TempDir() + "/headscale.db")
|
||||||
|
s, err := NewState(cfg)
|
||||||
|
require.NoError(t, err)
|
||||||
|
t.Cleanup(func() { require.NoError(t, s.Close()) })
|
||||||
|
|
||||||
|
id := types.MustAuthID()
|
||||||
|
pending := types.NewAuthRequest()
|
||||||
|
require.NoError(t, s.SetAuthCacheEntry(id, pending))
|
||||||
|
require.NoError(t, s.SetAuthCacheEntry(id, pending))
|
||||||
|
require.ErrorIs(t, s.SetAuthCacheEntry(id, types.NewAuthRequest()), ErrAuthCacheEntryExists)
|
||||||
|
|
||||||
|
got, ok := s.GetAuthCacheEntry(id)
|
||||||
|
require.True(t, ok)
|
||||||
|
require.Same(t, pending, got, "an existing auth ID must keep its original request and waiter")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAuthCacheAdmissionConcurrent(t *testing.T) {
|
||||||
|
cfg := persistTestConfig(t.TempDir() + "/headscale.db")
|
||||||
|
cfg.Tuning.RegisterCacheMaxEntries = 2
|
||||||
|
s, err := NewState(cfg)
|
||||||
|
require.NoError(t, err)
|
||||||
|
t.Cleanup(func() { require.NoError(t, s.Close()) })
|
||||||
|
|
||||||
|
id := types.MustAuthID()
|
||||||
|
pending := types.NewAuthRequest()
|
||||||
|
require.NoError(t, s.SetAuthCacheEntry(id, pending))
|
||||||
|
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
|
||||||
|
results := make(chan error, 4)
|
||||||
|
|
||||||
|
for range 4 {
|
||||||
|
wg.Go(func() {
|
||||||
|
results <- s.SetAuthCacheEntry(types.MustAuthID(), types.NewAuthRequest())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
wg.Wait()
|
||||||
|
close(results)
|
||||||
|
|
||||||
|
var accepted int
|
||||||
|
|
||||||
|
for err := range results {
|
||||||
|
if err == nil {
|
||||||
|
accepted++
|
||||||
|
} else {
|
||||||
|
require.ErrorIs(t, err, ErrAuthCacheFull)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
require.Equal(t, 1, accepted)
|
||||||
|
|
||||||
|
got, ok := s.GetAuthCacheEntry(id)
|
||||||
|
require.True(t, ok, "concurrent admission must preserve existing transactions")
|
||||||
|
require.Same(t, pending, got)
|
||||||
|
require.Equal(t, 2, s.authCache.Len())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAuthCacheAdmissionReservesSSHCapacity(t *testing.T) {
|
||||||
|
cfg := persistTestConfig(t.TempDir() + "/headscale.db")
|
||||||
|
cfg.Tuning.RegisterCacheMaxEntries = 1
|
||||||
|
s, err := NewState(cfg)
|
||||||
|
require.NoError(t, err)
|
||||||
|
t.Cleanup(func() { require.NoError(t, s.Close()) })
|
||||||
|
|
||||||
|
registrationID := types.MustAuthID()
|
||||||
|
require.NoError(t, s.SetAuthCacheEntry(registrationID, types.NewAuthRequest()))
|
||||||
|
|
||||||
|
sshID := types.MustAuthID()
|
||||||
|
require.NoError(t, s.SetAuthCacheEntry(sshID, types.NewSSHCheckAuthRequest(1, 2)),
|
||||||
|
"registration capacity must not consume the SSH reservation")
|
||||||
|
require.ErrorIs(t, s.SetAuthCacheEntry(types.MustAuthID(), types.NewAuthRequest()), ErrAuthCacheFull)
|
||||||
|
_, ok := s.GetAuthCacheEntry(registrationID)
|
||||||
|
require.True(t, ok)
|
||||||
|
_, ok = s.GetAuthCacheEntry(sshID)
|
||||||
|
require.True(t, ok)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAuthCacheAdmissionBoundsRegistrationPayload(t *testing.T) {
|
||||||
|
cfg := persistTestConfig(t.TempDir() + "/headscale.db")
|
||||||
|
s, err := NewState(cfg)
|
||||||
|
require.NoError(t, err)
|
||||||
|
t.Cleanup(func() { require.NoError(t, s.Close()) })
|
||||||
|
|
||||||
|
id := types.MustAuthID()
|
||||||
|
request := types.NewRegisterAuthRequest(&types.RegistrationData{
|
||||||
|
Hostname: strings.Repeat("x", types.MaxRegistrationPayloadBytes),
|
||||||
|
})
|
||||||
|
require.ErrorIs(t, s.SetAuthCacheEntry(id, request), types.ErrRegistrationPayloadTooLarge)
|
||||||
|
_, ok := s.GetAuthCacheEntry(id)
|
||||||
|
require.False(t, ok)
|
||||||
|
require.Zero(t, s.authCache.Len())
|
||||||
|
require.Zero(t, s.authCacheRegularEntries.Load())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAuthCacheAdmissionDoesNotReuseExpiredRequest(t *testing.T) {
|
||||||
|
cfg := persistTestConfig(t.TempDir() + "/headscale.db")
|
||||||
|
cfg.Tuning.RegisterCacheExpiration = 50 * time.Millisecond
|
||||||
|
s, err := NewState(cfg)
|
||||||
|
require.NoError(t, err)
|
||||||
|
t.Cleanup(func() { require.NoError(t, s.Close()) })
|
||||||
|
|
||||||
|
id := types.MustAuthID()
|
||||||
|
request := types.NewAuthRequest()
|
||||||
|
require.NoError(t, s.SetAuthCacheEntry(id, request))
|
||||||
|
requireExpiredVerdict(t, request)
|
||||||
|
require.Error(t, s.SetAuthCacheEntry(id, request), "expired waiters cannot be reused as new pending requests")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAuthCacheAdmissionReleasesCapacity(t *testing.T) {
|
||||||
|
for _, removal := range []string{"consumption", "expiry"} {
|
||||||
|
t.Run(removal, func(t *testing.T) {
|
||||||
|
cfg := persistTestConfig(t.TempDir() + "/headscale.db")
|
||||||
|
cfg.Tuning.RegisterCacheMaxEntries = 1
|
||||||
|
cfg.Tuning.RegisterCacheExpiration = 50 * time.Millisecond
|
||||||
|
s, err := NewState(cfg)
|
||||||
|
require.NoError(t, err)
|
||||||
|
t.Cleanup(func() { require.NoError(t, s.Close()) })
|
||||||
|
|
||||||
|
id := types.MustAuthID()
|
||||||
|
pending := types.NewAuthRequest()
|
||||||
|
require.NoError(t, s.SetAuthCacheEntry(id, pending))
|
||||||
|
|
||||||
|
if removal == "consumption" {
|
||||||
|
pending.FinishAuth(types.AuthVerdict{})
|
||||||
|
s.DeleteAuthCacheEntry(id)
|
||||||
|
} else {
|
||||||
|
requireExpiredVerdict(t, pending)
|
||||||
|
}
|
||||||
|
|
||||||
|
require.NoError(t, s.SetAuthCacheEntry(types.MustAuthID(), types.NewAuthRequest()))
|
||||||
|
require.Equal(t, int64(1), s.authCacheRegularEntries.Load())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func goroutineID() string {
|
func goroutineID() string {
|
||||||
buf := make([]byte, 64)
|
buf := make([]byte, 64)
|
||||||
n := runtime.Stack(buf, false)
|
n := runtime.Stack(buf, false)
|
||||||
@@ -138,11 +302,12 @@ func TestStateCloseStopsAuthCacheSweeper(t *testing.T) {
|
|||||||
require.Equal(t, 1, authCacheSweepers(t, gid), "NewState must start a sweeper")
|
require.Equal(t, 1, authCacheSweepers(t, gid), "NewState must start a sweeper")
|
||||||
|
|
||||||
pending := types.NewAuthRequest()
|
pending := types.NewAuthRequest()
|
||||||
s.SetAuthCacheEntry(types.MustAuthID(), pending)
|
require.NoError(t, s.SetAuthCacheEntry(types.MustAuthID(), pending))
|
||||||
|
|
||||||
require.NoError(t, s.Close())
|
require.NoError(t, s.Close())
|
||||||
|
|
||||||
requireExpiredVerdict(t, pending)
|
requireExpiredVerdict(t, pending)
|
||||||
|
require.ErrorIs(t, s.SetAuthCacheEntry(types.MustAuthID(), types.NewAuthRequest()), ErrAuthCacheClosed)
|
||||||
|
|
||||||
assert.Eventually(t, func() bool {
|
assert.Eventually(t, func() bool {
|
||||||
return authCacheSweepers(t, gid) == 0
|
return authCacheSweepers(t, gid) == 0
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ func TestTaggedReauthKeepsNilExpiry(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
authID := types.MustAuthID()
|
authID := types.MustAuthID()
|
||||||
s.SetAuthCacheEntry(authID, types.NewRegisterAuthRequest(regData))
|
require.NoError(t, s.SetAuthCacheEntry(authID, types.NewRegisterAuthRequest(regData)))
|
||||||
|
|
||||||
finalNode, _, err := s.HandleNodeFromAuthPath(
|
finalNode, _, err := s.HandleNodeFromAuthPath(
|
||||||
authID,
|
authID,
|
||||||
@@ -396,12 +396,12 @@ func TestAuthPathOnTaggedMachine(t *testing.T) {
|
|||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
authID := types.MustAuthID()
|
authID := types.MustAuthID()
|
||||||
s.SetAuthCacheEntry(authID, types.NewRegisterAuthRequest(&types.RegistrationData{
|
require.NoError(t, s.SetAuthCacheEntry(authID, types.NewRegisterAuthRequest(&types.RegistrationData{
|
||||||
MachineKey: mk,
|
MachineKey: mk,
|
||||||
NodeKey: nodeKey,
|
NodeKey: nodeKey,
|
||||||
Hostname: "multi",
|
Hostname: "multi",
|
||||||
Hostinfo: &tailcfg.Hostinfo{Hostname: "multi"},
|
Hostinfo: &tailcfg.Hostinfo{Hostname: "multi"},
|
||||||
}))
|
})))
|
||||||
|
|
||||||
node, _, err := s.HandleNodeFromAuthPath(authID, types.UserID(user.ID), nil, util.RegisterMethodOIDC)
|
node, _, err := s.HandleNodeFromAuthPath(authID, types.UserID(user.ID), nil, util.RegisterMethodOIDC)
|
||||||
|
|
||||||
@@ -439,12 +439,12 @@ func TestUserKeyOnLoginJoinedTaggedNode(t *testing.T) {
|
|||||||
nodeKey := key.NewNode().Public()
|
nodeKey := key.NewNode().Public()
|
||||||
|
|
||||||
authID := types.MustAuthID()
|
authID := types.MustAuthID()
|
||||||
s.SetAuthCacheEntry(authID, types.NewRegisterAuthRequest(&types.RegistrationData{
|
require.NoError(t, s.SetAuthCacheEntry(authID, types.NewRegisterAuthRequest(&types.RegistrationData{
|
||||||
MachineKey: machineKey,
|
MachineKey: machineKey,
|
||||||
NodeKey: nodeKey,
|
NodeKey: nodeKey,
|
||||||
Hostname: "login-node",
|
Hostname: "login-node",
|
||||||
Hostinfo: &tailcfg.Hostinfo{Hostname: "login-node"},
|
Hostinfo: &tailcfg.Hostinfo{Hostname: "login-node"},
|
||||||
}))
|
})))
|
||||||
joined, _, err := s.HandleNodeFromAuthPath(authID, types.UserID(owner.ID), nil, util.RegisterMethodOIDC)
|
joined, _, err := s.HandleNodeFromAuthPath(authID, types.UserID(owner.ID), nil, util.RegisterMethodOIDC)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
@@ -595,7 +595,7 @@ func (n seededTaggedNode) reauth(t *testing.T, authUser *types.User, requestTags
|
|||||||
rd.Expiry = clientExpiry
|
rd.Expiry = clientExpiry
|
||||||
|
|
||||||
authID := types.MustAuthID()
|
authID := types.MustAuthID()
|
||||||
n.s.SetAuthCacheEntry(authID, types.NewRegisterAuthRequest(&rd))
|
require.NoError(t, n.s.SetAuthCacheEntry(authID, types.NewRegisterAuthRequest(&rd)))
|
||||||
|
|
||||||
node, _, err := n.s.HandleNodeFromAuthPath(
|
node, _, err := n.s.HandleNodeFromAuthPath(
|
||||||
authID,
|
authID,
|
||||||
@@ -997,7 +997,7 @@ func TestIssue3371_TaggedNodeInteractiveReloginAfterLogout(t *testing.T) {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
authID := types.MustAuthID()
|
authID := types.MustAuthID()
|
||||||
s.SetAuthCacheEntry(authID, types.NewRegisterAuthRequest(regData))
|
require.NoError(t, s.SetAuthCacheEntry(authID, types.NewRegisterAuthRequest(regData)))
|
||||||
|
|
||||||
relogged, _, err := s.HandleNodeFromAuthPath(
|
relogged, _, err := s.HandleNodeFromAuthPath(
|
||||||
authID, types.UserID(user.ID), nil, util.RegisterMethodOIDC,
|
authID, types.UserID(user.ID), nil, util.RegisterMethodOIDC,
|
||||||
|
|||||||
@@ -217,7 +217,8 @@ func (s *State) DebugRegistrationCache() map[string]any {
|
|||||||
return map[string]any{
|
return map[string]any{
|
||||||
"type": "expirable-lru",
|
"type": "expirable-lru",
|
||||||
"expiration": registerCacheExpiration.String(),
|
"expiration": registerCacheExpiration.String(),
|
||||||
"max_entries": defaultRegisterCacheMaxEntries,
|
"max_entries": s.authCacheMaxEntries,
|
||||||
|
"ssh_reserve": authCacheSSHReserve,
|
||||||
"current_len": s.authCache.Len(),
|
"current_len": s.authCache.Len(),
|
||||||
"status": "active",
|
"status": "active",
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -839,11 +839,11 @@ func TestAuthPathRejectsUnencodableExpiry(t *testing.T) {
|
|||||||
user := s.CreateUserForTest("auth-path-user")
|
user := s.CreateUserForTest("auth-path-user")
|
||||||
nodeKey := key.NewNode().Public()
|
nodeKey := key.NewNode().Public()
|
||||||
authID := types.MustAuthID()
|
authID := types.MustAuthID()
|
||||||
s.SetAuthCacheEntry(authID, types.NewRegisterAuthRequest(&types.RegistrationData{
|
require.NoError(t, s.SetAuthCacheEntry(authID, types.NewRegisterAuthRequest(&types.RegistrationData{
|
||||||
MachineKey: key.NewMachine().Public(),
|
MachineKey: key.NewMachine().Public(),
|
||||||
NodeKey: nodeKey,
|
NodeKey: nodeKey,
|
||||||
Hostname: "auth-path-node",
|
Hostname: "auth-path-node",
|
||||||
}))
|
})))
|
||||||
|
|
||||||
// How go-oidc reads an exp claim in the year 10000.
|
// How go-oidc reads an exp claim in the year 10000.
|
||||||
beyond := time.Unix(253402300800, 0)
|
beyond := time.Unix(253402300800, 0)
|
||||||
@@ -871,11 +871,11 @@ func TestAcceptedExpirySurvivesRestart(t *testing.T) {
|
|||||||
|
|
||||||
user := s.CreateUserForTest("oidc-user")
|
user := s.CreateUserForTest("oidc-user")
|
||||||
authID := types.MustAuthID()
|
authID := types.MustAuthID()
|
||||||
s.SetAuthCacheEntry(authID, types.NewRegisterAuthRequest(&types.RegistrationData{
|
require.NoError(t, s.SetAuthCacheEntry(authID, types.NewRegisterAuthRequest(&types.RegistrationData{
|
||||||
MachineKey: key.NewMachine().Public(),
|
MachineKey: key.NewMachine().Public(),
|
||||||
NodeKey: key.NewNode().Public(),
|
NodeKey: key.NewNode().Public(),
|
||||||
Hostname: "oidc-node",
|
Hostname: "oidc-node",
|
||||||
}))
|
})))
|
||||||
|
|
||||||
oidcNode, _, err := s.HandleNodeFromAuthPath(
|
oidcNode, _, err := s.HandleNodeFromAuthPath(
|
||||||
authID, types.UserID(user.ID), &east, util.RegisterMethodOIDC,
|
authID, types.UserID(user.ID), &east, util.RegisterMethodOIDC,
|
||||||
@@ -1100,12 +1100,12 @@ func TestReauthKeepsOperationalMetadata(t *testing.T) {
|
|||||||
"precondition: the node serves its route")
|
"precondition: the node serves its route")
|
||||||
|
|
||||||
authID := types.MustAuthID()
|
authID := types.MustAuthID()
|
||||||
s.SetAuthCacheEntry(authID, types.NewRegisterAuthRequest(&types.RegistrationData{
|
require.NoError(t, s.SetAuthCacheEntry(authID, types.NewRegisterAuthRequest(&types.RegistrationData{
|
||||||
MachineKey: node.MachineKey(),
|
MachineKey: node.MachineKey(),
|
||||||
NodeKey: key.NewNode().Public(),
|
NodeKey: key.NewNode().Public(),
|
||||||
Hostname: node.Hostname(),
|
Hostname: node.Hostname(),
|
||||||
Hostinfo: &tailcfg.Hostinfo{Hostname: node.Hostname()},
|
Hostinfo: &tailcfg.Hostinfo{Hostname: node.Hostname()},
|
||||||
}))
|
})))
|
||||||
|
|
||||||
relogged, _, err := s.HandleNodeFromAuthPath(
|
relogged, _, err := s.HandleNodeFromAuthPath(
|
||||||
authID, node.TypedUserID(), nil, util.RegisterMethodOIDC,
|
authID, node.TypedUserID(), nil, util.RegisterMethodOIDC,
|
||||||
@@ -1142,11 +1142,11 @@ func TestReauthKeepsTaggedFutureExpiry(t *testing.T) {
|
|||||||
require.True(t, node.IsTagged())
|
require.True(t, node.IsTagged())
|
||||||
|
|
||||||
authID := types.MustAuthID()
|
authID := types.MustAuthID()
|
||||||
s.SetAuthCacheEntry(authID, types.NewRegisterAuthRequest(&types.RegistrationData{
|
require.NoError(t, s.SetAuthCacheEntry(authID, types.NewRegisterAuthRequest(&types.RegistrationData{
|
||||||
MachineKey: node.MachineKey(), NodeKey: key.NewNode().Public(),
|
MachineKey: node.MachineKey(), NodeKey: key.NewNode().Public(),
|
||||||
Hostname: node.Hostname(),
|
Hostname: node.Hostname(),
|
||||||
Hostinfo: &tailcfg.Hostinfo{Hostname: node.Hostname(), RequestTags: []string{"tag:ci"}},
|
Hostinfo: &tailcfg.Hostinfo{Hostname: node.Hostname(), RequestTags: []string{"tag:ci"}},
|
||||||
}))
|
})))
|
||||||
relogged, _, err := s.HandleNodeFromAuthPath(authID, userID, nil, util.RegisterMethodOIDC)
|
relogged, _, err := s.HandleNodeFromAuthPath(authID, userID, nil, util.RegisterMethodOIDC)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.Equal(t, nodeID, relogged.ID())
|
require.Equal(t, nodeID, relogged.ID())
|
||||||
@@ -1175,11 +1175,11 @@ func TestReauthReplacesReportedTags(t *testing.T) {
|
|||||||
for _, tags := range [][]string{{"tag:a"}, nil, {"tag:b"}} {
|
for _, tags := range [][]string{{"tag:a"}, nil, {"tag:b"}} {
|
||||||
want := slices.Clone(tags)
|
want := slices.Clone(tags)
|
||||||
authID := types.MustAuthID()
|
authID := types.MustAuthID()
|
||||||
s.SetAuthCacheEntry(authID, types.NewRegisterAuthRequest(&types.RegistrationData{
|
require.NoError(t, s.SetAuthCacheEntry(authID, types.NewRegisterAuthRequest(&types.RegistrationData{
|
||||||
MachineKey: node.MachineKey(), NodeKey: key.NewNode().Public(),
|
MachineKey: node.MachineKey(), NodeKey: key.NewNode().Public(),
|
||||||
Hostname: node.Hostname(),
|
Hostname: node.Hostname(),
|
||||||
Hostinfo: &tailcfg.Hostinfo{Hostname: node.Hostname(), RequestTags: tags},
|
Hostinfo: &tailcfg.Hostinfo{Hostname: node.Hostname(), RequestTags: tags},
|
||||||
}))
|
})))
|
||||||
relogged, _, err := s.HandleNodeFromAuthPath(authID, userID, nil, util.RegisterMethodOIDC)
|
relogged, _, err := s.HandleNodeFromAuthPath(authID, userID, nil, util.RegisterMethodOIDC)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.Equal(t, nodeID, relogged.ID())
|
require.Equal(t, nodeID, relogged.ID())
|
||||||
@@ -1229,10 +1229,10 @@ func TestReauthRejectsOversizedMergedHostinfo(t *testing.T) {
|
|||||||
require.NoError(t, types.ValidateHostinfo(&tailcfg.Hostinfo{Hostname: hostname}))
|
require.NoError(t, types.ValidateHostinfo(&tailcfg.Hostinfo{Hostname: hostname}))
|
||||||
|
|
||||||
authID := types.MustAuthID()
|
authID := types.MustAuthID()
|
||||||
s.SetAuthCacheEntry(authID, types.NewRegisterAuthRequest(&types.RegistrationData{
|
require.NoError(t, s.SetAuthCacheEntry(authID, types.NewRegisterAuthRequest(&types.RegistrationData{
|
||||||
MachineKey: node.MachineKey(), NodeKey: key.NewNode().Public(),
|
MachineKey: node.MachineKey(), NodeKey: key.NewNode().Public(),
|
||||||
Hostname: hostname, Hostinfo: &tailcfg.Hostinfo{Hostname: hostname},
|
Hostname: hostname, Hostinfo: &tailcfg.Hostinfo{Hostname: hostname},
|
||||||
}))
|
})))
|
||||||
_, _, err = s.HandleNodeFromAuthPath(authID, node.TypedUserID(), nil, util.RegisterMethodOIDC)
|
_, _, err = s.HandleNodeFromAuthPath(authID, node.TypedUserID(), nil, util.RegisterMethodOIDC)
|
||||||
require.ErrorIs(t, err, types.ErrHostinfoTooLarge)
|
require.ErrorIs(t, err, types.ErrHostinfoTooLarge)
|
||||||
|
|
||||||
|
|||||||
+112
-28
@@ -50,11 +50,14 @@ const (
|
|||||||
// registerCacheExpiration defines how long node registration entries remain in cache.
|
// registerCacheExpiration defines how long node registration entries remain in cache.
|
||||||
registerCacheExpiration = time.Minute * 15
|
registerCacheExpiration = time.Minute * 15
|
||||||
|
|
||||||
// defaultRegisterCacheMaxEntries is the default upper bound on the number
|
// defaultRegisterCacheMaxEntries bounds pending authentication transactions.
|
||||||
// of pending registration entries the auth cache will hold. Entries keep
|
// Admission refuses new work at capacity instead of invalidating approvals.
|
||||||
// only hostname, OS and requested tags.
|
|
||||||
defaultRegisterCacheMaxEntries = 1024
|
defaultRegisterCacheMaxEntries = 1024
|
||||||
|
|
||||||
|
// authCacheSSHReserve keeps registered nodes' SSH checks available when
|
||||||
|
// unauthenticated registrations have filled their allocation.
|
||||||
|
authCacheSSHReserve = 64
|
||||||
|
|
||||||
// defaultNodeStoreBatchSize is the default number of write operations to batch
|
// defaultNodeStoreBatchSize is the default number of write operations to batch
|
||||||
// before rebuilding the in-memory node snapshot.
|
// before rebuilding the in-memory node snapshot.
|
||||||
defaultNodeStoreBatchSize = 100
|
defaultNodeStoreBatchSize = 100
|
||||||
@@ -115,6 +118,18 @@ var nodeUpdateColumns = []string{
|
|||||||
// ErrRegistrationExpired is returned when a registration has expired.
|
// ErrRegistrationExpired is returned when a registration has expired.
|
||||||
var ErrRegistrationExpired = errors.New("registration expired")
|
var ErrRegistrationExpired = errors.New("registration expired")
|
||||||
|
|
||||||
|
// ErrAuthCacheFull is returned when pending authentication has reached capacity.
|
||||||
|
var ErrAuthCacheFull = errors.New("authentication capacity reached; retry later")
|
||||||
|
|
||||||
|
// ErrAuthCacheEntryExists prevents replacing an existing authentication binding.
|
||||||
|
var ErrAuthCacheEntryExists = errors.New("authentication request already exists")
|
||||||
|
|
||||||
|
// ErrAuthCacheClosed prevents admitting authentication after shutdown.
|
||||||
|
var ErrAuthCacheClosed = errors.New("authentication cache closed")
|
||||||
|
|
||||||
|
// ErrAuthRequestFinished prevents reusing an already resolved verdict channel.
|
||||||
|
var ErrAuthRequestFinished = errors.New("authentication request already finished")
|
||||||
|
|
||||||
// ErrNodeKeyInUse is returned when a registration or re-auth claims a NodeKey
|
// ErrNodeKeyInUse is returned when a registration or re-auth claims a NodeKey
|
||||||
// already bound to a different machine, enforcing the 1:1 NodeKey<->MachineKey
|
// already bound to a different machine, enforcing the 1:1 NodeKey<->MachineKey
|
||||||
// binding.
|
// binding.
|
||||||
@@ -156,12 +171,14 @@ type State struct {
|
|||||||
// polMan handles policy evaluation and management
|
// polMan handles policy evaluation and management
|
||||||
polMan policy.PolicyManager
|
polMan policy.PolicyManager
|
||||||
|
|
||||||
// authCache holds any pending authentication requests from either auth
|
// authCache holds pending Web, OIDC and SSH authentication requests.
|
||||||
// type (Web and OIDC). It is a bounded LRU keyed by AuthID; oldest
|
// Admission preserves unexpired entries at capacity. Expiry resolves the
|
||||||
// entries are evicted once the size cap is reached, and entries that
|
// verdict with ErrRegistrationExpired so waiting goroutines wake.
|
||||||
// time out have their auth verdict resolved with ErrRegistrationExpired
|
authCache *expirable.LRU[types.AuthID, *types.AuthRequest]
|
||||||
// via the eviction callback so any waiting goroutines wake.
|
authCacheMu sync.Mutex
|
||||||
authCache *expirable.LRU[types.AuthID, *types.AuthRequest]
|
authCacheMaxEntries int
|
||||||
|
authCacheClosed bool
|
||||||
|
authCacheRegularEntries atomic.Int64
|
||||||
|
|
||||||
// pings tracks pending ping requests and their response channels.
|
// pings tracks pending ping requests and their response channels.
|
||||||
pings *pingTracker
|
pings *pingTracker
|
||||||
@@ -360,29 +377,33 @@ func NewState(cfg *types.Config) (*State, error) {
|
|||||||
)
|
)
|
||||||
nodeStore.Start()
|
nodeStore.Start()
|
||||||
|
|
||||||
// Last: NewLRU starts the TTL sweeper goroutine, which only Close stops.
|
|
||||||
authCache := expirable.NewLRU[types.AuthID, *types.AuthRequest](
|
|
||||||
cacheMaxEntries,
|
|
||||||
func(id types.AuthID, rn *types.AuthRequest) {
|
|
||||||
rn.FinishAuth(types.AuthVerdict{Err: ErrRegistrationExpired})
|
|
||||||
},
|
|
||||||
cacheExpiration,
|
|
||||||
)
|
|
||||||
|
|
||||||
s := &State{
|
s := &State{
|
||||||
cfg: cfg,
|
cfg: cfg,
|
||||||
|
|
||||||
db: db,
|
db: db,
|
||||||
ipAlloc: ipAlloc,
|
ipAlloc: ipAlloc,
|
||||||
polMan: polMan,
|
polMan: polMan,
|
||||||
authCache: authCache,
|
authCacheMaxEntries: cacheMaxEntries,
|
||||||
nodeStore: nodeStore,
|
nodeStore: nodeStore,
|
||||||
pings: newPingTracker(),
|
pings: newPingTracker(),
|
||||||
|
|
||||||
sshCheckAuth: make(map[sshCheckPair]time.Time),
|
sshCheckAuth: make(map[sshCheckPair]time.Time),
|
||||||
registerLocks: xsync.NewMap[key.MachinePublic, *registerLock](),
|
registerLocks: xsync.NewMap[key.MachinePublic, *registerLock](),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Last: NewLRU starts the TTL sweeper goroutine, which only Close stops.
|
||||||
|
s.authCache = expirable.NewLRU[types.AuthID, *types.AuthRequest](
|
||||||
|
cacheMaxEntries+authCacheSSHReserve,
|
||||||
|
func(_ types.AuthID, rn *types.AuthRequest) {
|
||||||
|
if !rn.IsSSHCheck() {
|
||||||
|
s.authCacheRegularEntries.Add(-1)
|
||||||
|
}
|
||||||
|
|
||||||
|
rn.FinishAuth(types.AuthVerdict{Err: ErrRegistrationExpired})
|
||||||
|
},
|
||||||
|
cacheExpiration,
|
||||||
|
)
|
||||||
|
|
||||||
// Surface nodes whose stored data would break map generation (e.g. an
|
// Surface nodes whose stored data would break map generation (e.g. an
|
||||||
// invalid given name from a legacy row) so an operator can fix them. This
|
// invalid given name from a legacy row) so an operator can fix them. This
|
||||||
// only logs; it never mutates a node's stored name at boot.
|
// only logs; it never mutates a node's stored name at boot.
|
||||||
@@ -393,6 +414,10 @@ func NewState(cfg *types.Config) (*State, error) {
|
|||||||
|
|
||||||
// Close gracefully shuts down the [State] instance and releases all resources.
|
// Close gracefully shuts down the [State] instance and releases all resources.
|
||||||
func (s *State) Close() error {
|
func (s *State) Close() error {
|
||||||
|
s.authCacheMu.Lock()
|
||||||
|
s.authCacheClosed = true
|
||||||
|
s.authCacheMu.Unlock()
|
||||||
|
|
||||||
s.pings.drain()
|
s.pings.drain()
|
||||||
s.nodeStore.Stop()
|
s.nodeStore.Stop()
|
||||||
// Purge before Close: Close stops the sweeper, so anything still cached
|
// Purge before Close: Close stops the sweeper, so anything still cached
|
||||||
@@ -1690,12 +1715,71 @@ func (s *State) DeletePreAuthKey(id uint64) error {
|
|||||||
|
|
||||||
// GetAuthCacheEntry retrieves a pending auth request from the cache.
|
// GetAuthCacheEntry retrieves a pending auth request from the cache.
|
||||||
func (s *State) GetAuthCacheEntry(id types.AuthID) (*types.AuthRequest, bool) {
|
func (s *State) GetAuthCacheEntry(id types.AuthID) (*types.AuthRequest, bool) {
|
||||||
return s.authCache.Get(id)
|
// Preserve insertion order so admission can reclaim expired entries from
|
||||||
|
// the oldest end without scanning every pending authentication.
|
||||||
|
return s.authCache.Peek(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetAuthCacheEntry stores a pending auth request in the cache.
|
// SetAuthCacheEntry admits a pending authentication without displacing existing
|
||||||
func (s *State) SetAuthCacheEntry(id types.AuthID, entry *types.AuthRequest) {
|
// transactions. Re-admitting the same request does not extend its lifetime.
|
||||||
|
func (s *State) SetAuthCacheEntry(id types.AuthID, entry *types.AuthRequest) error {
|
||||||
|
s.authCacheMu.Lock()
|
||||||
|
defer s.authCacheMu.Unlock()
|
||||||
|
|
||||||
|
if s.authCacheClosed {
|
||||||
|
return ErrAuthCacheClosed
|
||||||
|
}
|
||||||
|
|
||||||
|
if existing, ok := s.authCache.Peek(id); ok {
|
||||||
|
if existing == entry {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return ErrAuthCacheEntryExists
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove an expired entry with this ID before Add can overwrite its waiter.
|
||||||
|
s.authCache.Remove(id)
|
||||||
|
|
||||||
|
if entry.IsFinished() {
|
||||||
|
return ErrAuthRequestFinished
|
||||||
|
}
|
||||||
|
|
||||||
|
for {
|
||||||
|
oldest, _, ok := s.authCache.GetOldest()
|
||||||
|
if !ok {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, live := s.authCache.Peek(oldest); live {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
s.authCache.Remove(oldest)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!entry.IsSSHCheck() && s.authCacheRegularEntries.Load() >= int64(s.authCacheMaxEntries)) ||
|
||||||
|
s.authCache.Len() >= s.authCacheMaxEntries+authCacheSSHReserve {
|
||||||
|
return ErrAuthCacheFull
|
||||||
|
}
|
||||||
|
|
||||||
|
err := entry.PrepareForCache()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if !entry.IsSSHCheck() {
|
||||||
|
s.authCacheRegularEntries.Add(1)
|
||||||
|
}
|
||||||
|
|
||||||
s.authCache.Add(id, entry)
|
s.authCache.Add(id, entry)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteAuthCacheEntry releases a consumed authentication transaction.
|
||||||
|
func (s *State) DeleteAuthCacheEntry(id types.AuthID) {
|
||||||
|
s.authCache.Remove(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
// DeleteAuthCacheEntryForTest drops a pending auth request from the cache,
|
// DeleteAuthCacheEntryForTest drops a pending auth request from the cache,
|
||||||
@@ -1703,7 +1787,7 @@ func (s *State) SetAuthCacheEntry(id types.AuthID, entry *types.AuthRequest) {
|
|||||||
// (expired, evicted, or dropped on a control-plane restart) without faking an
|
// (expired, evicted, or dropped on a control-plane restart) without faking an
|
||||||
// auth_id.
|
// auth_id.
|
||||||
func (s *State) DeleteAuthCacheEntryForTest(id types.AuthID) {
|
func (s *State) DeleteAuthCacheEntryForTest(id types.AuthID) {
|
||||||
s.authCache.Remove(id)
|
s.DeleteAuthCacheEntry(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetLastSSHAuth records a successful SSH check authentication
|
// SetLastSSHAuth records a successful SSH check authentication
|
||||||
|
|||||||
+60
-15
@@ -4,6 +4,7 @@ package types
|
|||||||
//go:generate go run tailscale.com/cmd/viewer --type=User,Node,PreAuthKey
|
//go:generate go run tailscale.com/cmd/viewer --type=User,Node,PreAuthKey
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"runtime"
|
"runtime"
|
||||||
@@ -23,9 +24,10 @@ const (
|
|||||||
|
|
||||||
// Common errors.
|
// Common errors.
|
||||||
var (
|
var (
|
||||||
ErrCannotParsePrefix = errors.New("cannot parse prefix")
|
ErrCannotParsePrefix = errors.New("cannot parse prefix")
|
||||||
ErrInvalidAuthIDLength = errors.New("auth ID has invalid length")
|
ErrInvalidAuthIDLength = errors.New("auth ID has invalid length")
|
||||||
ErrInvalidAuthIDPrefix = errors.New("auth ID has invalid prefix")
|
ErrInvalidAuthIDPrefix = errors.New("auth ID has invalid prefix")
|
||||||
|
ErrRegistrationPayloadTooLarge = errors.New("registration payload exceeds size limit")
|
||||||
)
|
)
|
||||||
|
|
||||||
type StateUpdateType int
|
type StateUpdateType int
|
||||||
@@ -248,7 +250,7 @@ type PendingRegistrationConfirmation struct {
|
|||||||
|
|
||||||
// AuthRequest represents a pending authentication request from a user or a
|
// AuthRequest represents a pending authentication request from a user or a
|
||||||
// node. It carries the minimum data needed to either complete a node
|
// node. It carries the minimum data needed to either complete a node
|
||||||
// registration (regData populated) or an SSH check-mode auth (sshBinding
|
// registration (regData or regDataJSON populated) or an SSH check-mode auth (sshBinding
|
||||||
// populated), and signals the verdict via the finished channel. The closed
|
// populated), and signals the verdict via the finished channel. The closed
|
||||||
// flag guards [AuthRequest.FinishAuth] against double-close.
|
// flag guards [AuthRequest.FinishAuth] against double-close.
|
||||||
//
|
//
|
||||||
@@ -256,14 +258,14 @@ type PendingRegistrationConfirmation struct {
|
|||||||
// have a single canonical instance even when stored in caches that
|
// have a single canonical instance even when stored in caches that
|
||||||
// internally copy values.
|
// internally copy values.
|
||||||
type AuthRequest struct {
|
type AuthRequest struct {
|
||||||
// regData is populated for node-registration flows (interactive web
|
// regData holds the caller's registration payload until PrepareForCache
|
||||||
// or OIDC). It carries the cached registration payload that the
|
// replaces it with owned serialized data and releases the original pointer.
|
||||||
// auth callback uses to promote this request into a real node.
|
|
||||||
//
|
|
||||||
// nil for non-registration flows. Use [AuthRequest.RegistrationData] to read it
|
|
||||||
// safely.
|
|
||||||
regData *RegistrationData
|
regData *RegistrationData
|
||||||
|
|
||||||
|
// regDataJSON is immutable and bounded by MaxRegistrationPayloadBytes.
|
||||||
|
// RegistrationData decodes a fresh copy without retaining the decoded graph.
|
||||||
|
regDataJSON string
|
||||||
|
|
||||||
// sshBinding is populated for SSH check-mode flows. It captures the
|
// sshBinding is populated for SSH check-mode flows. It captures the
|
||||||
// (src, dst) node pair the request was minted for so the follow-up
|
// (src, dst) node pair the request was minted for so the follow-up
|
||||||
// and OIDC callback can refuse to record a verdict for any other
|
// and OIDC callback can refuse to record a verdict for any other
|
||||||
@@ -299,7 +301,8 @@ func NewAuthRequest() *AuthRequest {
|
|||||||
|
|
||||||
// NewRegisterAuthRequest creates a pending auth request carrying the
|
// NewRegisterAuthRequest creates a pending auth request carrying the
|
||||||
// minimal [RegistrationData] for a node-registration flow. The data is
|
// minimal [RegistrationData] for a node-registration flow. The data is
|
||||||
// stored by pointer; callers must not mutate it after handing it off.
|
// stored by pointer until [AuthRequest.PrepareForCache] detaches it; callers
|
||||||
|
// must not mutate it until preparation finishes.
|
||||||
func NewRegisterAuthRequest(data *RegistrationData) *AuthRequest {
|
func NewRegisterAuthRequest(data *RegistrationData) *AuthRequest {
|
||||||
return &AuthRequest{
|
return &AuthRequest{
|
||||||
regData: data,
|
regData: data,
|
||||||
@@ -308,6 +311,31 @@ func NewRegisterAuthRequest(data *RegistrationData) *AuthRequest {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PrepareForCache replaces the registration payload with an owned serialized
|
||||||
|
// value of at most [MaxRegistrationPayloadBytes]. It must run before publishing
|
||||||
|
// the request to the cache. Serialization and size errors leave it unchanged;
|
||||||
|
// callers must reject admission on error. Repeated calls and non-registration
|
||||||
|
// requests are no-ops.
|
||||||
|
func (rn *AuthRequest) PrepareForCache() error {
|
||||||
|
if rn.regData == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := json.Marshal(rn.regData)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("serializing pending registration: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(data) > MaxRegistrationPayloadBytes {
|
||||||
|
return fmt.Errorf("%w: limit %d bytes", ErrRegistrationPayloadTooLarge, MaxRegistrationPayloadBytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
rn.regDataJSON = string(data)
|
||||||
|
rn.regData = nil
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// NewSSHCheckAuthRequest creates a pending auth request bound to a
|
// NewSSHCheckAuthRequest creates a pending auth request bound to a
|
||||||
// specific (src, dst) SSH check-mode pair. The follow-up handler and
|
// specific (src, dst) SSH check-mode pair. The follow-up handler and
|
||||||
// OIDC callback must verify their incoming request matches this binding
|
// OIDC callback must verify their incoming request matches this binding
|
||||||
@@ -323,15 +351,27 @@ func NewSSHCheckAuthRequest(src, dst NodeID) *AuthRequest {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// RegistrationData returns the cached registration payload. It panics if
|
// RegistrationData returns the original registration payload before preparation,
|
||||||
|
// or a fresh decoded copy after [AuthRequest.PrepareForCache]. It panics if
|
||||||
// called on an [AuthRequest] that was not created via
|
// called on an [AuthRequest] that was not created via
|
||||||
// [NewRegisterAuthRequest].
|
// [NewRegisterAuthRequest].
|
||||||
func (rn *AuthRequest) RegistrationData() *RegistrationData {
|
func (rn *AuthRequest) RegistrationData() *RegistrationData {
|
||||||
if rn.regData == nil {
|
if rn.regData != nil {
|
||||||
|
return rn.regData
|
||||||
|
}
|
||||||
|
|
||||||
|
if rn.regDataJSON == "" {
|
||||||
panic("RegistrationData can only be used in registration requests")
|
panic("RegistrationData can only be used in registration requests")
|
||||||
}
|
}
|
||||||
|
|
||||||
return rn.regData
|
var data RegistrationData
|
||||||
|
|
||||||
|
err := json.Unmarshal([]byte(rn.regDataJSON), &data)
|
||||||
|
if err != nil {
|
||||||
|
panic(fmt.Sprintf("decoding prepared registration: %v", err))
|
||||||
|
}
|
||||||
|
|
||||||
|
return &data
|
||||||
}
|
}
|
||||||
|
|
||||||
// SSHCheckBinding returns the (src, dst) node pair an SSH check-mode
|
// SSHCheckBinding returns the (src, dst) node pair an SSH check-mode
|
||||||
@@ -348,7 +388,7 @@ func (rn *AuthRequest) SSHCheckBinding() *SSHCheckBinding {
|
|||||||
// IsRegistration reports whether this auth request carries registration
|
// IsRegistration reports whether this auth request carries registration
|
||||||
// data (i.e. it was created via [NewRegisterAuthRequest]).
|
// data (i.e. it was created via [NewRegisterAuthRequest]).
|
||||||
func (rn *AuthRequest) IsRegistration() bool {
|
func (rn *AuthRequest) IsRegistration() bool {
|
||||||
return rn.regData != nil
|
return rn.regData != nil || rn.regDataJSON != ""
|
||||||
}
|
}
|
||||||
|
|
||||||
// IsSSHCheck reports whether this auth request is bound to an SSH
|
// IsSSHCheck reports whether this auth request is bound to an SSH
|
||||||
@@ -358,6 +398,11 @@ func (rn *AuthRequest) IsSSHCheck() bool {
|
|||||||
return rn.sshBinding != nil
|
return rn.sshBinding != nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// IsFinished reports whether a verdict has already resolved this request.
|
||||||
|
func (rn *AuthRequest) IsFinished() bool {
|
||||||
|
return rn.closed.Load()
|
||||||
|
}
|
||||||
|
|
||||||
// SetPendingConfirmation marks this [AuthRequest] as having an
|
// SetPendingConfirmation marks this [AuthRequest] as having an
|
||||||
// OIDC-resolved user that is waiting to confirm the registration on
|
// OIDC-resolved user that is waiting to confirm the registration on
|
||||||
// the interstitial. The OIDC callback should call this and then render
|
// the interstitial. The OIDC callback should call this and then render
|
||||||
|
|||||||
@@ -1,10 +1,18 @@
|
|||||||
package types
|
package types
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"math"
|
||||||
|
"net/netip"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
|
"tailscale.com/tailcfg"
|
||||||
|
"tailscale.com/types/key"
|
||||||
)
|
)
|
||||||
|
|
||||||
// TestNewSSHCheckAuthRequestBinding verifies that an SSH-check [AuthRequest]
|
// TestNewSSHCheckAuthRequestBinding verifies that an SSH-check [AuthRequest]
|
||||||
@@ -44,6 +52,195 @@ func TestNewRegisterAuthRequestPayload(t *testing.T) {
|
|||||||
}, "SSHCheckBinding() must panic on a registration AuthRequest")
|
}, "SSHCheckBinding() must panic on a registration AuthRequest")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestAuthRequestPrepareForCacheOwnsRegistration(t *testing.T) {
|
||||||
|
expiry := time.Date(2027, time.January, 2, 3, 4, 5, 6, time.UTC)
|
||||||
|
data := &RegistrationData{
|
||||||
|
MachineKey: key.NewMachine().Public(),
|
||||||
|
NodeKey: key.NewNode().Public(),
|
||||||
|
DiscoKey: key.NewDisco().Public(),
|
||||||
|
Hostname: "router-a",
|
||||||
|
Hostinfo: &tailcfg.Hostinfo{
|
||||||
|
Hostname: "router-a",
|
||||||
|
IPNVersion: "1.104.0",
|
||||||
|
OS: "linux",
|
||||||
|
OSVersion: "6.18.0",
|
||||||
|
Distro: "nixos",
|
||||||
|
Container: "false",
|
||||||
|
Userspace: "true",
|
||||||
|
RequestTags: []string{"tag:router", "tag:production"},
|
||||||
|
RoutableIPs: []netip.Prefix{netip.MustParsePrefix("10.0.0.0/24"), netip.MustParsePrefix("2001:db8::/64")},
|
||||||
|
WoLMACs: []string{"00:11:22:33:44:55"},
|
||||||
|
Services: []tailcfg.Service{{Proto: tailcfg.TCP, Port: 22, Description: "ssh"}},
|
||||||
|
SSH_HostKeys: []string{"ssh-ed25519 initial-host-key"},
|
||||||
|
NetInfo: &tailcfg.NetInfo{
|
||||||
|
WorkingUDP: "true",
|
||||||
|
WorkingIPv6: "false",
|
||||||
|
PreferredDERP: 1,
|
||||||
|
DERPLatency: map[string]float64{"1-v4": 0.01, "2-v6": 0.02},
|
||||||
|
},
|
||||||
|
Location: &tailcfg.Location{Country: "Norway", City: "Tromsø", Latitude: 69.65, Longitude: 18.96},
|
||||||
|
TPM: &tailcfg.TPMInfo{Manufacturer: "MSFT", Model: 1},
|
||||||
|
},
|
||||||
|
Endpoints: []netip.AddrPort{netip.MustParseAddrPort("192.0.2.1:41641"), netip.MustParseAddrPort("[2001:db8::1]:41641")},
|
||||||
|
Expiry: &expiry,
|
||||||
|
}
|
||||||
|
want := *data
|
||||||
|
want.Hostinfo = data.Hostinfo.Clone()
|
||||||
|
want.Endpoints = append([]netip.AddrPort(nil), data.Endpoints...)
|
||||||
|
wantExpiry := expiry
|
||||||
|
want.Expiry = &wantExpiry
|
||||||
|
|
||||||
|
req := NewRegisterAuthRequest(data)
|
||||||
|
require.NoError(t, req.PrepareForCache())
|
||||||
|
assert.True(t, req.IsRegistration())
|
||||||
|
assert.False(t, req.IsSSHCheck())
|
||||||
|
assert.Nil(t, req.regData, "prepared requests must release the decoded payload")
|
||||||
|
assert.Equal(t, &want, req.RegistrationData())
|
||||||
|
|
||||||
|
data.Hostname = "changed"
|
||||||
|
data.Hostinfo.RequestTags[0] = "tag:changed"
|
||||||
|
data.Hostinfo.RoutableIPs[0] = netip.MustParsePrefix("192.0.2.0/24")
|
||||||
|
data.Hostinfo.Services[0].Description = "changed"
|
||||||
|
data.Hostinfo.NetInfo.DERPLatency["1-v4"] = 1
|
||||||
|
data.Hostinfo.Location.City = "changed"
|
||||||
|
data.Hostinfo.TPM.Manufacturer = "changed"
|
||||||
|
data.Endpoints[0] = netip.MustParseAddrPort("192.0.2.2:1")
|
||||||
|
*data.Expiry = time.Time{}
|
||||||
|
|
||||||
|
assert.Equal(t, &want, req.RegistrationData(), "the producer must not retain ownership of cached data")
|
||||||
|
|
||||||
|
got := req.RegistrationData()
|
||||||
|
got.Hostname = "changed by consumer"
|
||||||
|
got.Hostinfo.RequestTags[0] = "tag:consumer"
|
||||||
|
got.Hostinfo.Services[0].Description = "changed by consumer"
|
||||||
|
got.Hostinfo.NetInfo.DERPLatency["1-v4"] = 2
|
||||||
|
got.Endpoints[0] = netip.MustParseAddrPort("192.0.2.3:1")
|
||||||
|
*got.Expiry = time.Time{}
|
||||||
|
|
||||||
|
require.NoError(t, req.PrepareForCache())
|
||||||
|
assert.Equal(t, &want, req.RegistrationData(), "access and repeated preparation must leave cached data unchanged")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAuthRequestPrepareForCachePayloadLimit(t *testing.T) {
|
||||||
|
const limit = MaxRegistrationPayloadBytes
|
||||||
|
|
||||||
|
for _, test := range []struct {
|
||||||
|
name string
|
||||||
|
size int
|
||||||
|
}{
|
||||||
|
{name: "below limit", size: limit - 1},
|
||||||
|
{name: "exact limit", size: limit},
|
||||||
|
{name: "over limit", size: limit + 1},
|
||||||
|
} {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
data := &RegistrationData{Hostinfo: &tailcfg.Hostinfo{OS: "linux"}}
|
||||||
|
base, err := json.Marshal(data)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
data.Hostinfo.OS = strings.Repeat("a", test.size-len(base)+len(data.Hostinfo.OS))
|
||||||
|
encoded, err := json.Marshal(data)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, encoded, test.size)
|
||||||
|
|
||||||
|
req := NewRegisterAuthRequest(data)
|
||||||
|
|
||||||
|
err = req.PrepareForCache()
|
||||||
|
if test.size > limit {
|
||||||
|
require.ErrorIs(t, err, ErrRegistrationPayloadTooLarge, "oversize payloads must be rejected instead of truncated")
|
||||||
|
assert.Empty(t, req.regDataJSON)
|
||||||
|
assert.Same(t, data, req.RegistrationData(), "failed preparation must leave the original data intact")
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Nil(t, req.regData)
|
||||||
|
assert.Len(t, req.regDataJSON, test.size)
|
||||||
|
assert.Equal(t, data, req.RegistrationData())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAuthRequestPrepareForCacheRouteAndServiceMetadata(t *testing.T) {
|
||||||
|
data := &RegistrationData{
|
||||||
|
MachineKey: key.NewMachine().Public(),
|
||||||
|
NodeKey: key.NewNode().Public(),
|
||||||
|
Hostname: "regional-router",
|
||||||
|
Hostinfo: &tailcfg.Hostinfo{
|
||||||
|
Hostname: "regional-router",
|
||||||
|
OS: "linux",
|
||||||
|
OSVersion: "6.18.0",
|
||||||
|
IPNVersion: "1.104.0",
|
||||||
|
RequestTags: []string{"tag:router", "tag:production"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for i := range 128 {
|
||||||
|
data.Hostinfo.RoutableIPs = append(data.Hostinfo.RoutableIPs,
|
||||||
|
netip.MustParsePrefix(fmt.Sprintf("10.42.%d.0/24", i)),
|
||||||
|
netip.MustParsePrefix(fmt.Sprintf("2001:db8:%x::/64", i)))
|
||||||
|
}
|
||||||
|
|
||||||
|
for port := uint16(8000); port < 8064; port++ {
|
||||||
|
data.Hostinfo.Services = append(data.Hostinfo.Services, tailcfg.Service{
|
||||||
|
Proto: tailcfg.TCP,
|
||||||
|
Port: port,
|
||||||
|
Description: fmt.Sprintf("regional-service-%d", port),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
req := NewRegisterAuthRequest(data)
|
||||||
|
require.NoError(t, req.PrepareForCache())
|
||||||
|
assert.Nil(t, req.regData)
|
||||||
|
assert.LessOrEqual(t, len(req.regDataJSON), MaxRegistrationPayloadBytes)
|
||||||
|
assert.Equal(t, data, req.RegistrationData())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAuthRequestPrepareForCacheInvalidPayload(t *testing.T) {
|
||||||
|
invalidExpiry := time.Date(10000, time.January, 1, 0, 0, 0, 0, time.UTC)
|
||||||
|
for _, test := range []struct {
|
||||||
|
name string
|
||||||
|
data *RegistrationData
|
||||||
|
}{
|
||||||
|
{name: "invalid expiry", data: &RegistrationData{Expiry: &invalidExpiry}},
|
||||||
|
{name: "invalid latency", data: &RegistrationData{Hostinfo: &tailcfg.Hostinfo{
|
||||||
|
NetInfo: &tailcfg.NetInfo{DERPLatency: map[string]float64{"1-v4": math.NaN()}},
|
||||||
|
}}},
|
||||||
|
} {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
req := NewRegisterAuthRequest(test.data)
|
||||||
|
require.Error(t, req.PrepareForCache())
|
||||||
|
assert.Empty(t, req.regDataJSON)
|
||||||
|
assert.Same(t, test.data, req.RegistrationData())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAuthRequestPrepareForCacheNilValues(t *testing.T) {
|
||||||
|
for _, data := range []*RegistrationData{
|
||||||
|
{},
|
||||||
|
{Hostinfo: &tailcfg.Hostinfo{}},
|
||||||
|
{Hostinfo: &tailcfg.Hostinfo{NetInfo: &tailcfg.NetInfo{}}},
|
||||||
|
{Expiry: &time.Time{}},
|
||||||
|
{Endpoints: []netip.AddrPort{}},
|
||||||
|
} {
|
||||||
|
req := NewRegisterAuthRequest(data)
|
||||||
|
require.NoError(t, req.PrepareForCache())
|
||||||
|
assert.True(t, req.IsRegistration())
|
||||||
|
assert.Equal(t, data, req.RegistrationData())
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, req := range []*AuthRequest{NewAuthRequest(), NewRegisterAuthRequest(nil), NewSSHCheckAuthRequest(7, 11)} {
|
||||||
|
require.NoError(t, req.PrepareForCache())
|
||||||
|
require.NoError(t, req.PrepareForCache())
|
||||||
|
assert.False(t, req.IsRegistration())
|
||||||
|
assert.Panics(t, func() { _ = req.RegistrationData() })
|
||||||
|
|
||||||
|
if req.IsSSHCheck() {
|
||||||
|
assert.Equal(t, &SSHCheckBinding{SrcNodeID: 7, DstNodeID: 11}, req.SSHCheckBinding())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// TestNewAuthRequestEmptyPayload verifies that a payload-less
|
// TestNewAuthRequestEmptyPayload verifies that a payload-less
|
||||||
// [AuthRequest] reports both Is* helpers as false and panics on either
|
// [AuthRequest] reports both Is* helpers as false and panics on either
|
||||||
// payload accessor.
|
// payload accessor.
|
||||||
|
|||||||
@@ -316,9 +316,9 @@ type Tuning struct {
|
|||||||
// valid before being eligible for eviction.
|
// valid before being eligible for eviction.
|
||||||
RegisterCacheExpiration time.Duration
|
RegisterCacheExpiration time.Duration
|
||||||
|
|
||||||
// RegisterCacheMaxEntries bounds the number of pending registration
|
// RegisterCacheMaxEntries bounds pending registrations. At capacity, new
|
||||||
// entries the auth cache will hold. Older entries are evicted (LRU)
|
// registrations are refused without evicting admitted sessions. SSH checks
|
||||||
// when the cap is reached, preventing unauthenticated cache-fill DoS.
|
// can also use 64 reserved slots beyond this limit.
|
||||||
// A value of 0 falls back to defaultRegisterCacheMaxEntries (1024).
|
// A value of 0 falls back to defaultRegisterCacheMaxEntries (1024).
|
||||||
RegisterCacheMaxEntries int
|
RegisterCacheMaxEntries int
|
||||||
|
|
||||||
|
|||||||
@@ -8,14 +8,25 @@ import (
|
|||||||
"tailscale.com/types/key"
|
"tailscale.com/types/key"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// MaxRegistrationPayloadBytes limits the owned serialized registration payload
|
||||||
|
// retained by an [AuthRequest] after [AuthRequest.PrepareForCache]. It covers
|
||||||
|
// the authentication metadata and the minimal registration Hostinfo.
|
||||||
|
// It bounds retained serialized bytes, not transient encoding or decoded heap
|
||||||
|
// usage. A cache of 1024 entries retains at most 64 MiB of these payloads plus
|
||||||
|
// record overhead; a different entry limit scales that budget.
|
||||||
|
const MaxRegistrationPayloadBytes = 64 << 10
|
||||||
|
|
||||||
// RegistrationData is the payload cached for a pending node registration.
|
// RegistrationData is the payload cached for a pending node registration.
|
||||||
// It replaces the previous practice of caching a full *[Node] and carries
|
// It replaces the previous practice of caching a full *[Node] and carries
|
||||||
// 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.
|
||||||
//
|
//
|
||||||
// Any client can open a pending registration. The bounded-LRU cache that
|
// Any client can open a pending registration. Cache admission caps the number
|
||||||
// holds these entries caps their number. Hostinfo retains only auth and
|
// of pending entries. Hostinfo retains only auth and
|
||||||
// confirmation metadata.
|
// confirmation metadata, bounded by [ValidateHostinfo] before caching.
|
||||||
|
//
|
||||||
|
// [AuthRequest.PrepareForCache] retains an owned serialized representation
|
||||||
|
// bounded by [MaxRegistrationPayloadBytes], releasing this decoded payload.
|
||||||
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.
|
||||||
|
|||||||
Reference in new issue
Block a user