state, policy, noise: implement SSH check period auto-approval

Add SSH check period tracking so that recently authenticated users
are auto-approved without requiring manual intervention each time.

Introduce SSHCheckPeriod type with validation (min 1m, max 168h,
"always" for every request) and encode the compiled check period
as URL query parameters in the HoldAndDelegate URL.

The SSHActionHandler checks recorded auth times before creating a
new HoldAndDelegate flow. Auth timestamps are stored in-memory:
- Default period (no explicit checkPeriod): auth covers any
  destination, keyed by source node with Dst=0 sentinel
- Explicit period: auth covers only that specific destination,
  keyed by (source, destination) pair

Auth times are cleared on policy changes.

Updates #1850
This commit is contained in:
Kristoffer Dalby 2026-02-24 18:52:17 +00:00
parent 48cc98b787
commit 7bab8da366
9 changed files with 897 additions and 22 deletions

View file

@ -8,6 +8,7 @@ import (
"io"
"net/http"
"net/url"
"time"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
@ -331,6 +332,7 @@ func (ns *noiseServer) SSHActionHandler(
action, err := ns.sshAction(
reqLog,
srcNodeID, dstNodeID,
req.URL.Query().Get("auth_id"),
)
if err != nil {
@ -356,16 +358,18 @@ func (ns *noiseServer) SSHActionHandler(
}
// sshAction resolves the SSH action for the given request parameters.
// It returns the action to send to the client, or an HTTPError on
// failure.
// It returns the action to send to the client, or an HTTPError on failure.
//
// Two cases:
// 1. Initial request — build a HoldAndDelegate URL and wait for the
// user to authenticate.
// 2. Follow-up request — an auth_id is present, wait for the auth
// Three cases:
// 1. Initial request, auto-approved — source recently authenticated
// within the check period, accept immediately.
// 2. Initial request, needs auth — build a HoldAndDelegate URL and
// wait for the user to authenticate.
// 3. Follow-up request — an auth_id is present, wait for the auth
// verdict and accept or reject.
func (ns *noiseServer) sshAction(
reqLog zerolog.Logger,
srcNodeID, dstNodeID types.NodeID,
authIDStr string,
) (*tailcfg.SSHAction, error) {
action := tailcfg.SSHAction{
@ -374,14 +378,38 @@ func (ns *noiseServer) sshAction(
AllowRemotePortForwarding: true,
}
// Look up check params from the server's own policy rather than
// trusting URL parameters, which the client could tamper with.
checkPeriod, checkFound := ns.headscale.state.SSHCheckParams(
srcNodeID, dstNodeID,
)
// Follow-up request with auth_id — wait for the auth verdict.
if authIDStr != "" {
return ns.sshActionFollowUp(
reqLog, &action, authIDStr,
srcNodeID, dstNodeID,
checkFound,
)
}
// Initial request — create an auth session and hold.
// Initial request — check if auto-approval applies.
if checkFound && checkPeriod > 0 {
if lastAuth, ok := ns.headscale.state.GetLastSSHAuth(
srcNodeID, dstNodeID,
); ok && time.Since(lastAuth) < checkPeriod {
reqLog.Trace().Caller().
Dur("check_period", checkPeriod).
Time("last_auth", lastAuth).
Msg("auto-approved within check period")
action.Accept = true
return &action, nil
}
}
// No auto-approval — create an auth session and hold.
return ns.sshActionHoldAndDelegate(reqLog, &action)
}
@ -445,6 +473,8 @@ func (ns *noiseServer) sshActionFollowUp(
reqLog zerolog.Logger,
action *tailcfg.SSHAction,
authIDStr string,
srcNodeID, dstNodeID types.NodeID,
checkFound bool,
) (*tailcfg.SSHAction, error) {
authID, err := types.AuthIDFromString(authIDStr)
if err != nil {
@ -481,6 +511,14 @@ func (ns *noiseServer) sshActionFollowUp(
action.Accept = true
// Record the successful auth for future auto-approval.
if checkFound {
ns.headscale.state.SetLastSSHAuth(srcNodeID, dstNodeID)
reqLog.Trace().Caller().
Msg("auth recorded for auto-approval")
}
return action, nil
}