Skip to content

Commit f2bef17

Browse files
committed
hscontrol: prefer completed auth over expired ctx in followup wait
waitForFollowup selected on ctx.Done() and the verdict channel with equal priority; when both were ready, select picked at random and discarded a successful registration as a spurious 401 timeout. Check for a completed verdict first, race the deadline only if none is ready. Fixes #3385
1 parent f20f1f1 commit f2bef17

2 files changed

Lines changed: 87 additions & 10 deletions

File tree

hscontrol/auth.go

Lines changed: 20 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -301,19 +301,29 @@ func (h *Headscale) waitForFollowup(
301301
}
302302

303303
if reg, ok := h.state.GetAuthCacheEntry(followupReg); ok {
304+
var verdict types.AuthVerdict
304305
select {
305-
case <-ctx.Done():
306-
return nil, NewHTTPError(http.StatusUnauthorized, "registration timed out", err)
307-
case verdict := <-reg.WaitForAuth():
308-
if verdict.Accept() {
309-
if !verdict.Node.Valid() {
310-
// registration is expired in the cache, instruct the client to try a new registration
311-
return h.reqToNewRegisterResponse(req, machineKey)
312-
}
313-
314-
return nodeToRegisterResponse(verdict.Node), nil
306+
// Prefer a completed registration even if the context has also
307+
// expired. When both are ready, a plain select picks at random and
308+
// would discard a successful registration as a spurious timeout
309+
// (issue #3385).
310+
case verdict = <-reg.WaitForAuth():
311+
default:
312+
select {
313+
case <-ctx.Done():
314+
return nil, NewHTTPError(http.StatusUnauthorized, "registration timed out", ctx.Err())
315+
case verdict = <-reg.WaitForAuth():
315316
}
316317
}
318+
319+
if verdict.Accept() {
320+
if !verdict.Node.Valid() {
321+
// registration is expired in the cache, instruct the client to try a new registration
322+
return h.reqToNewRegisterResponse(req, machineKey)
323+
}
324+
325+
return nodeToRegisterResponse(verdict.Node), nil
326+
}
317327
}
318328

319329
// if the follow-up registration isn't found anymore, instruct the client to try a new registration

hscontrol/auth_test.go

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4101,3 +4101,70 @@ func TestHandleNodeFromAuthPath_OldUserNil_NoPanic(t *testing.T) {
41014101
assert.NotEqual(t, types.NodeID(99002), node.ID(), "new node, not orphan")
41024102
assert.Equal(t, userB.ID, node.UserID().Get(), "new node belongs to userB")
41034103
}
4104+
4105+
// TestFollowupWaitPrefersCompletedAuthOverExpiredContext reproduces
4106+
// https://github.com/juanfont/headscale/issues/3385.
4107+
//
4108+
// Root cause: [Headscale.waitForFollowup] selects on ctx.Done() and the auth
4109+
// verdict channel with equal priority. When the registration has ALREADY
4110+
// completed (verdict buffered) but the request context has ALSO expired, Go's
4111+
// select picks a ready case at random, so roughly half the time it returns
4112+
// "registration timed out" and discards a successful registration.
4113+
//
4114+
// The v0.28.0 hscontrol test suite hit this because the followup context
4115+
// timeout was only 100ms while the setup goroutine (create user + node in
4116+
// SQLite) frequently took longer on slower/constrained builders (ppc64le,
4117+
// Alpine CI). Both channels ended up ready at once and the flake surfaced as
4118+
// TestAuthenticationFlows/followup_registration_success failing with
4119+
// "http error[401]: registration timed out".
4120+
//
4121+
// The fix must give the completed-auth case priority over context
4122+
// cancellation. This test forces both cases ready on every iteration; it must
4123+
// never report a timeout.
4124+
func TestFollowupWaitPrefersCompletedAuthOverExpiredContext(t *testing.T) {
4125+
app := createTestApp(t)
4126+
4127+
machineKey := key.NewMachine().Public()
4128+
nodeKey := key.NewNode().Public()
4129+
4130+
const iterations = 300
4131+
4132+
timeouts, authorized := 0, 0
4133+
4134+
for i := range iterations {
4135+
regID, err := types.NewAuthID()
4136+
require.NoError(t, err)
4137+
4138+
authReq := types.NewRegisterAuthRequest(&types.RegistrationData{
4139+
Hostname: "followup-race-node",
4140+
})
4141+
app.state.SetAuthCacheEntry(regID, authReq)
4142+
4143+
// Registration completes BEFORE we wait: verdict is buffered.
4144+
user := app.state.CreateUserForTest(fmt.Sprintf("followup-race-user-%d", i))
4145+
node := app.state.CreateNodeForTest(user, "followup-race-node")
4146+
authReq.FinishAuth(types.AuthVerdict{Node: node.View()})
4147+
4148+
// Context is expired BEFORE we wait: both select cases are ready.
4149+
ctx, cancel := context.WithCancel(context.Background())
4150+
cancel()
4151+
4152+
req := tailcfg.RegisterRequest{
4153+
Followup: fmt.Sprintf("http://localhost:8080/register/%s", regID),
4154+
NodeKey: nodeKey,
4155+
}
4156+
4157+
resp, err := app.waitForFollowup(ctx, req, machineKey)
4158+
switch {
4159+
case err != nil:
4160+
timeouts++
4161+
case resp != nil && resp.MachineAuthorized:
4162+
authorized++
4163+
}
4164+
}
4165+
4166+
assert.Zero(t, timeouts,
4167+
"waitForFollowup must never report a timeout when auth has already completed; got %d/%d timeouts",
4168+
timeouts, iterations)
4169+
assert.Equal(t, iterations, authorized, "every completed registration must be returned as authorized")
4170+
}

0 commit comments

Comments
 (0)