feat(agent+panel): sing-box core version probe and display (round-2 #4)

agent-go: engineReport gains singBoxVersion. The probe runs
`sing-box version` once, parses the version token from its first line
('unknown' from unstamped builds reports empty), and caches it keyed by
the binary's mtime+size — re-probed only when an engine install/update
replaces the file. Steady-state heartbeats pay one stat, no subprocess.
Agent version stays 0.6.8.

panel-go: EngineState.singBoxVersion persisted from heartbeats (old
agents without the field keep showing '-'), servers table core-version
column now renders it. Golden gains heartbeatWithEngine +
serversAfterEngineHeartbeat sections locking the full chain, plus a
listen-address (<hostip>) normalization for the sandbox bind address.
singboxversion_test.go covers parsing, cache hits, and re-probe on
binary replacement.
This commit is contained in:
aaaamoooo
2026-10-21 11:18:00 +08:00
parent 35c9bd2cc0
commit b49c134513
7 changed files with 248 additions and 23 deletions
+16 -3
View File
@@ -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 '<hostip>';
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) ? '<num>' : squashVolatile(v);
out[k] = typeof v === 'number' && VOLATILE_NUM_KEYS.test(k) ? '<num>' : 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`)),