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.
406 lines
12 KiB
Go
406 lines
12 KiB
Go
// NexusGate node agent — Go rewrite of agent/agent.js (Node.js) with 1:1
|
|
// protocol parity against the panel's /api/agent/* endpoints.
|
|
//
|
|
// Configuration comes from the process environment, exactly like the Node
|
|
// version: systemd EnvironmentFile or agent/run.sh sources
|
|
// /etc/nexusgate/agent.env before exec. This binary never reads the env
|
|
// file itself, same as agent.js.
|
|
package main
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"os"
|
|
"regexp"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
// version is reported to the panel in every heartbeat. It MUST stay a plain
|
|
// semver: lib/orchestrator.js parses it with split('.').map(Number) and
|
|
// requires >= 0.6.1 before allowing AnyTLS deploys, so a suffix such as
|
|
// "0.6.8-go" would parse to NaN and break that gate.
|
|
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
|
|
SingBoxVersionFile string
|
|
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 {
|
|
if v, ok := os.LookupEnv(name); ok {
|
|
return v
|
|
}
|
|
return def
|
|
}
|
|
|
|
func loadConfig() config {
|
|
root := envOr("NG_CONFIG_DIR", "/etc/nexusgate/agent")
|
|
// POLL_MS = Math.max(3, Number(NG_POLL_SECONDS || 8)) * 1000.
|
|
// JS yields NaN for a non-numeric value; we clamp to the 3s floor instead
|
|
// (documented divergence, only affects a misconfigured env).
|
|
pollSec := 8.0
|
|
if raw, ok := os.LookupEnv("NG_POLL_SECONDS"); ok && raw != "" {
|
|
if n, err := strconv.ParseFloat(raw, 64); err == nil {
|
|
pollSec = n
|
|
} else {
|
|
pollSec = 0
|
|
}
|
|
}
|
|
if pollSec < 3 {
|
|
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"),
|
|
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"),
|
|
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),
|
|
}
|
|
}
|
|
|
|
var cfg = loadConfig()
|
|
|
|
// stateMu serializes access to on-disk agent state (resource files, sing-box
|
|
// config, keys, cursor). agent.js gets this for free from Node's event loop
|
|
// plus synchronous fs calls; the Go goroutines need it explicitly.
|
|
var stateMu sync.Mutex
|
|
|
|
// lastEngineError holds the most recent engine failure text; serviceHealth
|
|
// surfaces it as the detail string while sing-box is down (agent.js keeps a
|
|
// module-level `lastEngineError` variable for the same purpose).
|
|
var lastEngineError struct {
|
|
mu sync.Mutex
|
|
s string
|
|
}
|
|
|
|
func setLastEngineError(v string) {
|
|
lastEngineError.mu.Lock()
|
|
lastEngineError.s = v
|
|
lastEngineError.mu.Unlock()
|
|
}
|
|
|
|
func getLastEngineError() string {
|
|
lastEngineError.mu.Lock()
|
|
defer lastEngineError.mu.Unlock()
|
|
return lastEngineError.s
|
|
}
|
|
|
|
var httpClient = &http.Client{Timeout: 20 * time.Second}
|
|
|
|
// isoNow mirrors new Date().toISOString(): UTC, millisecond precision, Z suffix.
|
|
func isoNow() string {
|
|
return time.Now().UTC().Format("2006-01-02T15:04:05.000Z07:00")
|
|
}
|
|
|
|
func logf(format string, args ...interface{}) {
|
|
fmt.Printf("%s %s\n", isoNow(), fmt.Sprintf(format, args...))
|
|
}
|
|
|
|
var (
|
|
rePrivateKey = regexp.MustCompile(`(?i)\bPrivate\s*Key\s*:\s*[^\s,;]+`)
|
|
reBearer = regexp.MustCompile(`(?i)\bBearer\s+[^\s,;]+`)
|
|
)
|
|
|
|
// safeError mirrors agent.js safeError(): redacts private keys and bearer
|
|
// tokens from text that may be logged or reported to the panel.
|
|
func safeError(value string) string {
|
|
value = rePrivateKey.ReplaceAllString(value, "PrivateKey: [REDACTED]")
|
|
value = reBearer.ReplaceAllString(value, "Bearer [REDACTED]")
|
|
return value
|
|
}
|
|
|
|
// truncateRunes ~ JS String.slice(0, n) (JS counts UTF-16 units; close enough
|
|
// for diagnostics, and safer than cutting a UTF-8 sequence mid-rune).
|
|
func truncateRunes(s string, n int) string {
|
|
r := []rune(s)
|
|
if len(r) > n {
|
|
return string(r[:n])
|
|
}
|
|
return s
|
|
}
|
|
|
|
func fileExists(p string) bool {
|
|
_, err := os.Stat(p)
|
|
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. 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 "+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
|
|
}
|
|
if resp.StatusCode < 200 || resp.StatusCode > 299 {
|
|
if msg, ok := parsed["message"].(string); ok && msg != "" {
|
|
return nil, fmt.Errorf("%s", msg)
|
|
}
|
|
return nil, fmt.Errorf("Controller returned %d", resp.StatusCode)
|
|
}
|
|
return parsed, nil
|
|
}
|
|
|
|
func requestJSON(endpoint string, payload interface{}) (map[string]interface{}, error) {
|
|
body, err := marshalJSON(payload)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return request(endpoint, []byte(body))
|
|
}
|
|
|
|
func ensureDirectories() error {
|
|
if err := os.MkdirAll(cfg.ResourceDir, 0o700); err != nil {
|
|
return err
|
|
}
|
|
return os.MkdirAll("/var/log/nexusgate", 0o750)
|
|
}
|
|
|
|
func sleepMS(ms int64) {
|
|
time.Sleep(time.Duration(ms) * time.Millisecond)
|
|
}
|
|
|
|
func pollLoop() {
|
|
for {
|
|
result, err := request("/api/agent/poll", []byte("{}"))
|
|
if err != nil {
|
|
logf("Poll failed %s", err.Error())
|
|
ms := cfg.PollMS
|
|
if ms < 10000 {
|
|
ms = 10000
|
|
}
|
|
sleepMS(ms)
|
|
continue
|
|
}
|
|
jobRaw, ok := result["job"]
|
|
if !ok || jobRaw == nil {
|
|
sleepMS(cfg.PollMS)
|
|
continue
|
|
}
|
|
encoded, _ := json.Marshal(jobRaw)
|
|
var job agentJob
|
|
if err := json.Unmarshal(encoded, &job); err != nil {
|
|
logf("Poll failed %s", err.Error())
|
|
sleepMS(cfg.PollMS)
|
|
continue
|
|
}
|
|
execute(&job)
|
|
}
|
|
}
|
|
|
|
func heartbeatLoop() {
|
|
ticker := time.NewTicker(30 * time.Second)
|
|
defer ticker.Stop()
|
|
for range ticker.C {
|
|
if err := heartbeat(); err != nil {
|
|
logf("Heartbeat failed %s", err.Error())
|
|
}
|
|
}
|
|
}
|
|
|
|
func runAgent() error {
|
|
if cfg.Controller == "" || cfg.AgentKey == "" {
|
|
return fmt.Errorf("NG_CONTROLLER and NG_AGENT_KEY are required")
|
|
}
|
|
if err := ensureDirectories(); err != nil {
|
|
return err
|
|
}
|
|
logf("NexusGate Agent v%s starting", version)
|
|
// Keep the control channel alive even when a stale node configuration
|
|
// cannot start (same rationale as agent.js main()).
|
|
stateMu.Lock()
|
|
if err := activateSingBoxLocked(); err != nil {
|
|
setLastEngineError(safeError(err.Error()))
|
|
logf("Initial sing-box activation failed %s", getLastEngineError())
|
|
}
|
|
stateMu.Unlock()
|
|
if err := heartbeat(); err != nil {
|
|
logf("Initial heartbeat failed %s", err.Error())
|
|
}
|
|
go heartbeatLoop()
|
|
go usageLoop()
|
|
pollLoop()
|
|
return nil
|
|
}
|
|
|
|
func main() {
|
|
// `nexusgate-agent flush-usage` mirrors `node agent.js flush-usage`,
|
|
// invoked by agent-update.sh before the agent binary is replaced so the
|
|
// running sing-box counters are persisted first.
|
|
if len(os.Args) > 1 && os.Args[1] == "flush-usage" {
|
|
if err := flushAllUsageBeforeRestart(); err != nil {
|
|
fmt.Fprintln(os.Stderr, safeError(err.Error()))
|
|
os.Exit(1)
|
|
}
|
|
return
|
|
}
|
|
if err := runAgent(); err != nil {
|
|
fmt.Fprintln(os.Stderr, safeError(err.Error()))
|
|
os.Exit(1)
|
|
}
|
|
}
|