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
+52
View File
@@ -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"
+81
View File
@@ -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)
}
}