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:
@@ -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
|
||||
}
|
||||
}
|
||||
+150
-24
@@ -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:]...)
|
||||
|
||||
+64
-48
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user