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)
|
||||
|
||||
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:
|
||||
|
||||
+150
-28
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user