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

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

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

Tests: panel — issuance, hash-only storage, scrypt-free token path
(sabotaged KeyHash still authenticates), expiry fallback + rotation,
hash-mismatch fallback, revocation, per-record binding, key-only
rotation. Agent — capture/persist/reuse against a fake panel, 401
fallback and re-issue, old-panel key-only, client-side expiry,
double rejection surfacing without retry loops.
This commit is contained in:
aaaamoooo
2026-10-21 13:12:19 +00:00
parent 18074daf50
commit 408b03ee68
6 changed files with 796 additions and 100 deletions
+255
View File
@@ -0,0 +1,255 @@
package main
// Item 25: agent session tokens. After one scrypt-verified key auth the
// panel issues a random session token (only its SHA-256 hash is stored);
// subsequent requests authenticate by hash compare. These tests drive
// the real /api/agent/heartbeat route through the smoke harness.
import (
"net/http"
"net/http/httptest"
"path/filepath"
"testing"
"time"
)
const testAgentKey = "test-agent-key-0123456789abcdef-XYZ"
func seedAgentApp(t *testing.T, key string) (*App, string) {
t.Helper()
cookieSecure = false
store := NewStore(filepath.Join(t.TempDir(), "nexusgate.json"))
if err := store.Init(); err != nil {
t.Fatalf("store init: %v", err)
}
fp := TokenFingerprint(key)
agentID := ""
_, err := store.Transaction(func(d *Data) (any, error) {
d.Servers = append(d.Servers, &Server{
ID: "srv_tok", Name: "tok", Status: "online",
CreatedAt: NowISO(), UpdatedAt: NowISO(),
})
agent := &Agent{
ID: NewID("agt"),
ServerID: "srv_tok",
KeyHash: HashSecret(key),
KeyFingerprint: &fp,
Status: "active",
CreatedAt: NowISO(),
LastSeenAt: "2000-01-01T00:00:00.000Z",
}
agentID = agent.ID
d.Agents = append(d.Agents, agent)
return nil, nil
})
if err != nil {
t.Fatalf("seed agent: %v", err)
}
return NewApp(store, NewSessionManager(12), NewOrchestrator(store)), agentID
}
func agentHeartbeat(t *testing.T, app *App, bearer string) *httptest.ResponseRecorder {
t.Helper()
return doRequest(app, http.MethodPost, "/api/agent/heartbeat", "{}", map[string]string{
"Authorization": "Bearer " + bearer,
})
}
func storedAgent(t *testing.T, app *App, agentID string) *Agent {
t.Helper()
var out *Agent
app.store.View(func(d *Data) {
for _, agent := range d.Agents {
if agent.ID == agentID {
copied := *agent
out = &copied
}
}
})
if out == nil {
t.Fatalf("agent %s not in store", agentID)
}
return out
}
func mutateAgent(t *testing.T, app *App, agentID string, fn func(*Agent)) {
t.Helper()
_, err := app.store.Transaction(func(d *Data) (any, error) {
for _, agent := range d.Agents {
if agent.ID == agentID {
fn(agent)
}
}
return nil, nil
})
if err != nil {
t.Fatalf("mutate agent: %v", err)
}
}
// Key auth succeeds and issues a session token whose hash (and only the
// hash) lands on the agent record.
func TestAgentSessionTokenIssuedOnKeyAuth(t *testing.T) {
app, agentID := seedAgentApp(t, testAgentKey)
rec := agentHeartbeat(t, app, testAgentKey)
if rec.Code != 200 {
t.Fatalf("key heartbeat: got %d (%s)", rec.Code, rec.Body.String())
}
token := rec.Header().Get(agentSessionTokenHeader)
expires := rec.Header().Get(agentSessionTokenExpiresHeader)
if token == "" || expires == "" {
t.Fatalf("no session token issued: token=%q expires=%q", token, expires)
}
if token == testAgentKey {
t.Fatal("session token must differ from the agent key")
}
stored := storedAgent(t, app, agentID)
if stored.SessionTokenHash == nil || *stored.SessionTokenHash != TokenFingerprint(token) {
t.Fatalf("stored hash does not match issued token: %+v", stored.SessionTokenHash)
}
if stored.SessionTokenExpiresAt == nil || *stored.SessionTokenExpiresAt != expires {
t.Fatalf("stored expiry %v != header expiry %q", stored.SessionTokenExpiresAt, expires)
}
expMs, ok := ParseISOMs(expires)
if !ok || expMs <= time.Now().UnixMilli() {
t.Fatalf("issued expiry not in the future: %q", expires)
}
}
// Token auth succeeds even when the key hash can no longer verify —
// proving the token path never touches scrypt — and does not re-issue.
func TestAgentSessionTokenAuthSkipsScrypt(t *testing.T) {
app, agentID := seedAgentApp(t, testAgentKey)
token := agentHeartbeat(t, app, testAgentKey).Header().Get(agentSessionTokenHeader)
if token == "" {
t.Fatal("setup: no token issued")
}
// Sabotage the key hash: any scrypt verification now fails.
mutateAgent(t, app, agentID, func(agent *Agent) {
agent.KeyHash = "scrypt:deadbeef:abcd"
})
rec := agentHeartbeat(t, app, token)
if rec.Code != 200 {
t.Fatalf("token heartbeat with sabotaged key hash: got %d", rec.Code)
}
if again := rec.Header().Get(agentSessionTokenHeader); again != "" {
t.Fatalf("token path must not re-issue, got %q", again)
}
// And the (now unverifiable) key is rejected.
if rec := agentHeartbeat(t, app, testAgentKey); rec.Code != 401 {
t.Fatalf("sabotaged key: got %d, want 401", rec.Code)
}
}
// An expired token is rejected; the key still works and re-issues a
// fresh token (rotation).
func TestAgentSessionTokenExpiryFallsBackToKey(t *testing.T) {
app, agentID := seedAgentApp(t, testAgentKey)
token := agentHeartbeat(t, app, testAgentKey).Header().Get(agentSessionTokenHeader)
mutateAgent(t, app, agentID, func(agent *Agent) {
past := ISOFromTime(time.Now().Add(-time.Hour))
agent.SessionTokenExpiresAt = &past
})
if rec := agentHeartbeat(t, app, token); rec.Code != 401 {
t.Fatalf("expired token: got %d, want 401", rec.Code)
}
rec := agentHeartbeat(t, app, testAgentKey)
if rec.Code != 200 {
t.Fatalf("key after expiry: got %d", rec.Code)
}
fresh := rec.Header().Get(agentSessionTokenHeader)
if fresh == "" || fresh == token {
t.Fatalf("expected a fresh token after expiry, got %q", fresh)
}
if rec := agentHeartbeat(t, app, fresh); rec.Code != 200 {
t.Fatalf("fresh token: got %d", rec.Code)
}
}
// A token whose stored hash no longer matches (tampered / rotated
// server-side) is rejected; the key re-issues.
func TestAgentSessionTokenHashMismatchFallsBack(t *testing.T) {
app, agentID := seedAgentApp(t, testAgentKey)
token := agentHeartbeat(t, app, testAgentKey).Header().Get(agentSessionTokenHeader)
mutateAgent(t, app, agentID, func(agent *Agent) {
wrong := TokenFingerprint("some-other-token")
agent.SessionTokenHash = &wrong
})
if rec := agentHeartbeat(t, app, token); rec.Code != 401 {
t.Fatalf("mismatched token: got %d, want 401", rec.Code)
}
rec := agentHeartbeat(t, app, testAgentKey)
if rec.Code != 200 || rec.Header().Get(agentSessionTokenHeader) == "" {
t.Fatalf("key re-issue after mismatch: code=%d token=%q", rec.Code, rec.Header().Get(agentSessionTokenHeader))
}
}
// Revoking the agent kills both the token and the key immediately, and
// Revoke() clears the stored token material.
func TestAgentSessionTokenRevokedAgentRejected(t *testing.T) {
app, agentID := seedAgentApp(t, testAgentKey)
token := agentHeartbeat(t, app, testAgentKey).Header().Get(agentSessionTokenHeader)
mutateAgent(t, app, agentID, func(agent *Agent) { agent.Revoke() })
stored := storedAgent(t, app, agentID)
if stored.SessionTokenHash != nil || stored.SessionTokenExpiresAt != nil {
t.Fatal("Revoke() must clear session token fields")
}
if rec := agentHeartbeat(t, app, token); rec.Code != 401 {
t.Fatalf("revoked agent token: got %d, want 401", rec.Code)
}
if rec := agentHeartbeat(t, app, testAgentKey); rec.Code != 401 {
t.Fatalf("revoked agent key: got %d, want 401", rec.Code)
}
}
// The token is bound to its own agent record: it must not authenticate
// a request as a different agent (observed via which record's
// LastSeenAt the heartbeat advances).
func TestAgentSessionTokenBoundToRecord(t *testing.T) {
app, agentID := seedAgentApp(t, testAgentKey)
otherKey := "other-agent-key-9876543210"
otherFP := TokenFingerprint(otherKey)
otherID := ""
_, err := app.store.Transaction(func(d *Data) (any, error) {
other := &Agent{
ID: NewID("agt"), ServerID: "srv_tok",
KeyHash: HashSecret(otherKey), KeyFingerprint: &otherFP,
Status: "active", CreatedAt: NowISO(),
LastSeenAt: "2000-01-01T00:00:00.000Z",
}
otherID = other.ID
d.Agents = append(d.Agents, other)
return nil, nil
})
if err != nil {
t.Fatalf("seed second agent: %v", err)
}
token := agentHeartbeat(t, app, testAgentKey).Header().Get(agentSessionTokenHeader)
if rec := agentHeartbeat(t, app, token); rec.Code != 200 {
t.Fatalf("token heartbeat: got %d", rec.Code)
}
if got := storedAgent(t, app, agentID).LastSeenAt; got == "2000-01-01T00:00:00.000Z" {
t.Fatal("token heartbeat did not advance its own agent's LastSeenAt")
}
if got := storedAgent(t, app, otherID).LastSeenAt; got != "2000-01-01T00:00:00.000Z" {
t.Fatalf("token heartbeat advanced the WRONG agent (LastSeenAt=%s)", got)
}
}
// Key-only agents (pre-token clients) keep working request after
// request; each key auth rotates the stored token.
func TestAgentKeyOnlyRequestsKeepWorking(t *testing.T) {
app, agentID := seedAgentApp(t, testAgentKey)
var prevHash string
for i := 0; i < 3; i++ {
rec := agentHeartbeat(t, app, testAgentKey)
if rec.Code != 200 {
t.Fatalf("key heartbeat %d: got %d", i, rec.Code)
}
stored := storedAgent(t, app, agentID)
if stored.SessionTokenHash == nil || *stored.SessionTokenHash == prevHash {
t.Fatalf("key heartbeat %d did not rotate the token", i)
}
prevHash = *stored.SessionTokenHash
}
}
+150 -24
View File
@@ -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
View File
@@ -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 {