From 71aa2cf4959b7f1a31111d5937bddfee1420ce8c Mon Sep 17 00:00:00 2001 From: lr00rl Date: Tue, 7 Jul 2026 05:04:23 -0400 Subject: [PATCH] feat(singboxdiscover): enrich primary sb list path with chain/line data MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `sb --json list` emits only per-inbound fields (name, protocol, port, etc.) — no outbound/routing and no `_lattice.line_id`. Since most nodes have a working `sb`, the primary path runs in production and left the just-shipped jump_edges producer empty. After the primary `sb --json list` succeeds, best-effort enrich each returned node from the on-box config files, matched by inbound tag (node.Name == config inbound tag / filename): - OutboundRef from the route map (inbound tag -> outbound tag) - OutboundServer/Port/Type from the outbound map (terminal/logical outbounds keep empty server/port) - LineID / NodeIdentityUUID from each inbound's `_lattice` block Never overwrites a non-empty value sb already provided; silently skips if the config is unreadable (primary path still returns sb data). Factors the "locate + read + parse config files" logic out of the fallback path into loadSingBoxRuntimeConfigFiles, reused by both paths, and adds singBoxLatticeByInbound for the tag -> identity map. --- internal/singboxdiscover/discover.go | 122 +++++++++++++++++++--- internal/singboxdiscover/discover_test.go | 80 ++++++++++++++ 2 files changed, 189 insertions(+), 13 deletions(-) diff --git a/internal/singboxdiscover/discover.go b/internal/singboxdiscover/discover.go index ca33284..0e937be 100644 --- a/internal/singboxdiscover/discover.go +++ b/internal/singboxdiscover/discover.go @@ -109,6 +109,11 @@ func Discover(ctx context.Context, source Source, nodeID string) (model.SingBoxI if listResp.Nodes != nil { inv.Nodes = listResp.Nodes } + // `sb --json list` emits only per-inbound fields — no outbound/routing and no + // `_lattice`. Best-effort enrich from the on-box config (matched by inbound + // tag) so the primary path also carries chain/line data; never overwrites a + // value sb already provided, and silently skips if the config is unreadable. + enrichSingBoxNodesFromConfig(source, inv.Nodes) // Best-effort core version/health; a failure here must not fail discovery. provCtx, cancel2 := context.WithTimeout(ctx, timeout) @@ -126,6 +131,29 @@ func Discover(ctx context.Context, source Source, nodeID string) (model.SingBoxI } func discoverRuntimeConfig(source Source, nodeID string, at time.Time) (model.SingBoxInventory, error) { + configs := loadSingBoxRuntimeConfigFiles(source) + if len(configs) == 0 { + return model.SingBoxInventory{}, fmt.Errorf("no readable sing-box runtime config files found") + } + routeMap := singBoxRouteMap(configs) + outboundMap := singBoxOutboundMap(configs) + inv := model.SingBoxInventory{NodeID: nodeID, At: at.UTC(), Status: "ok", Nodes: []model.SingBoxNode{}} + for _, parsed := range configs { + inv.Nodes = append(inv.Nodes, parseSingBoxRuntimeConfig(parsed.path, parsed.cfg, routeMap, outboundMap, strings.TrimSpace(source.Addr))...) + } + if inv.Nodes == nil { + inv.Nodes = []model.SingBoxNode{} + } + return inv, nil +} + +// loadSingBoxRuntimeConfigFiles locates and reads the on-box sing-box config +// files (the running process's -c/-C paths plus the /etc/sing-box defaults) and +// returns each one that parsed successfully. Returns an empty slice when none +// are found or readable. Both the config-FALLBACK path and the PRIMARY-path +// enrichment use this to recover the route/outbound/_lattice data that +// `sb --json list` omits. +func loadSingBoxRuntimeConfigFiles(source Source) []singBoxRuntimeConfigFile { filesFn := source.runtimeFiles if filesFn == nil { filesFn = singBoxRuntimeConfigFiles @@ -134,12 +162,8 @@ func discoverRuntimeConfig(source Source, nodeID string, at time.Time) (model.Si if readFn == nil { readFn = os.ReadFile } - files := filesFn() - if len(files) == 0 { - return model.SingBoxInventory{}, fmt.Errorf("no sing-box runtime config files found") - } configs := []singBoxRuntimeConfigFile{} - for _, path := range files { + for _, path := range filesFn() { raw, err := readFn(path) if err != nil { continue @@ -150,19 +174,91 @@ func discoverRuntimeConfig(source Source, nodeID string, at time.Time) (model.Si } configs = append(configs, singBoxRuntimeConfigFile{path: path, cfg: cfg}) } + return configs +} + +type singBoxLatticeIdentity struct { + LineID string + NodeUUID string +} + +// singBoxLatticeByInbound indexes each inbound's `_lattice` identity (line_id / +// node_uuid) by inbound tag, so a primary-path node can recover its LineID / +// NodeIdentityUUID by matching node.Name to the inbound tag. Inbounds without a +// tag or without either identity value are skipped. +func singBoxLatticeByInbound(configs []singBoxRuntimeConfigFile) map[string]singBoxLatticeIdentity { + out := map[string]singBoxLatticeIdentity{} + for _, parsed := range configs { + for _, in := range parsed.cfg.Inbounds { + tag := strings.TrimSpace(in.Tag) + if tag == "" { + continue + } + ident := singBoxLatticeIdentity{ + LineID: singBoxLatticeString(in.Lattice, "line_id"), + NodeUUID: singBoxLatticeString(in.Lattice, "node_uuid"), + } + if ident.LineID == "" && ident.NodeUUID == "" { + continue + } + out[tag] = ident + } + } + return out +} + +// enrichSingBoxNodesFromConfig augments PRIMARY-path (`sb --json list`) nodes +// with the route/outbound/_lattice data that the sb JSON does not carry. It +// reads the on-box config ONCE, matches each node by its inbound tag +// (node.Name == config inbound tag / filename), and fills only fields sb left +// empty — it NEVER overwrites a value sb already provided. Best-effort: if the +// config cannot be read, the sb data is returned unchanged. +func enrichSingBoxNodesFromConfig(source Source, nodes []model.SingBoxNode) { + if len(nodes) == 0 { + return + } + configs := loadSingBoxRuntimeConfigFiles(source) if len(configs) == 0 { - return model.SingBoxInventory{}, fmt.Errorf("no readable sing-box runtime config files found") + return } routeMap := singBoxRouteMap(configs) outboundMap := singBoxOutboundMap(configs) - inv := model.SingBoxInventory{NodeID: nodeID, At: at.UTC(), Status: "ok", Nodes: []model.SingBoxNode{}} - for _, parsed := range configs { - inv.Nodes = append(inv.Nodes, parseSingBoxRuntimeConfig(parsed.path, parsed.cfg, routeMap, outboundMap, strings.TrimSpace(source.Addr))...) - } - if inv.Nodes == nil { - inv.Nodes = []model.SingBoxNode{} + latticeByInbound := singBoxLatticeByInbound(configs) + for i := range nodes { + tag := strings.TrimSpace(nodes[i].Name) + if tag == "" { + continue + } + if nodes[i].OutboundRef == "" { + if ref, ok := routeMap[tag]; ok { + nodes[i].OutboundRef = ref + } + } + if nodes[i].OutboundRef != "" { + // outboundMap already zeroes Server/ServerPort for terminal/logical + // outbounds (direct/block/dns/selector/urltest), so those inbounds + // keep an empty OutboundServer/OutboundPort. + if ob, ok := outboundMap[nodes[i].OutboundRef]; ok { + if nodes[i].OutboundServer == "" { + nodes[i].OutboundServer = ob.Server + } + if nodes[i].OutboundPort == "" && ob.ServerPort > 0 { + nodes[i].OutboundPort = strconv.Itoa(ob.ServerPort) + } + if nodes[i].OutboundType == "" { + nodes[i].OutboundType = ob.Type + } + } + } + if ident, ok := latticeByInbound[tag]; ok { + if nodes[i].LineID == "" { + nodes[i].LineID = ident.LineID + } + if nodes[i].NodeIdentityUUID == "" { + nodes[i].NodeIdentityUUID = ident.NodeUUID + } + } } - return inv, nil } func singBoxRuntimeConfigFiles() []string { diff --git a/internal/singboxdiscover/discover_test.go b/internal/singboxdiscover/discover_test.go index a434485..cee90e9 100644 --- a/internal/singboxdiscover/discover_test.go +++ b/internal/singboxdiscover/discover_test.go @@ -205,6 +205,86 @@ func TestDiscoverRuntimeConfigResolvesOutboundDestination(t *testing.T) { } } +func TestPrimaryPathEnrichesFromConfig(t *testing.T) { + // `sb --json list` output carries only per-inbound fields. Trojan-41003 also + // arrives with an sb-provided line_id, which enrichment must not clobber. + listJSON := `{"ok":true,"count":3,"nodes":[ + {"name":"Trojan-41001.json","protocol":"trojan","port":"41001"}, + {"name":"VLESS-41002.json","protocol":"vless","port":"41002"}, + {"name":"Trojan-41003.json","protocol":"trojan","port":"41003","line_id":"sb-provided-line"} + ]}` + files := map[string]string{ + "/etc/sing-box/config.json": `{ + "inbounds":[ + {"tag":"Trojan-41001.json","type":"trojan","listen":"::","listen_port":41001,"_lattice":{"line_id":"line-uuid-a","node_uuid":"node-uuid-a"}}, + {"tag":"VLESS-41002.json","type":"vless","listen":"::","listen_port":41002,"_lattice":{"line_id":"line-uuid-b","node_uuid":"node-uuid-b"}}, + {"tag":"Trojan-41003.json","type":"trojan","listen":"::","listen_port":41003,"_lattice":{"line_id":"config-line-c"}} + ], + "outbounds":[ + {"tag":"exit-hk","type":"trojan","server":"198.51.100.9","server_port":8443}, + {"tag":"direct","type":"direct"} + ], + "route":{"rules":[ + {"inbound":["Trojan-41001.json"],"action":"route","outbound":"exit-hk"}, + {"inbound":["VLESS-41002.json"],"action":"route","outbound":"direct"}, + {"inbound":["Trojan-41003.json"],"action":"route","outbound":"exit-hk"} + ]} + }`, + } + src := Source{ + Addr: "203.0.113.42", + runner: func(_ context.Context, _ string, args ...string) ([]byte, error) { + switch args[len(args)-1] { + case "list": + return []byte(listJSON), nil + case "provision": + return []byte(`{"ok":true,"version":"1.12.0"}`), nil + } + return nil, errors.New("unexpected command") + }, + runtimeFiles: func() []string { return []string{"/etc/sing-box/config.json"} }, + readFile: func(path string) ([]byte, error) { return []byte(files[path]), nil }, + } + inv, err := Discover(context.Background(), src, "node-primary") + if err != nil { + t.Fatalf("Discover: %v", err) + } + if inv.Status != "ok" || len(inv.Nodes) != 3 { + t.Fatalf("unexpected inventory: status=%q nodes=%+v", inv.Status, inv.Nodes) + } + byName := map[string]model.SingBoxNode{} + for _, n := range inv.Nodes { + byName[n.Name] = n + } + // Relayed inbound: outbound tag + downstream server:port + line/identity all + // recovered from the config that sb omitted. + relay := byName["Trojan-41001.json"] + if relay.OutboundRef != "exit-hk" || relay.OutboundServer != "198.51.100.9" || + relay.OutboundPort != "8443" || relay.OutboundType != "trojan" { + t.Fatalf("relay outbound enrichment wrong: %+v", relay) + } + if relay.LineID != "line-uuid-a" || relay.NodeIdentityUUID != "node-uuid-a" { + t.Fatalf("relay lattice identity enrichment wrong: %+v", relay) + } + // Direct-routed inbound: outbound tag recorded but no downstream server/port. + direct := byName["VLESS-41002.json"] + if direct.OutboundRef != "direct" || direct.OutboundServer != "" || direct.OutboundPort != "" { + t.Fatalf("direct-routed inbound must leave OutboundServer/Port empty: %+v", direct) + } + if direct.LineID != "line-uuid-b" { + t.Fatalf("direct inbound line enrichment wrong: %+v", direct) + } + // sb-provided value must survive: config line_id ("config-line-c") must NOT + // overwrite the sb-provided one. + kept := byName["Trojan-41003.json"] + if kept.LineID != "sb-provided-line" { + t.Fatalf("sb-provided LineID must not be overwritten, got %q", kept.LineID) + } + if kept.OutboundRef != "exit-hk" || kept.OutboundServer != "198.51.100.9" { + t.Fatalf("outbound enrichment should still apply to sb node: %+v", kept) + } +} + func contains(ss []string, want string) bool { for _, s := range ss { if s == want {