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:
+96
-19
@@ -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": "<ts>"
|
||||
},
|
||||
"status": 200
|
||||
},
|
||||
"serversAfterEngineHeartbeat": {
|
||||
"body": {
|
||||
"servers": [
|
||||
{
|
||||
"agentVersion": "0.6.8",
|
||||
"createdAt": "<ts>",
|
||||
"enabled": true,
|
||||
"engine": {
|
||||
"at": "<ts>",
|
||||
"certificates": [],
|
||||
"detail": "",
|
||||
"singBoxInstalled": true,
|
||||
"singBoxStatus": "ready",
|
||||
"singBoxVersion": "1.13.21",
|
||||
"status": "ready"
|
||||
},
|
||||
"id": "<srv>#1",
|
||||
"labels": [],
|
||||
"lastSeenAt": "<ts>",
|
||||
"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": "<ts>",
|
||||
"usage": {
|
||||
"error": null,
|
||||
"lastReportAt": "<ts>",
|
||||
"sampleCount": 2
|
||||
}
|
||||
},
|
||||
{
|
||||
"agentVersion": "0.6.8",
|
||||
"createdAt": "<ts>",
|
||||
"enabled": true,
|
||||
"engine": null,
|
||||
"id": "<srv>#2",
|
||||
"labels": [],
|
||||
"lastSeenAt": "<ts>",
|
||||
"name": "Exit",
|
||||
"pendingCleanup": 0,
|
||||
"portRangeEnd": 50000,
|
||||
"portRangeStart": 20000,
|
||||
"publicAddress": "exit.example.com",
|
||||
"publicAddressV6": "",
|
||||
"region": "",
|
||||
"role": "exit",
|
||||
"status": "online",
|
||||
"system": {},
|
||||
"tlsDomain": "",
|
||||
"updatedAt": "<ts>"
|
||||
}
|
||||
]
|
||||
},
|
||||
"status": 200
|
||||
},
|
||||
"credentials": {
|
||||
"alice": {
|
||||
"body": {
|
||||
@@ -787,7 +856,7 @@
|
||||
"id": "<res>#6",
|
||||
"inbounds": [
|
||||
{
|
||||
"listen": "198.18.187.63",
|
||||
"listen": "<hostip>",
|
||||
"listen_port": "<port>#4",
|
||||
"tag": "ng-<nod>#3-in",
|
||||
"tls": {
|
||||
@@ -864,7 +933,7 @@
|
||||
"id": "<res>#6",
|
||||
"inbounds": [
|
||||
{
|
||||
"listen": "198.18.187.63",
|
||||
"listen": "<hostip>",
|
||||
"listen_port": "<port>#4",
|
||||
"tag": "ng-<nod>#3-in",
|
||||
"tls": {
|
||||
@@ -1403,7 +1472,7 @@
|
||||
"id": "<res>#2",
|
||||
"inbounds": [
|
||||
{
|
||||
"listen": "198.18.187.62",
|
||||
"listen": "<hostip>",
|
||||
"listen_port": "<port>#2",
|
||||
"tag": "ng-<nod>#2-in",
|
||||
"tls": {
|
||||
@@ -1491,7 +1560,7 @@
|
||||
"id": "<res>#2",
|
||||
"inbounds": [
|
||||
{
|
||||
"listen": "198.18.187.62",
|
||||
"listen": "<hostip>",
|
||||
"listen_port": "<port>#2",
|
||||
"tag": "ng-<nod>#2-in",
|
||||
"tls": {
|
||||
@@ -1578,7 +1647,7 @@
|
||||
"id": "<res>#1",
|
||||
"inbounds": [
|
||||
{
|
||||
"listen": "198.18.187.61",
|
||||
"listen": "<hostip>",
|
||||
"listen_port": "<port>#1",
|
||||
"method": "2022-blake3-aes-256-gcm",
|
||||
"password": "<secret>#11",
|
||||
@@ -1629,7 +1698,7 @@
|
||||
"id": "<res>#1",
|
||||
"inbounds": [
|
||||
{
|
||||
"listen": "198.18.187.61",
|
||||
"listen": "<hostip>",
|
||||
"listen_port": "<port>#1",
|
||||
"method": "2022-blake3-aes-256-gcm",
|
||||
"password": "<secret>#11",
|
||||
@@ -2324,7 +2393,7 @@
|
||||
"id": "<res>#2",
|
||||
"inbounds": [
|
||||
{
|
||||
"listen": "198.18.60.35",
|
||||
"listen": "<hostip>",
|
||||
"listen_port": "<port>#2",
|
||||
"tag": "ng-<nod>#2-in",
|
||||
"tls": {
|
||||
@@ -2406,7 +2475,7 @@
|
||||
"id": "<res>#1",
|
||||
"inbounds": [
|
||||
{
|
||||
"listen": "198.18.60.37",
|
||||
"listen": "<hostip>",
|
||||
"listen_port": "<port>#1",
|
||||
"method": "2022-blake3-aes-256-gcm",
|
||||
"password": "<secret>#11",
|
||||
@@ -2457,7 +2526,7 @@
|
||||
"id": "<res>#4",
|
||||
"inbounds": [
|
||||
{
|
||||
"listen": "198.18.60.35",
|
||||
"listen": "<hostip>",
|
||||
"listen_port": "<port>#3",
|
||||
"method": "2022-blake3-aes-128-gcm",
|
||||
"password": "<secret>#12",
|
||||
@@ -2521,7 +2590,7 @@
|
||||
"id": "<res>#6",
|
||||
"inbounds": [
|
||||
{
|
||||
"listen": "198.18.187.63",
|
||||
"listen": "<hostip>",
|
||||
"listen_port": "<port>#4",
|
||||
"tag": "ng-<nod>#3-in",
|
||||
"tls": {
|
||||
@@ -2598,7 +2667,7 @@
|
||||
"id": "<res>#2",
|
||||
"inbounds": [
|
||||
{
|
||||
"listen": "198.18.187.62",
|
||||
"listen": "<hostip>",
|
||||
"listen_port": "<port>#2",
|
||||
"tag": "ng-<nod>#2-in",
|
||||
"tls": {
|
||||
@@ -2680,7 +2749,7 @@
|
||||
"id": "<res>#1",
|
||||
"inbounds": [
|
||||
{
|
||||
"listen": "198.18.187.61",
|
||||
"listen": "<hostip>",
|
||||
"listen_port": "<port>#1",
|
||||
"method": "2022-blake3-aes-256-gcm",
|
||||
"password": "<secret>#11",
|
||||
@@ -2717,15 +2786,15 @@
|
||||
}
|
||||
],
|
||||
"keyStore": {
|
||||
"reality-<nod>#2": {
|
||||
"privateKey": "<rpriv>#2",
|
||||
"publicKey": "<rpub>#1",
|
||||
"updatedAt": "<ts>"
|
||||
},
|
||||
"reality-<nod>#3": {
|
||||
"privateKey": "<rpriv>#1",
|
||||
"publicKey": "<rpub>#2",
|
||||
"updatedAt": "<ts>"
|
||||
},
|
||||
"reality-<nod>#2": {
|
||||
"privateKey": "<rpriv>#2",
|
||||
"publicKey": "<rpub>#1",
|
||||
"updatedAt": "<ts>"
|
||||
}
|
||||
},
|
||||
"nodeClients": [
|
||||
@@ -2922,7 +2991,15 @@
|
||||
"servers": [
|
||||
{
|
||||
"createdAt": "<ts>",
|
||||
"engine": null,
|
||||
"engine": {
|
||||
"at": "<ts>",
|
||||
"certificates": [],
|
||||
"detail": "",
|
||||
"singBoxInstalled": true,
|
||||
"singBoxStatus": "ready",
|
||||
"singBoxVersion": "1.13.21",
|
||||
"status": "ready"
|
||||
},
|
||||
"id": "<srv>#1",
|
||||
"labels": [],
|
||||
"lastSeenAt": "<ts>",
|
||||
|
||||
@@ -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`)),
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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:"-"`
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user