Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
122 changes: 109 additions & 13 deletions internal/singboxdiscover/discover.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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 {
Expand Down
80 changes: 80 additions & 0 deletions internal/singboxdiscover/discover_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down