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:
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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:]...)
|
||||||
|
|||||||
@@ -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"`
|
||||||
|
|||||||
Reference in New Issue
Block a user