feat(agent-auth): session tokens replace per-request scrypt (item 25)

Panel: after one scrypt-verified key auth, issue a random 24h
session token; store only its SHA-256 hash + expiry on the agent
record. authenticateAgent tries the token path first (constant-time
hash compare, active + unexpired), then the original fingerprint /
legacy VerifySecret path, and hands fresh tokens out via the
X-Agent-Session-Token response headers. Revoke() now clears token
material at all three revocation sites.

Agent: persist the token in the config dir (0600, temp+rename like
keys.json), present it as bearer, and on any 401 clear it and retry
once with the key (which re-issues). Old panels never send the
headers, so key-only operation is unchanged; old agents ignore the
headers. Version stays 0.6.8.

Tests: panel — issuance, hash-only storage, scrypt-free token path
(sabotaged KeyHash still authenticates), expiry fallback + rotation,
hash-mismatch fallback, revocation, per-record binding, key-only
rotation. Agent — capture/persist/reuse against a fake panel, 401
fallback and re-issue, old-panel key-only, client-side expiry,
double rejection surfacing without retry loops.
This commit is contained in:
aaaamoooo
2026-10-21 13:12:19 +00:00
parent 18074daf50
commit 408b03ee68
6 changed files with 796 additions and 100 deletions
+8
View File
@@ -132,6 +132,14 @@ using sing-box's own protobuf stubs. The Go agent keeps that exact design:
## Known divergences (all behavior-preserving) ## Known divergences (all behavior-preserving)
0. **Panel session tokens (round-4 item 25, protocol superset)**: against a
panel that issues them, the Go agent persists the short-lived session
token (`session-token.json` in the config dir, 0600) and presents it as
the bearer instead of its key, falling back to the key on any 401. The
Node agent has no such concept; wire format (a Bearer value) and all
endpoints are unchanged, and panels without token support are
unaffected. Divergent by design — it exists to take scrypt off the
panel's per-request path.
1. **JSON key order inside resource files / combined config**: Go reserializes 1. **JSON key order inside resource files / combined config**: Go reserializes
nested objects with alphabetically sorted keys; JS preserves insertion nested objects with alphabetically sorted keys; JS preserves insertion
order. Semantically identical for sing-box and for the panel. Side effect: order. Semantically identical for sing-box and for the panel. Side effect:
+124 -2
View File
@@ -39,6 +39,7 @@ type config struct {
SingBoxLog string SingBoxLog string
SingBoxCursor string SingBoxCursor string
KeyFile string KeyFile string
SessionTokenFile string
TLSDir string TLSDir string
APIPort string // NG_SINGBOX_API_PORT (string, as in JS) APIPort string // NG_SINGBOX_API_PORT (string, as in JS)
PollMS int64 PollMS int64
@@ -79,6 +80,7 @@ func loadConfig() config {
SingBoxLog: envOr("NG_SINGBOX_LOG", "/var/log/nexusgate/sing-box-access.log"), SingBoxLog: envOr("NG_SINGBOX_LOG", "/var/log/nexusgate/sing-box-access.log"),
SingBoxCursor: envOr("NG_SINGBOX_CURSOR", "/etc/nexusgate/sing-box-cursor.json"), SingBoxCursor: envOr("NG_SINGBOX_CURSOR", "/etc/nexusgate/sing-box-cursor.json"),
KeyFile: envOr("NG_KEY_FILE", "/etc/nexusgate/keys.json"), KeyFile: envOr("NG_KEY_FILE", "/etc/nexusgate/keys.json"),
SessionTokenFile: envOr("NG_SESSION_TOKEN_FILE", root+"/session-token.json"),
TLSDir: envOr("NG_TLS_DIR", "/etc/nexusgate/tls"), TLSDir: envOr("NG_TLS_DIR", "/etc/nexusgate/tls"),
APIPort: envOr("NG_SINGBOX_API_PORT", "10086"), APIPort: envOr("NG_SINGBOX_API_PORT", "10086"),
PollMS: int64(pollSec * 1000), PollMS: int64(pollSec * 1000),
@@ -151,25 +153,145 @@ func fileExists(p string) bool {
return err == nil return err == nil
} }
// ---------- panel session token (item 25) ----------
//
// After one scrypt-verified key auth the panel issues a short-lived
// session token via the X-Agent-Session-Token response header. The
// agent persists it in its state dir (0600, same handling as
// keys.json) and presents it as the bearer on subsequent requests;
// the panel then authenticates with a fast hash compare instead of
// scrypt. Any 401 while presenting a token clears it and retries
// once with the agent key, which makes the panel issue a fresh
// token. Panels that predate session tokens never send the header,
// so the agent simply keeps authenticating with its key — the token
// layer is invisible in that direction too. This is a deliberate
// divergence from the frozen Node agent (protocol superset, wire
// compatible).
type agentSessionToken struct {
Token string `json:"token"`
ExpiresAt string `json:"expiresAt"`
}
var sessionTokenState struct {
mu sync.Mutex
loaded bool
value *agentSessionToken
}
func loadSessionTokenLocked() {
sessionTokenState.loaded = true
raw, err := os.ReadFile(cfg.SessionTokenFile)
if err != nil {
return
}
var tok agentSessionToken
if err := json.Unmarshal(raw, &tok); err == nil && tok.Token != "" {
v := tok
sessionTokenState.value = &v
}
}
// currentSessionToken returns the held token, or nil when none is
// stored or it is expired (a one-minute margin keeps a request from
// riding on a token that dies in flight).
func currentSessionToken() *agentSessionToken {
sessionTokenState.mu.Lock()
defer sessionTokenState.mu.Unlock()
if !sessionTokenState.loaded {
loadSessionTokenLocked()
}
if sessionTokenState.value == nil {
return nil
}
if exp, err := time.Parse(time.RFC3339, sessionTokenState.value.ExpiresAt); err == nil && time.Until(exp) < time.Minute {
return nil
}
tok := *sessionTokenState.value
return &tok
}
func storeSessionToken(token, expiresAt string) {
if token == "" {
return
}
sessionTokenState.mu.Lock()
defer sessionTokenState.mu.Unlock()
if !sessionTokenState.loaded {
loadSessionTokenLocked()
}
if sessionTokenState.value != nil && sessionTokenState.value.Token == token {
return
}
next := &agentSessionToken{Token: token, ExpiresAt: expiresAt}
sessionTokenState.value = next
body, err := json.Marshal(next)
if err != nil {
return
}
tmp := cfg.SessionTokenFile + ".tmp"
if err := os.WriteFile(tmp, append(body, '\n'), 0o600); err != nil {
logf("Session token persist failed %s", safeError(err.Error()))
return
}
if err := os.Rename(tmp, cfg.SessionTokenFile); err != nil {
logf("Session token persist failed %s", safeError(err.Error()))
return
}
_ = os.Chmod(cfg.SessionTokenFile, 0o600)
}
func clearSessionToken() {
sessionTokenState.mu.Lock()
defer sessionTokenState.mu.Unlock()
sessionTokenState.value = nil
sessionTokenState.loaded = true
if err := os.Remove(cfg.SessionTokenFile); err != nil && !os.IsNotExist(err) {
logf("Session token clear failed %s", safeError(err.Error()))
}
}
// request mirrors agent.js request(): POST with bearer auth, 20s timeout, // request mirrors agent.js request(): POST with bearer auth, 20s timeout,
// JSON body best-effort parsed ({} on failure), error message taken from // JSON body best-effort parsed ({} on failure), error message taken from
// body.message or synthesized from the HTTP status. // body.message or synthesized from the HTTP status. Bearer selection and
// the 401 retry follow the session-token rules above.
func request(endpoint string, body []byte) (map[string]interface{}, error) { func request(endpoint string, body []byte) (map[string]interface{}, error) {
return requestAttempt(endpoint, body, true)
}
func requestAttempt(endpoint string, body []byte, mayRetry bool) (map[string]interface{}, error) {
bearer := cfg.AgentKey
usedToken := false
if tok := currentSessionToken(); tok != nil {
bearer = tok.Token
usedToken = true
}
req, err := http.NewRequest(http.MethodPost, cfg.Controller+endpoint, bytes.NewReader(body)) req, err := http.NewRequest(http.MethodPost, cfg.Controller+endpoint, bytes.NewReader(body))
if err != nil { if err != nil {
return nil, err return nil, err
} }
req.Header.Set("Authorization", "Bearer "+cfg.AgentKey) req.Header.Set("Authorization", "Bearer "+bearer)
req.Header.Set("Content-Type", "application/json") req.Header.Set("Content-Type", "application/json")
resp, err := httpClient.Do(req) resp, err := httpClient.Do(req)
if err != nil { if err != nil {
return nil, err return nil, err
} }
defer resp.Body.Close() defer resp.Body.Close()
// A freshly issued token can ride on any authenticated response.
if issued := resp.Header.Get("X-Agent-Session-Token"); issued != "" {
storeSessionToken(issued, resp.Header.Get("X-Agent-Session-Token-Expires"))
}
raw, err := io.ReadAll(resp.Body) raw, err := io.ReadAll(resp.Body)
if err != nil { if err != nil {
return nil, err return nil, err
} }
if resp.StatusCode == 401 && usedToken && mayRetry {
// Token rejected (expired or revoked server-side): drop it
// and retry once with the key; a valid key makes the panel
// issue a fresh token on that response.
clearSessionToken()
return requestAttempt(endpoint, body, false)
}
parsed := map[string]interface{}{} parsed := map[string]interface{}{}
if len(raw) > 0 { if len(raw) > 0 {
_ = json.Unmarshal(raw, &parsed) // .catch(() => ({})) in JS _ = json.Unmarshal(raw, &parsed) // .catch(() => ({})) in JS
+169
View File
@@ -0,0 +1,169 @@
package main
// Item 25: agent-side session token handling. A fake panel (httptest)
// issues tokens via response headers exactly like the real one; the
// tests pin down capture, persistence, reuse, the 401 fallback to the
// key, and old-panel behavior (no headers → key on every request).
import (
"fmt"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"time"
)
// fakePanel records the bearer of every request and answers like the
// real panel: the key always authenticates (and issues issueToken
// when non-empty), the current valid token authenticates, anything
// else is a 401.
type fakePanel struct {
key string
validToken string
issueToken string
bearers []string
}
func (p *fakePanel) handler(w http.ResponseWriter, r *http.Request) {
bearer := ""
if h := r.Header.Get("Authorization"); len(h) > len("Bearer ") {
bearer = h[len("Bearer "):]
}
p.bearers = append(p.bearers, bearer)
switch bearer {
case p.key:
if p.issueToken != "" {
w.Header().Set("X-Agent-Session-Token", p.issueToken)
w.Header().Set("X-Agent-Session-Token-Expires", time.Now().Add(24*time.Hour).UTC().Format("2006-01-02T15:04:05.000Z"))
}
w.WriteHeader(200)
fmt.Fprint(w, `{"ok":true}`)
case p.validToken:
w.WriteHeader(200)
fmt.Fprint(w, `{"ok":true}`)
default:
w.WriteHeader(401)
fmt.Fprint(w, `{"message":"Agent 身份校验失败"}`)
}
}
func setupTokenAgent(t *testing.T, panel *fakePanel) func() {
t.Helper()
srv := httptest.NewServer(http.HandlerFunc(panel.handler))
oldCfg := cfg
cfg.Controller = srv.URL
cfg.AgentKey = panel.key
cfg.SessionTokenFile = filepath.Join(t.TempDir(), "session-token.json")
sessionTokenState.mu.Lock()
oldState := sessionTokenState.value
oldLoaded := sessionTokenState.loaded
sessionTokenState.value = nil
sessionTokenState.loaded = false
sessionTokenState.mu.Unlock()
return func() {
srv.Close()
cfg = oldCfg
sessionTokenState.mu.Lock()
sessionTokenState.value = oldState
sessionTokenState.loaded = oldLoaded
sessionTokenState.mu.Unlock()
}
}
func TestSessionTokenCapturedPersistedAndReused(t *testing.T) {
panel := &fakePanel{key: "the-key", issueToken: "tok-1", validToken: "tok-1"}
defer setupTokenAgent(t, panel)()
if _, err := request("/api/agent/poll", []byte("{}")); err != nil {
t.Fatalf("first request: %v", err)
}
if _, err := request("/api/agent/poll", []byte("{}")); err != nil {
t.Fatalf("second request: %v", err)
}
want := []string{"the-key", "tok-1"}
if fmt.Sprint(panel.bearers) != fmt.Sprint(want) {
t.Fatalf("bearers = %v, want %v", panel.bearers, want)
}
// Persisted with keys.json-grade permissions.
info, err := os.Stat(cfg.SessionTokenFile)
if err != nil {
t.Fatalf("token file not persisted: %v", err)
}
if perm := info.Mode().Perm(); perm != 0o600 {
t.Fatalf("token file perm = %o, want 600", perm)
}
// And it survives a cold start: reset memory, it must load from disk.
sessionTokenState.mu.Lock()
sessionTokenState.value = nil
sessionTokenState.loaded = false
sessionTokenState.mu.Unlock()
if tok := currentSessionToken(); tok == nil || tok.Token != "tok-1" {
t.Fatalf("token not reloaded from disk: %+v", tok)
}
}
func TestSessionToken401FallsBackToKeyAndReissues(t *testing.T) {
panel := &fakePanel{key: "the-key", issueToken: "tok-2", validToken: "tok-2"}
defer setupTokenAgent(t, panel)()
// Plant a stale token the panel no longer honors.
storeSessionToken("tok-stale", time.Now().Add(time.Hour).UTC().Format("2006-01-02T15:04:05.000Z"))
if _, err := request("/api/agent/poll", []byte("{}")); err != nil {
t.Fatalf("request with stale token: %v", err)
}
want := []string{"tok-stale", "the-key"}
if fmt.Sprint(panel.bearers) != fmt.Sprint(want) {
t.Fatalf("bearers = %v, want %v (token attempt, then key retry)", panel.bearers, want)
}
if tok := currentSessionToken(); tok == nil || tok.Token != "tok-2" {
t.Fatalf("fresh token not stored after retry: %+v", tok)
}
}
func TestSessionTokenAbsentKeepsKeyAuth(t *testing.T) {
// Old panel: never issues tokens.
panel := &fakePanel{key: "the-key"}
defer setupTokenAgent(t, panel)()
for i := 0; i < 2; i++ {
if _, err := request("/api/agent/poll", []byte("{}")); err != nil {
t.Fatalf("request %d: %v", i, err)
}
}
if fmt.Sprint(panel.bearers) != "[the-key the-key]" {
t.Fatalf("bearers = %v, want key on every request", panel.bearers)
}
if _, err := os.Stat(cfg.SessionTokenFile); !os.IsNotExist(err) {
t.Fatalf("no token file expected against an old panel, stat err = %v", err)
}
}
func TestSessionTokenExpiredClientSideUsesKey(t *testing.T) {
panel := &fakePanel{key: "the-key", issueToken: "tok-3", validToken: "tok-3"}
defer setupTokenAgent(t, panel)()
storeSessionToken("tok-old", time.Now().Add(-time.Hour).UTC().Format("2006-01-02T15:04:05.000Z"))
if _, err := request("/api/agent/poll", []byte("{}")); err != nil {
t.Fatalf("request: %v", err)
}
if len(panel.bearers) != 1 || panel.bearers[0] != "the-key" {
t.Fatalf("bearers = %v, want the key used directly (no doomed token attempt)", panel.bearers)
}
}
func TestSessionTokenKeyRejectedSurfacesError(t *testing.T) {
panel := &fakePanel{key: "the-real-key"}
defer setupTokenAgent(t, panel)()
cfg.AgentKey = "wrong-key"
storeSessionToken("tok-stale", time.Now().Add(time.Hour).UTC().Format("2006-01-02T15:04:05.000Z"))
if _, err := request("/api/agent/poll", []byte("{}")); err == nil {
t.Fatal("expected an error when both token and key are rejected")
}
// Exactly two attempts: token, then key — never a loop.
if len(panel.bearers) != 2 {
t.Fatalf("attempts = %v, want exactly 2", panel.bearers)
}
if tok := currentSessionToken(); tok != nil {
t.Fatalf("stale token should have been cleared, still held: %+v", tok)
}
}
+255
View File
@@ -0,0 +1,255 @@
package main
// Item 25: agent session tokens. After one scrypt-verified key auth the
// panel issues a random session token (only its SHA-256 hash is stored);
// subsequent requests authenticate by hash compare. These tests drive
// the real /api/agent/heartbeat route through the smoke harness.
import (
"net/http"
"net/http/httptest"
"path/filepath"
"testing"
"time"
)
const testAgentKey = "test-agent-key-0123456789abcdef-XYZ"
func seedAgentApp(t *testing.T, key string) (*App, string) {
t.Helper()
cookieSecure = false
store := NewStore(filepath.Join(t.TempDir(), "nexusgate.json"))
if err := store.Init(); err != nil {
t.Fatalf("store init: %v", err)
}
fp := TokenFingerprint(key)
agentID := ""
_, err := store.Transaction(func(d *Data) (any, error) {
d.Servers = append(d.Servers, &Server{
ID: "srv_tok", Name: "tok", Status: "online",
CreatedAt: NowISO(), UpdatedAt: NowISO(),
})
agent := &Agent{
ID: NewID("agt"),
ServerID: "srv_tok",
KeyHash: HashSecret(key),
KeyFingerprint: &fp,
Status: "active",
CreatedAt: NowISO(),
LastSeenAt: "2000-01-01T00:00:00.000Z",
}
agentID = agent.ID
d.Agents = append(d.Agents, agent)
return nil, nil
})
if err != nil {
t.Fatalf("seed agent: %v", err)
}
return NewApp(store, NewSessionManager(12), NewOrchestrator(store)), agentID
}
func agentHeartbeat(t *testing.T, app *App, bearer string) *httptest.ResponseRecorder {
t.Helper()
return doRequest(app, http.MethodPost, "/api/agent/heartbeat", "{}", map[string]string{
"Authorization": "Bearer " + bearer,
})
}
func storedAgent(t *testing.T, app *App, agentID string) *Agent {
t.Helper()
var out *Agent
app.store.View(func(d *Data) {
for _, agent := range d.Agents {
if agent.ID == agentID {
copied := *agent
out = &copied
}
}
})
if out == nil {
t.Fatalf("agent %s not in store", agentID)
}
return out
}
func mutateAgent(t *testing.T, app *App, agentID string, fn func(*Agent)) {
t.Helper()
_, err := app.store.Transaction(func(d *Data) (any, error) {
for _, agent := range d.Agents {
if agent.ID == agentID {
fn(agent)
}
}
return nil, nil
})
if err != nil {
t.Fatalf("mutate agent: %v", err)
}
}
// Key auth succeeds and issues a session token whose hash (and only the
// hash) lands on the agent record.
func TestAgentSessionTokenIssuedOnKeyAuth(t *testing.T) {
app, agentID := seedAgentApp(t, testAgentKey)
rec := agentHeartbeat(t, app, testAgentKey)
if rec.Code != 200 {
t.Fatalf("key heartbeat: got %d (%s)", rec.Code, rec.Body.String())
}
token := rec.Header().Get(agentSessionTokenHeader)
expires := rec.Header().Get(agentSessionTokenExpiresHeader)
if token == "" || expires == "" {
t.Fatalf("no session token issued: token=%q expires=%q", token, expires)
}
if token == testAgentKey {
t.Fatal("session token must differ from the agent key")
}
stored := storedAgent(t, app, agentID)
if stored.SessionTokenHash == nil || *stored.SessionTokenHash != TokenFingerprint(token) {
t.Fatalf("stored hash does not match issued token: %+v", stored.SessionTokenHash)
}
if stored.SessionTokenExpiresAt == nil || *stored.SessionTokenExpiresAt != expires {
t.Fatalf("stored expiry %v != header expiry %q", stored.SessionTokenExpiresAt, expires)
}
expMs, ok := ParseISOMs(expires)
if !ok || expMs <= time.Now().UnixMilli() {
t.Fatalf("issued expiry not in the future: %q", expires)
}
}
// Token auth succeeds even when the key hash can no longer verify —
// proving the token path never touches scrypt — and does not re-issue.
func TestAgentSessionTokenAuthSkipsScrypt(t *testing.T) {
app, agentID := seedAgentApp(t, testAgentKey)
token := agentHeartbeat(t, app, testAgentKey).Header().Get(agentSessionTokenHeader)
if token == "" {
t.Fatal("setup: no token issued")
}
// Sabotage the key hash: any scrypt verification now fails.
mutateAgent(t, app, agentID, func(agent *Agent) {
agent.KeyHash = "scrypt:deadbeef:abcd"
})
rec := agentHeartbeat(t, app, token)
if rec.Code != 200 {
t.Fatalf("token heartbeat with sabotaged key hash: got %d", rec.Code)
}
if again := rec.Header().Get(agentSessionTokenHeader); again != "" {
t.Fatalf("token path must not re-issue, got %q", again)
}
// And the (now unverifiable) key is rejected.
if rec := agentHeartbeat(t, app, testAgentKey); rec.Code != 401 {
t.Fatalf("sabotaged key: got %d, want 401", rec.Code)
}
}
// An expired token is rejected; the key still works and re-issues a
// fresh token (rotation).
func TestAgentSessionTokenExpiryFallsBackToKey(t *testing.T) {
app, agentID := seedAgentApp(t, testAgentKey)
token := agentHeartbeat(t, app, testAgentKey).Header().Get(agentSessionTokenHeader)
mutateAgent(t, app, agentID, func(agent *Agent) {
past := ISOFromTime(time.Now().Add(-time.Hour))
agent.SessionTokenExpiresAt = &past
})
if rec := agentHeartbeat(t, app, token); rec.Code != 401 {
t.Fatalf("expired token: got %d, want 401", rec.Code)
}
rec := agentHeartbeat(t, app, testAgentKey)
if rec.Code != 200 {
t.Fatalf("key after expiry: got %d", rec.Code)
}
fresh := rec.Header().Get(agentSessionTokenHeader)
if fresh == "" || fresh == token {
t.Fatalf("expected a fresh token after expiry, got %q", fresh)
}
if rec := agentHeartbeat(t, app, fresh); rec.Code != 200 {
t.Fatalf("fresh token: got %d", rec.Code)
}
}
// A token whose stored hash no longer matches (tampered / rotated
// server-side) is rejected; the key re-issues.
func TestAgentSessionTokenHashMismatchFallsBack(t *testing.T) {
app, agentID := seedAgentApp(t, testAgentKey)
token := agentHeartbeat(t, app, testAgentKey).Header().Get(agentSessionTokenHeader)
mutateAgent(t, app, agentID, func(agent *Agent) {
wrong := TokenFingerprint("some-other-token")
agent.SessionTokenHash = &wrong
})
if rec := agentHeartbeat(t, app, token); rec.Code != 401 {
t.Fatalf("mismatched token: got %d, want 401", rec.Code)
}
rec := agentHeartbeat(t, app, testAgentKey)
if rec.Code != 200 || rec.Header().Get(agentSessionTokenHeader) == "" {
t.Fatalf("key re-issue after mismatch: code=%d token=%q", rec.Code, rec.Header().Get(agentSessionTokenHeader))
}
}
// Revoking the agent kills both the token and the key immediately, and
// Revoke() clears the stored token material.
func TestAgentSessionTokenRevokedAgentRejected(t *testing.T) {
app, agentID := seedAgentApp(t, testAgentKey)
token := agentHeartbeat(t, app, testAgentKey).Header().Get(agentSessionTokenHeader)
mutateAgent(t, app, agentID, func(agent *Agent) { agent.Revoke() })
stored := storedAgent(t, app, agentID)
if stored.SessionTokenHash != nil || stored.SessionTokenExpiresAt != nil {
t.Fatal("Revoke() must clear session token fields")
}
if rec := agentHeartbeat(t, app, token); rec.Code != 401 {
t.Fatalf("revoked agent token: got %d, want 401", rec.Code)
}
if rec := agentHeartbeat(t, app, testAgentKey); rec.Code != 401 {
t.Fatalf("revoked agent key: got %d, want 401", rec.Code)
}
}
// The token is bound to its own agent record: it must not authenticate
// a request as a different agent (observed via which record's
// LastSeenAt the heartbeat advances).
func TestAgentSessionTokenBoundToRecord(t *testing.T) {
app, agentID := seedAgentApp(t, testAgentKey)
otherKey := "other-agent-key-9876543210"
otherFP := TokenFingerprint(otherKey)
otherID := ""
_, err := app.store.Transaction(func(d *Data) (any, error) {
other := &Agent{
ID: NewID("agt"), ServerID: "srv_tok",
KeyHash: HashSecret(otherKey), KeyFingerprint: &otherFP,
Status: "active", CreatedAt: NowISO(),
LastSeenAt: "2000-01-01T00:00:00.000Z",
}
otherID = other.ID
d.Agents = append(d.Agents, other)
return nil, nil
})
if err != nil {
t.Fatalf("seed second agent: %v", err)
}
token := agentHeartbeat(t, app, testAgentKey).Header().Get(agentSessionTokenHeader)
if rec := agentHeartbeat(t, app, token); rec.Code != 200 {
t.Fatalf("token heartbeat: got %d", rec.Code)
}
if got := storedAgent(t, app, agentID).LastSeenAt; got == "2000-01-01T00:00:00.000Z" {
t.Fatal("token heartbeat did not advance its own agent's LastSeenAt")
}
if got := storedAgent(t, app, otherID).LastSeenAt; got != "2000-01-01T00:00:00.000Z" {
t.Fatalf("token heartbeat advanced the WRONG agent (LastSeenAt=%s)", got)
}
}
// Key-only agents (pre-token clients) keep working request after
// request; each key auth rotates the stored token.
func TestAgentKeyOnlyRequestsKeepWorking(t *testing.T) {
app, agentID := seedAgentApp(t, testAgentKey)
var prevHash string
for i := 0; i < 3; i++ {
rec := agentHeartbeat(t, app, testAgentKey)
if rec.Code != 200 {
t.Fatalf("key heartbeat %d: got %d", i, rec.Code)
}
stored := storedAgent(t, app, agentID)
if stored.SessionTokenHash == nil || *stored.SessionTokenHash == prevHash {
t.Fatalf("key heartbeat %d did not rotate the token", i)
}
prevHash = *stored.SessionTokenHash
}
}
+148 -22
View File
@@ -6,8 +6,10 @@ package main
// line by line. // line by line.
import ( import (
"crypto/sha256"
"crypto/subtle" "crypto/subtle"
"encoding/base64" "encoding/base64"
"encoding/hex"
"encoding/json" "encoding/json"
"fmt" "fmt"
"math" "math"
@@ -887,32 +889,148 @@ func (a *App) RequireAdmin(w http.ResponseWriter, r *http.Request, csrf bool) *a
return auth return auth
} }
// FindAgentByBearer is findAgentByBearer: fingerprint fast path, then a // ---------- agent session tokens (item 25) ----------
// full VerifySecret scan over legacy agents without a fingerprint.
func (a *App) FindAgentByBearer(r *http.Request) *Agent { // AgentSessionTokenTTL is how long an issued agent session token stays
header := r.Header.Get("Authorization") // valid. Within the TTL the agent authenticates with a SHA-256 hash
if !strings.HasPrefix(header, "Bearer ") { // compare; after it the agent falls back to one scrypt-verified key
// auth and the panel issues a fresh token.
const AgentSessionTokenTTL = 24 * time.Hour
const (
agentSessionTokenHeader = "X-Agent-Session-Token"
agentSessionTokenExpiresHeader = "X-Agent-Session-Token-Expires"
)
// issuedAgentToken is a freshly minted session token to hand back to
// the agent via response headers; only its SHA-256 hash is persisted.
type issuedAgentToken struct {
Token string
ExpiresAt string
}
// agentBySessionToken resolves a session-token bearer: the stored hash
// is compared in constant time and the token must be unexpired and the
// agent active. Returns a copy of the agent record, or nil.
func (a *App) agentBySessionToken(bearer string) *Agent {
sum := sha256.Sum256([]byte(bearer))
nowMs := time.Now().UnixMilli()
var found *Agent
a.store.View(func(d *Data) {
for _, agent := range d.Agents {
if agent.Status != "active" || agent.SessionTokenHash == nil || agent.SessionTokenExpiresAt == nil {
continue
}
stored, err := hex.DecodeString(*agent.SessionTokenHash)
if err != nil || len(stored) != len(sum) {
continue
}
if subtle.ConstantTimeCompare(sum[:], stored) != 1 {
continue
}
expMs, ok := ParseISOMs(*agent.SessionTokenExpiresAt)
if !ok || expMs <= nowMs {
continue
}
copied := *agent
found = &copied
return
}
})
return found
}
// issueAgentSessionToken mints a session token for the agent, persists
// only its SHA-256 hash and expiry, and returns the plaintext for the
// response headers. Returns nil when the record is gone or no longer
// active (e.g. revoked between verification and issuance).
func (a *App) issueAgentSessionToken(agentID string) *issuedAgentToken {
token := RandomToken(40)
hash := TokenFingerprint(token)
expires := ISOFromTime(time.Now().Add(AgentSessionTokenTTL))
issued := false
_, err := a.store.Transaction(func(d *Data) (any, error) {
for _, agent := range d.Agents {
if agent.ID == agentID {
if agent.Status != "active" {
return nil, nil
}
agent.SessionTokenHash = &hash
agent.SessionTokenExpiresAt = &expires
issued = true
return nil, nil
}
}
return nil, nil
})
if err != nil || !issued {
return nil return nil
} }
token := header[len("Bearer "):] return &issuedAgentToken{Token: token, ExpiresAt: expires}
fingerprint := TokenFingerprint(token) }
data := a.store.Snapshot()
// authenticateAgent resolves the agent behind an /api/agent/* request.
// Order: (1) session-token fast path — SHA-256 the bearer and
// constant-time compare against the stored token hash of active,
// unexpired agents, no scrypt; (2) the original key path — fingerprint
// lookup, then a VerifySecret scan over legacy agents without a
// fingerprint. A key-path success mints a session token and returns it
// in the second value so the handler can hand it to the agent.
func (a *App) authenticateAgent(r *http.Request) (*Agent, *issuedAgentToken) {
header := r.Header.Get("Authorization")
if !strings.HasPrefix(header, "Bearer ") {
return nil, nil
}
bearer := header[len("Bearer "):]
if agent := a.agentBySessionToken(bearer); agent != nil {
return agent, nil
}
// Key path: copy candidates out under a read view, then run the
// expensive scrypt verification outside the store lock.
fingerprint := TokenFingerprint(bearer)
var fast *Agent var fast *Agent
for _, agent := range data.Agents { var legacy []*Agent
if agent.Status == "active" && agent.KeyFingerprint != nil && *agent.KeyFingerprint == fingerprint { a.store.View(func(d *Data) {
fast = agent for _, agent := range d.Agents {
if agent.Status != "active" {
continue
}
if agent.KeyFingerprint != nil && *agent.KeyFingerprint == fingerprint {
if fast == nil {
copied := *agent
fast = &copied
}
} else if agent.KeyFingerprint == nil {
copied := *agent
legacy = append(legacy, &copied)
}
}
})
var verified *Agent
if fast != nil && VerifySecret(bearer, fast.KeyHash) {
verified = fast
} else {
for _, agent := range legacy {
if VerifySecret(bearer, agent.KeyHash) {
verified = agent
break break
} }
} }
if fast != nil && VerifySecret(token, fast.KeyHash) {
return fast
} }
for _, agent := range data.Agents { if verified == nil {
if agent.Status == "active" && agent.KeyFingerprint == nil && VerifySecret(token, agent.KeyHash) { return nil, nil
}
return verified, a.issueAgentSessionToken(verified.ID)
}
// FindAgentByBearer is findAgentByBearer: session-token fast path,
// fingerprint lookup, then a full VerifySecret scan over legacy agents
// without a fingerprint.
func (a *App) FindAgentByBearer(r *http.Request) *Agent {
agent, _ := a.authenticateAgent(r)
return agent return agent
}
}
return nil
} }
func clearSessionCookie() map[string]string { func clearSessionCookie() map[string]string {
@@ -1084,7 +1202,7 @@ func (a *App) handleAgent(w http.ResponseWriter, r *http.Request, pathname strin
} }
for _, old := range d.Agents { for _, old := range d.Agents {
if old.ServerID == server.ID { if old.ServerID == server.ID {
old.Status = "revoked" old.Revoke()
} }
} }
fp := TokenFingerprint(agentKey) fp := TokenFingerprint(agentKey)
@@ -1130,11 +1248,19 @@ func (a *App) handleAgent(w http.ResponseWriter, r *http.Request, pathname strin
if !strings.HasPrefix(pathname, "/api/agent/") { if !strings.HasPrefix(pathname, "/api/agent/") {
return false, nil return false, nil
} }
agent := a.FindAgentByBearer(r) agent, issued := a.authenticateAgent(r)
if agent == nil { if agent == nil {
SendError(w, 401, "Agent 身份校验失败", "invalid_agent") SendError(w, 401, "Agent 身份校验失败", "invalid_agent")
return true, nil return true, nil
} }
if issued != nil {
// Hand the fresh session token to the agent (item 25): it
// persists the token and uses it for subsequent requests
// instead of its key. Agents that predate tokens ignore
// these headers and keep authenticating with the key.
w.Header().Set(agentSessionTokenHeader, issued.Token)
w.Header().Set(agentSessionTokenExpiresHeader, issued.ExpiresAt)
}
if r.Method == http.MethodPost && pathname == "/api/agent/heartbeat" { if r.Method == http.MethodPost && pathname == "/api/agent/heartbeat" {
body, err := ReadJSON(r, defaultMaxBody) body, err := ReadJSON(r, defaultMaxBody)
@@ -1790,7 +1916,7 @@ func (a *App) handleAdminApi(w http.ResponseWriter, r *http.Request, pathname st
d.Servers = append(d.Servers[:index], d.Servers[index+1:]...) d.Servers = append(d.Servers[:index], d.Servers[index+1:]...)
for _, agent := range d.Agents { for _, agent := range d.Agents {
if agent.ServerID == params["id"] { if agent.ServerID == params["id"] {
agent.Status = "revoked" agent.Revoke()
} }
} }
Audit(d, actor, "delete_server", params["id"], nil) Audit(d, actor, "delete_server", params["id"], nil)
@@ -1943,7 +2069,7 @@ func (a *App) handleAdminApi(w http.ResponseWriter, r *http.Request, pathname st
d.Nodes = nodes d.Nodes = nodes
for _, agent := range d.Agents { for _, agent := range d.Agents {
if agent.ServerID == server.ID { if agent.ServerID == server.ID {
agent.Status = "revoked" agent.Revoke()
} }
} }
d.Servers = append(d.Servers[:index], d.Servers[index+1:]...) d.Servers = append(d.Servers[:index], d.Servers[index+1:]...)
+16
View File
@@ -512,6 +512,13 @@ type Agent struct {
ServerID string `json:"serverId"` ServerID string `json:"serverId"`
KeyHash string `json:"keyHash"` KeyHash string `json:"keyHash"`
KeyFingerprint *string `json:"keyFingerprint,omitempty"` KeyFingerprint *string `json:"keyFingerprint,omitempty"`
// SessionTokenHash is the SHA-256 hex of the short-lived agent session
// token (item 25): after one scrypt-verified key auth the panel issues
// a random token and stores only its hash here, so steady-state agent
// requests authenticate with a constant-time hash compare instead of
// a scrypt run. The plaintext token is never persisted panel-side.
SessionTokenHash *string `json:"sessionTokenHash,omitempty"`
SessionTokenExpiresAt *string `json:"sessionTokenExpiresAt,omitempty"`
Status string `json:"status"` Status string `json:"status"`
Hostname string `json:"hostname"` Hostname string `json:"hostname"`
Version string `json:"version"` Version string `json:"version"`
@@ -536,6 +543,15 @@ func (a Agent) MarshalJSON() ([]byte, error) {
return mergeMarshal(plain(a), a.Extra) return mergeMarshal(plain(a), a.Extra)
} }
// Revoke marks the agent revoked and drops any issued session token
// (item 25), so the token dies with the revocation even if the record's
// status were flipped back later.
func (a *Agent) Revoke() {
a.Status = "revoked"
a.SessionTokenHash = nil
a.SessionTokenExpiresAt = nil
}
type EnrollmentToken struct { type EnrollmentToken struct {
ID string `json:"id"` ID string `json:"id"`
ServerID string `json:"serverId"` ServerID string `json:"serverId"`