diff --git a/agent-go/health.go b/agent-go/health.go index 38d6f0f..e8ad46c 100644 --- a/agent-go/health.go +++ b/agent-go/health.go @@ -199,6 +199,57 @@ type engineReport struct { SingBoxInstalled bool `json:"singBoxInstalled"` Certificates []string `json:"certificates"` SingBoxStatus string `json:"singBoxStatus"` + SingBoxVersion string `json:"singBoxVersion,omitempty"` +} + +// ---- sing-box version probe ------------------------------------------------- +// The core version is probed by running `sing-box version` exactly once and +// cached against the binary's mtime+size. It is re-probed only when the file +// changes (an engine install/update replaces the binary) — the first probe +// happens on the first heartbeat after agent start. Steady-state heartbeats +// pay one stat, never a subprocess. + +var singBoxVersionMu sync.Mutex +var singBoxVersionCache struct { + path string + mtimeNs int64 + size int64 + version string + probed bool +} + +// parseSingBoxVersion extracts the version token from the first line of +// `sing-box version` output ("sing-box version 1.13.21"). Unstamped local +// builds print "unknown"; those report no version. +func parseSingBoxVersion(out string) string { + line := out + if i := strings.IndexByte(line, '\n'); i >= 0 { + line = line[:i] + } + fields := strings.Fields(line) + if len(fields) >= 3 && fields[0] == "sing-box" && fields[1] == "version" && fields[2] != "unknown" { + return fields[2] + } + return "" +} + +func singBoxVersion() string { + info, err := os.Stat(cfg.SingBoxBin) + if err != nil { + return "" + } + singBoxVersionMu.Lock() + defer singBoxVersionMu.Unlock() + c := &singBoxVersionCache + if c.probed && c.path == cfg.SingBoxBin && c.mtimeNs == info.ModTime().UnixNano() && c.size == info.Size() { + return c.version + } + version := "" + if out, _, code := runCapture(5*time.Second, cfg.SingBoxBin, "version"); code == 0 { + version = parseSingBoxVersion(out) + } + c.path, c.mtimeNs, c.size, c.version, c.probed = cfg.SingBoxBin, info.ModTime().UnixNano(), info.Size(), version, true + return version } // engineHealth mirrors agent.js engineHealth(). Callers must hold stateMu @@ -214,6 +265,7 @@ func engineHealthLocked(resources []map[string]interface{}) engineReport { if installed { result.Status = service.Status result.Detail = service.Detail + result.SingBoxVersion = singBoxVersion() } else { result.Status = "error" result.Detail = "sing-box 及统计组件未安装;请在入口机器运行 ng-agent engine install" diff --git a/agent-go/singboxversion_test.go b/agent-go/singboxversion_test.go new file mode 100644 index 0000000..4187ad3 --- /dev/null +++ b/agent-go/singboxversion_test.go @@ -0,0 +1,81 @@ +package main + +// Tests for the sing-box version probe (round-2 item 4): parsing of +// `sing-box version` output and the mtime/size-keyed cache that keeps +// heartbeats from spawning the binary every round. + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestParseSingBoxVersion(t *testing.T) { + cases := []struct{ in, want string }{ + {"sing-box version 1.13.21\n\nEnvironment: go1.24.6 linux/amd64\n", "1.13.21"}, + {"sing-box version 1.13.21", "1.13.21"}, + {"sing-box version unknown\n", ""}, + {"", ""}, + {"garbage output\n", ""}, + {"sing-box version\n", ""}, + } + for _, c := range cases { + if got := parseSingBoxVersion(c.in); got != c.want { + t.Fatalf("parseSingBoxVersion(%q) = %q, want %q", c.in, got, c.want) + } + } +} + +func writeFakeSingBox(t *testing.T, dir, version string) (binPath, countPath string) { + t.Helper() + binPath = filepath.Join(dir, "fake-sing-box") + countPath = filepath.Join(dir, "count") + script := "#!/bin/sh\necho x >> \"" + countPath + "\"\necho 'sing-box version " + version + "'\n" + if err := os.WriteFile(binPath, []byte(script), 0o755); err != nil { + t.Fatal(err) + } + return binPath, countPath +} + +func TestSingBoxVersionProbeCachesByMtime(t *testing.T) { + dir := t.TempDir() + binPath, countPath := writeFakeSingBox(t, dir, "1.2.3") + + oldBin := cfg.SingBoxBin + cfg.SingBoxBin = binPath + defer func() { cfg.SingBoxBin = oldBin }() + singBoxVersionCache.probed = false + defer func() { singBoxVersionCache.probed = false }() + + count := func() int { + data, _ := os.ReadFile(countPath) + return strings.Count(string(data), "x") + } + if got := singBoxVersion(); got != "1.2.3" { + t.Fatalf("first probe = %q, want 1.2.3", got) + } + if got := singBoxVersion(); got != "1.2.3" { + t.Fatalf("cached probe = %q, want 1.2.3", got) + } + if n := count(); n != 1 { + t.Fatalf("binary executed %d times across two heartbeats, want 1 (cached)", n) + } + // Replacing the binary (engine install/update) must trigger one re-probe. + writeFakeSingBox(t, dir, "1.2.4") + if got := singBoxVersion(); got != "1.2.4" { + t.Fatalf("probe after binary replacement = %q, want 1.2.4", got) + } + if n := count(); n != 2 { + t.Fatalf("binary executed %d times after replacement, want 2", n) + } +} + +func TestSingBoxVersionMissingBinary(t *testing.T) { + oldBin := cfg.SingBoxBin + cfg.SingBoxBin = filepath.Join(t.TempDir(), "does-not-exist") + defer func() { cfg.SingBoxBin = oldBin }() + if got := singBoxVersion(); got != "" { + t.Fatalf("missing binary: got %q, want empty", got) + } +} diff --git a/panel-go/golden/golden.json b/panel-go/golden/golden.json index 3e2bec2..2863026 100644 --- a/panel-go/golden/golden.json +++ b/panel-go/golden/golden.json @@ -1,6 +1,6 @@ { "meta": { - "capturedAt": "2026-10-20T20:56:25.514Z", + "capturedAt": "2026-10-20T21:02:25.354Z", "note": "Go panel golden baseline; see README.md in this directory." }, "sections": { @@ -483,6 +483,75 @@ "status": 200 } }, + "heartbeatWithEngine": { + "body": { + "ok": true, + "serverTime": "" + }, + "status": 200 + }, + "serversAfterEngineHeartbeat": { + "body": { + "servers": [ + { + "agentVersion": "0.6.8", + "createdAt": "", + "enabled": true, + "engine": { + "at": "", + "certificates": [], + "detail": "", + "singBoxInstalled": true, + "singBoxStatus": "ready", + "singBoxVersion": "1.13.21", + "status": "ready" + }, + "id": "#1", + "labels": [], + "lastSeenAt": "", + "name": "Entry", + "pendingCleanup": 0, + "portRangeEnd": 50000, + "portRangeStart": 20000, + "publicAddress": "entry.example.com", + "publicAddressV6": "v6.example.com", + "region": "", + "role": "relay", + "status": "online", + "system": {}, + "tlsDomain": "tls.example.com", + "updatedAt": "", + "usage": { + "error": null, + "lastReportAt": "", + "sampleCount": 2 + } + }, + { + "agentVersion": "0.6.8", + "createdAt": "", + "enabled": true, + "engine": null, + "id": "#2", + "labels": [], + "lastSeenAt": "", + "name": "Exit", + "pendingCleanup": 0, + "portRangeEnd": 50000, + "portRangeStart": 20000, + "publicAddress": "exit.example.com", + "publicAddressV6": "", + "region": "", + "role": "exit", + "status": "online", + "system": {}, + "tlsDomain": "", + "updatedAt": "" + } + ] + }, + "status": 200 + }, "credentials": { "alice": { "body": { @@ -787,7 +856,7 @@ "id": "#6", "inbounds": [ { - "listen": "198.18.187.63", + "listen": "", "listen_port": "#4", "tag": "ng-#3-in", "tls": { @@ -864,7 +933,7 @@ "id": "#6", "inbounds": [ { - "listen": "198.18.187.63", + "listen": "", "listen_port": "#4", "tag": "ng-#3-in", "tls": { @@ -1403,7 +1472,7 @@ "id": "#2", "inbounds": [ { - "listen": "198.18.187.62", + "listen": "", "listen_port": "#2", "tag": "ng-#2-in", "tls": { @@ -1491,7 +1560,7 @@ "id": "#2", "inbounds": [ { - "listen": "198.18.187.62", + "listen": "", "listen_port": "#2", "tag": "ng-#2-in", "tls": { @@ -1578,7 +1647,7 @@ "id": "#1", "inbounds": [ { - "listen": "198.18.187.61", + "listen": "", "listen_port": "#1", "method": "2022-blake3-aes-256-gcm", "password": "#11", @@ -1629,7 +1698,7 @@ "id": "#1", "inbounds": [ { - "listen": "198.18.187.61", + "listen": "", "listen_port": "#1", "method": "2022-blake3-aes-256-gcm", "password": "#11", @@ -2324,7 +2393,7 @@ "id": "#2", "inbounds": [ { - "listen": "198.18.60.35", + "listen": "", "listen_port": "#2", "tag": "ng-#2-in", "tls": { @@ -2406,7 +2475,7 @@ "id": "#1", "inbounds": [ { - "listen": "198.18.60.37", + "listen": "", "listen_port": "#1", "method": "2022-blake3-aes-256-gcm", "password": "#11", @@ -2457,7 +2526,7 @@ "id": "#4", "inbounds": [ { - "listen": "198.18.60.35", + "listen": "", "listen_port": "#3", "method": "2022-blake3-aes-128-gcm", "password": "#12", @@ -2521,7 +2590,7 @@ "id": "#6", "inbounds": [ { - "listen": "198.18.187.63", + "listen": "", "listen_port": "#4", "tag": "ng-#3-in", "tls": { @@ -2598,7 +2667,7 @@ "id": "#2", "inbounds": [ { - "listen": "198.18.187.62", + "listen": "", "listen_port": "#2", "tag": "ng-#2-in", "tls": { @@ -2680,7 +2749,7 @@ "id": "#1", "inbounds": [ { - "listen": "198.18.187.61", + "listen": "", "listen_port": "#1", "method": "2022-blake3-aes-256-gcm", "password": "#11", @@ -2717,15 +2786,15 @@ } ], "keyStore": { - "reality-#2": { - "privateKey": "#2", - "publicKey": "#1", - "updatedAt": "" - }, "reality-#3": { "privateKey": "#1", "publicKey": "#2", "updatedAt": "" + }, + "reality-#2": { + "privateKey": "#2", + "publicKey": "#1", + "updatedAt": "" } }, "nodeClients": [ @@ -2922,7 +2991,15 @@ "servers": [ { "createdAt": "", - "engine": null, + "engine": { + "at": "", + "certificates": [], + "detail": "", + "singBoxInstalled": true, + "singBoxStatus": "ready", + "singBoxVersion": "1.13.21", + "status": "ready" + }, "id": "#1", "labels": [], "lastSeenAt": "", diff --git a/panel-go/golden/golden.mjs b/panel-go/golden/golden.mjs index 09afca0..6a38769 100644 --- a/panel-go/golden/golden.mjs +++ b/panel-go/golden/golden.mjs @@ -50,12 +50,15 @@ function view(res) { // Keys whose numeric values are wall-clock derived and therefore not part of // the behavior contract; squashed wherever they appear. const VOLATILE_NUM_KEYS = /uptime|serverTimeMs|nowMs/i; -function squashVolatile(value) { - if (Array.isArray(value)) return value.map(squashVolatile); +function squashVolatile(value, keyName = null) { + // A resource's inbound `listen` is the panel host's own interface address + // (bind resolution) — environment-specific, not behavior. + if (keyName === 'listen' && typeof value === 'string' && /^\d{1,3}(\.\d{1,3}){3}$/.test(value)) return ''; + if (Array.isArray(value)) return value.map((v) => squashVolatile(v, keyName)); if (value && typeof value === 'object') { const out = {}; for (const [k, v] of Object.entries(value)) { - out[k] = typeof v === 'number' && VOLATILE_NUM_KEYS.test(k) ? '' : squashVolatile(v); + out[k] = typeof v === 'number' && VOLATILE_NUM_KEYS.test(k) ? '' : squashVolatile(v, k); } return out; } @@ -116,6 +119,16 @@ async function runScript(bin) { jobs: view(await client.admin('GET', '/api/jobs')), }); + // New-shape heartbeat carrying the core version (round-2 item 4): the + // panel must persist it on the server's engine state and expose it in + // the servers list. The seed-shape heartbeats above (no engine object) + // must keep working and leave engine state absent. + cap('heartbeatWithEngine', view(await client.agentPost('/api/agent/heartbeat', agentKeys.entry, { + version: '0.6.8', system: {}, + engine: { status: 'ready', detail: '', singBoxInstalled: true, certificates: [], singBoxStatus: 'ready', singBoxVersion: '1.13.21' }, + }))); + cap('serversAfterEngineHeartbeat', view(await client.admin('GET', '/api/servers'))); + cap('credentials', { alice: view(await client.admin('GET', `/api/customers/${ids.alice}/credentials`)), bob: view(await client.admin('GET', `/api/customers/${ids.bob}/credentials`)), diff --git a/panel-go/handlers.go b/panel-go/handlers.go index 63ee91c..68a3399 100644 --- a/panel-go/handlers.go +++ b/panel-go/handlers.go @@ -1026,6 +1026,7 @@ func (a *App) handleAgent(w http.ResponseWriter, r *http.Request, pathname strin SingBoxInstalled: installed, Certificates: certs, SingBoxStatus: singBoxStatus, + SingBoxVersion: CleanText(eng["singBoxVersion"], 32), At: now, } } diff --git a/panel-go/types.go b/panel-go/types.go index 4391d9c..1a57e37 100644 --- a/panel-go/types.go +++ b/panel-go/types.go @@ -332,6 +332,7 @@ type EngineState struct { SingBoxInstalled bool `json:"singBoxInstalled"` Certificates []string `json:"certificates"` SingBoxStatus string `json:"singBoxStatus"` + SingBoxVersion string `json:"singBoxVersion,omitempty"` At string `json:"at"` Extra extraFields `json:"-"` } diff --git a/public/app.js b/public/app.js index 5a5a6cd..868fa82 100644 --- a/public/app.js +++ b/public/app.js @@ -476,7 +476,7 @@ function renderServers() { ${cpuPct == null ? '—' : `${cpuPct.toFixed(1)}%`} ${memPct == null ? '—' : `${memPct.toFixed(1)}%`} ${esc(server.agentVersion || '—')}${server.agentVersion && versionLessThan(server.agentVersion, state.version) ? '有可用更新' : ''} - — + ${esc(server.engine && server.engine.singBoxVersion ? server.engine.singBoxVersion : '—')} ${system.uptimeSeconds ? fmtUptime(system.uptimeSeconds) : '—'} ${ICONS.users}${clients.total}${clients.online} ${server.lastSeenAt ? relTime(server.lastSeenAt) : '等待注册'}`;