From 408b03ee68a07b25512a1bf50c4bf3e5919df925 Mon Sep 17 00:00:00 2001 From: aaaamoooo Date: Wed, 21 Oct 2026 13:12:19 +0000 Subject: [PATCH] feat(agent-auth): session tokens replace per-request scrypt (item 25) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- agent-go/PARITY.md | 8 ++ agent-go/main.go | 178 ++++++++++++++++++++---- agent-go/sessiontoken_test.go | 169 ++++++++++++++++++++++ panel-go/agenttoken_test.go | 255 ++++++++++++++++++++++++++++++++++ panel-go/handlers.go | 174 +++++++++++++++++++---- panel-go/types.go | 112 ++++++++------- 6 files changed, 796 insertions(+), 100 deletions(-) create mode 100644 agent-go/sessiontoken_test.go create mode 100644 panel-go/agenttoken_test.go diff --git a/agent-go/PARITY.md b/agent-go/PARITY.md index 755c136..1c76eee 100644 --- a/agent-go/PARITY.md +++ b/agent-go/PARITY.md @@ -132,6 +132,14 @@ using sing-box's own protobuf stubs. The Go agent keeps that exact design: ## 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 nested objects with alphabetically sorted keys; JS preserves insertion order. Semantically identical for sing-box and for the panel. Side effect: diff --git a/agent-go/main.go b/agent-go/main.go index d79eee4..1fda2f2 100644 --- a/agent-go/main.go +++ b/agent-go/main.go @@ -28,20 +28,21 @@ import ( const version = "0.6.8" type config struct { - Controller string - AgentKey string - SingBoxBin string - SingBoxStatsBin string - Root string // NG_CONFIG_DIR - ResourceDir string // Root + "/resources" - SingBoxConfig string + Controller string + AgentKey string + SingBoxBin string + SingBoxStatsBin string + Root string // NG_CONFIG_DIR + ResourceDir string // Root + "/resources" + SingBoxConfig string SingBoxVersionFile string - SingBoxLog string - SingBoxCursor string - KeyFile string - TLSDir string - APIPort string // NG_SINGBOX_API_PORT (string, as in JS) - PollMS int64 + SingBoxLog string + SingBoxCursor string + KeyFile string + SessionTokenFile string + TLSDir string + APIPort string // NG_SINGBOX_API_PORT (string, as in JS) + PollMS int64 } func envOr(name, def string) string { @@ -68,20 +69,21 @@ func loadConfig() config { pollSec = 3 } return config{ - Controller: strings.TrimRight(os.Getenv("NG_CONTROLLER"), "/"), - AgentKey: os.Getenv("NG_AGENT_KEY"), - SingBoxBin: envOr("NG_SINGBOX_BIN", "/usr/local/bin/nexusgate-sing-box"), - SingBoxStatsBin: envOr("NG_SINGBOX_STATS_BIN", "/usr/local/bin/nexusgate-sing-box-stats"), - Root: root, - ResourceDir: root + "/resources", - SingBoxConfig: envOr("NG_SINGBOX_CONFIG", "/etc/nexusgate/sing-box/config.json"), + Controller: strings.TrimRight(os.Getenv("NG_CONTROLLER"), "/"), + AgentKey: os.Getenv("NG_AGENT_KEY"), + SingBoxBin: envOr("NG_SINGBOX_BIN", "/usr/local/bin/nexusgate-sing-box"), + SingBoxStatsBin: envOr("NG_SINGBOX_STATS_BIN", "/usr/local/bin/nexusgate-sing-box-stats"), + Root: root, + ResourceDir: root + "/resources", + SingBoxConfig: envOr("NG_SINGBOX_CONFIG", "/etc/nexusgate/sing-box/config.json"), SingBoxVersionFile: envOr("NG_SINGBOX_VERSION_FILE", "/etc/nexusgate/sing-box/version"), - SingBoxLog: envOr("NG_SINGBOX_LOG", "/var/log/nexusgate/sing-box-access.log"), - SingBoxCursor: envOr("NG_SINGBOX_CURSOR", "/etc/nexusgate/sing-box-cursor.json"), - KeyFile: envOr("NG_KEY_FILE", "/etc/nexusgate/keys.json"), - TLSDir: envOr("NG_TLS_DIR", "/etc/nexusgate/tls"), - APIPort: envOr("NG_SINGBOX_API_PORT", "10086"), - PollMS: int64(pollSec * 1000), + SingBoxLog: envOr("NG_SINGBOX_LOG", "/var/log/nexusgate/sing-box-access.log"), + SingBoxCursor: envOr("NG_SINGBOX_CURSOR", "/etc/nexusgate/sing-box-cursor.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"), + APIPort: envOr("NG_SINGBOX_API_PORT", "10086"), + PollMS: int64(pollSec * 1000), } } @@ -151,25 +153,145 @@ func fileExists(p string) bool { 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, // 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) { + 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)) if err != nil { return nil, err } - req.Header.Set("Authorization", "Bearer "+cfg.AgentKey) + req.Header.Set("Authorization", "Bearer "+bearer) req.Header.Set("Content-Type", "application/json") resp, err := httpClient.Do(req) if err != nil { return nil, err } 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) if err != nil { 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{}{} if len(raw) > 0 { _ = json.Unmarshal(raw, &parsed) // .catch(() => ({})) in JS diff --git a/agent-go/sessiontoken_test.go b/agent-go/sessiontoken_test.go new file mode 100644 index 0000000..e6f907c --- /dev/null +++ b/agent-go/sessiontoken_test.go @@ -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) + } +} diff --git a/panel-go/agenttoken_test.go b/panel-go/agenttoken_test.go new file mode 100644 index 0000000..9dcd98c --- /dev/null +++ b/panel-go/agenttoken_test.go @@ -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 + } +} diff --git a/panel-go/handlers.go b/panel-go/handlers.go index a32c82e..1b74ddd 100644 --- a/panel-go/handlers.go +++ b/panel-go/handlers.go @@ -6,8 +6,10 @@ package main // line by line. import ( + "crypto/sha256" "crypto/subtle" "encoding/base64" + "encoding/hex" "encoding/json" "fmt" "math" @@ -887,32 +889,148 @@ func (a *App) RequireAdmin(w http.ResponseWriter, r *http.Request, csrf bool) *a return auth } -// FindAgentByBearer is findAgentByBearer: fingerprint fast path, then a -// full VerifySecret scan over legacy agents without a fingerprint. -func (a *App) FindAgentByBearer(r *http.Request) *Agent { - header := r.Header.Get("Authorization") - if !strings.HasPrefix(header, "Bearer ") { +// ---------- agent session tokens (item 25) ---------- + +// AgentSessionTokenTTL is how long an issued agent session token stays +// valid. Within the TTL the agent authenticates with a SHA-256 hash +// 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 } - token := header[len("Bearer "):] - fingerprint := TokenFingerprint(token) - data := a.store.Snapshot() + return &issuedAgentToken{Token: token, ExpiresAt: expires} +} + +// 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 - for _, agent := range data.Agents { - if agent.Status == "active" && agent.KeyFingerprint != nil && *agent.KeyFingerprint == fingerprint { - fast = agent - break + var legacy []*Agent + a.store.View(func(d *Data) { + 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 + } } } - if fast != nil && VerifySecret(token, fast.KeyHash) { - return fast + if verified == nil { + return nil, nil } - for _, agent := range data.Agents { - if agent.Status == "active" && agent.KeyFingerprint == nil && VerifySecret(token, agent.KeyHash) { - return agent - } - } - return 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 } 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 { if old.ServerID == server.ID { - old.Status = "revoked" + old.Revoke() } } 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/") { return false, nil } - agent := a.FindAgentByBearer(r) + agent, issued := a.authenticateAgent(r) if agent == nil { SendError(w, 401, "Agent 身份校验失败", "invalid_agent") 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" { 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:]...) for _, agent := range d.Agents { if agent.ServerID == params["id"] { - agent.Status = "revoked" + agent.Revoke() } } 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 for _, agent := range d.Agents { if agent.ServerID == server.ID { - agent.Status = "revoked" + agent.Revoke() } } d.Servers = append(d.Servers[:index], d.Servers[index+1:]...) diff --git a/panel-go/types.go b/panel-go/types.go index 6d618c5..2787efd 100644 --- a/panel-go/types.go +++ b/panel-go/types.go @@ -508,16 +508,23 @@ func (s Server) MarshalJSON() ([]byte, error) { } type Agent struct { - ID string `json:"id"` - ServerID string `json:"serverId"` - KeyHash string `json:"keyHash"` - KeyFingerprint *string `json:"keyFingerprint,omitempty"` - Status string `json:"status"` - Hostname string `json:"hostname"` - Version string `json:"version"` - CreatedAt string `json:"createdAt"` - LastSeenAt string `json:"lastSeenAt"` - Extra extraFields `json:"-"` + ID string `json:"id"` + ServerID string `json:"serverId"` + KeyHash string `json:"keyHash"` + 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"` + Hostname string `json:"hostname"` + Version string `json:"version"` + CreatedAt string `json:"createdAt"` + LastSeenAt string `json:"lastSeenAt"` + Extra extraFields `json:"-"` } func (a *Agent) UnmarshalJSON(raw []byte) error { @@ -536,6 +543,15 @@ func (a Agent) MarshalJSON() ([]byte, error) { 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 { ID string `json:"id"` ServerID string `json:"serverId"` @@ -792,26 +808,26 @@ type Node struct { // described by ExitAddress + ExitPort + ExitProtocol + ExitCredentials // (round-2 item 8). Manual exits have no exit resource and are always // ExitReady — the panel only builds the entry-side outbound. - ExitMode string `json:"exitMode,omitempty"` - ExitAddress string `json:"exitAddress,omitempty"` - NetworkMode string `json:"networkMode"` - RealityServerName string `json:"realityServerName"` - RealityDestPort int `json:"realityDestPort"` - PortMode string `json:"portMode"` - Port int `json:"port"` - ExitPortMode *string `json:"exitPortMode"` - ExitPort *int `json:"exitPort"` - Status string `json:"status"` - EntryReady bool `json:"entryReady"` - ExitReady bool `json:"exitReady"` - ResourceID string `json:"resourceId"` - ExitResourceID string `json:"exitResourceId"` - Artifacts map[string]json.RawMessage `json:"artifacts"` - Error *string `json:"error"` - PendingDelete bool `json:"pendingDelete"` - AutoRemoving *bool `json:"autoRemoving,omitempty"` - WSPath *string `json:"wsPath,omitempty"` - SSServerKey *string `json:"ssServerKey,omitempty"` + ExitMode string `json:"exitMode,omitempty"` + ExitAddress string `json:"exitAddress,omitempty"` + NetworkMode string `json:"networkMode"` + RealityServerName string `json:"realityServerName"` + RealityDestPort int `json:"realityDestPort"` + PortMode string `json:"portMode"` + Port int `json:"port"` + ExitPortMode *string `json:"exitPortMode"` + ExitPort *int `json:"exitPort"` + Status string `json:"status"` + EntryReady bool `json:"entryReady"` + ExitReady bool `json:"exitReady"` + ResourceID string `json:"resourceId"` + ExitResourceID string `json:"exitResourceId"` + Artifacts map[string]json.RawMessage `json:"artifacts"` + Error *string `json:"error"` + PendingDelete bool `json:"pendingDelete"` + AutoRemoving *bool `json:"autoRemoving,omitempty"` + WSPath *string `json:"wsPath,omitempty"` + SSServerKey *string `json:"ssServerKey,omitempty"` // SocksUser/SocksPassword are the node-level fixed account of a // socks5-auth inbound (round-3 item 21, the 3x-ui inbound model): // the inbound's only user, carried by every subscriber's link for @@ -824,24 +840,24 @@ type Node struct { // the final state once the fixed window (from RebuildPendingAt, the // FIRST pending change) elapses. Persisted so a panel restart inside // the window cannot strand the node un-deployed. - RebuildPending bool `json:"rebuildPending,omitempty"` - RebuildPendingAt string `json:"rebuildPendingAt,omitempty"` - RebuildPendingExit bool `json:"rebuildPendingExit,omitempty"` - Reality *RealityConfig `json:"reality,omitempty"` - ExitCredentials *ExitCredentials `json:"exitCredentials,omitempty"` - UsageCounters *UsageCounters `json:"usageCounters"` - RetiredUsageEpochs []string `json:"retiredUsageEpochs"` - MeteredTraffic *MeteredTraffic `json:"meteredTraffic"` - LastUsageAt *string `json:"lastUsageAt"` - SortIndex int `json:"sortIndex"` - SpeedUpBps *float64 `json:"speedUpBps,omitempty"` - SpeedDownBps *float64 `json:"speedDownBps,omitempty"` - SpeedAt *string `json:"speedAt,omitempty"` - LastActiveAt *string `json:"lastActiveAt,omitempty"` - LastSampleAt *string `json:"lastSampleAt,omitempty"` - CreatedAt string `json:"createdAt"` - UpdatedAt string `json:"updatedAt"` - Extra extraFields `json:"-"` + RebuildPending bool `json:"rebuildPending,omitempty"` + RebuildPendingAt string `json:"rebuildPendingAt,omitempty"` + RebuildPendingExit bool `json:"rebuildPendingExit,omitempty"` + Reality *RealityConfig `json:"reality,omitempty"` + ExitCredentials *ExitCredentials `json:"exitCredentials,omitempty"` + UsageCounters *UsageCounters `json:"usageCounters"` + RetiredUsageEpochs []string `json:"retiredUsageEpochs"` + MeteredTraffic *MeteredTraffic `json:"meteredTraffic"` + LastUsageAt *string `json:"lastUsageAt"` + SortIndex int `json:"sortIndex"` + SpeedUpBps *float64 `json:"speedUpBps,omitempty"` + SpeedDownBps *float64 `json:"speedDownBps,omitempty"` + SpeedAt *string `json:"speedAt,omitempty"` + LastActiveAt *string `json:"lastActiveAt,omitempty"` + LastSampleAt *string `json:"lastSampleAt,omitempty"` + CreatedAt string `json:"createdAt"` + UpdatedAt string `json:"updatedAt"` + Extra extraFields `json:"-"` } func (n *Node) UnmarshalJSON(raw []byte) error {