From d8c3b5d38303c54c36c0be11f1586d3b699f664e Mon Sep 17 00:00:00 2001 From: lr00rl Date: Tue, 7 Jul 2026 04:31:46 -0400 Subject: [PATCH] feat(singboxdiscover): resolve outbound tag to downstream server:port Parse the sing-box `outbounds` section, index it by tag, and populate OutboundServer/OutboundPort/OutboundType on each discovered inbound from its routed outbound (config-fallback path). Direct/terminal outbounds stay empty. Adds a focused unit test for vless-relay vs direct resolution. Claude-Session: https://claude.ai/code/session_01K3G7UAfcWNQkATYevDvnVM --- internal/singboxdiscover/discover.go | 60 ++++++++++++++++++++--- internal/singboxdiscover/discover_test.go | 60 +++++++++++++++++++++++ 2 files changed, 114 insertions(+), 6 deletions(-) diff --git a/internal/singboxdiscover/discover.go b/internal/singboxdiscover/discover.go index d3a33d1..ca33284 100644 --- a/internal/singboxdiscover/discover.go +++ b/internal/singboxdiscover/discover.go @@ -154,9 +154,10 @@ func discoverRuntimeConfig(source Source, nodeID string, at time.Time) (model.Si 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, strings.TrimSpace(source.Addr))...) + inv.Nodes = append(inv.Nodes, parseSingBoxRuntimeConfig(parsed.path, parsed.cfg, routeMap, outboundMap, strings.TrimSpace(source.Addr))...) } if inv.Nodes == nil { inv.Nodes = []model.SingBoxNode{} @@ -263,8 +264,9 @@ func containsArg(args []string, want string) bool { } type singBoxRuntimeConfig struct { - Inbounds []singBoxRuntimeInbound `json:"inbounds"` - Route *singBoxRuntimeRoute `json:"route"` + Inbounds []singBoxRuntimeInbound `json:"inbounds"` + Outbounds []singBoxRuntimeOutbound `json:"outbounds"` + Route *singBoxRuntimeRoute `json:"route"` } type singBoxRuntimeConfigFile struct { @@ -293,6 +295,13 @@ type singBoxRuntimeRouteRule struct { Action string `json:"action"` } +type singBoxRuntimeOutbound struct { + Tag string `json:"tag"` + Type string `json:"type"` + Server string `json:"server"` + ServerPort int `json:"server_port"` +} + type singBoxRuntimeNetwork struct { Type string `json:"type"` } @@ -334,7 +343,35 @@ func singBoxRouteMap(configs []singBoxRuntimeConfigFile) map[string]string { return routes } -func parseSingBoxRuntimeConfig(path string, cfg singBoxRuntimeConfig, routeMap map[string]string, addr string) []model.SingBoxNode { +// singBoxOutboundMap indexes every declared outbound by its tag across all config +// files so an inbound's outbound tag can be resolved to its downstream +// destination (server:port). Terminal/logical outbounds (direct/block/dns) and +// group outbounds (selector/urltest) carry no dest of their own — they still get +// recorded so the outbound type is known, but their Server/ServerPort stay empty. +func singBoxOutboundMap(configs []singBoxRuntimeConfigFile) map[string]singBoxRuntimeOutbound { + outbounds := map[string]singBoxRuntimeOutbound{} + for _, parsed := range configs { + for _, ob := range parsed.cfg.Outbounds { + tag := strings.TrimSpace(ob.Tag) + if tag == "" { + continue + } + ob.Tag = tag + ob.Type = strings.TrimSpace(ob.Type) + switch ob.Type { + case "direct", "block", "dns", "selector", "urltest": + ob.Server = "" + ob.ServerPort = 0 + default: + ob.Server = strings.TrimSpace(ob.Server) + } + outbounds[tag] = ob + } + } + return outbounds +} + +func parseSingBoxRuntimeConfig(path string, cfg singBoxRuntimeConfig, routeMap map[string]string, outboundMap map[string]singBoxRuntimeOutbound, addr string) []model.SingBoxNode { nodes := make([]model.SingBoxNode, 0, len(cfg.Inbounds)) for _, in := range cfg.Inbounds { if strings.TrimSpace(in.Type) == "" && strings.TrimSpace(in.Tag) == "" && in.ListenPort == 0 { @@ -365,7 +402,7 @@ func parseSingBoxRuntimeConfig(path string, cfg singBoxRuntimeConfig, routeMap m if in.ListenPort > 0 { port = strconv.Itoa(in.ListenPort) } - nodes = append(nodes, model.SingBoxNode{ + node := model.SingBoxNode{ Name: name, LineID: singBoxLatticeString(in.Lattice, "line_id"), NodeIdentityUUID: singBoxLatticeString(in.Lattice, "node_uuid"), @@ -379,7 +416,18 @@ func parseSingBoxRuntimeConfig(path string, cfg singBoxRuntimeConfig, routeMap m UserCount: len(in.Users), UserKnown: in.Users != nil, Metadata: singBoxRuntimeMetadata(in.Lattice), - }) + } + // Resolve the outbound tag to its downstream destination so the server can + // draw cross-node relay (jump) edges. Terminal outbounds (e.g. "direct") + // carry no server/port and leave those fields empty. + if ob, ok := outboundMap[node.OutboundRef]; ok { + node.OutboundServer = ob.Server + if ob.ServerPort > 0 { + node.OutboundPort = strconv.Itoa(ob.ServerPort) + } + node.OutboundType = ob.Type + } + nodes = append(nodes, node) } return nodes } diff --git a/internal/singboxdiscover/discover_test.go b/internal/singboxdiscover/discover_test.go index 6037af8..a434485 100644 --- a/internal/singboxdiscover/discover_test.go +++ b/internal/singboxdiscover/discover_test.go @@ -5,6 +5,8 @@ import ( "errors" "strings" "testing" + + "github.com/LatticeNet/lattice-sdk/model" ) func TestDiscoverParsesListAndVersion(t *testing.T) { @@ -145,6 +147,64 @@ func TestDiscoverProvisionFailureStillReturnsNodes(t *testing.T) { } } +func TestDiscoverRuntimeConfigResolvesOutboundDestination(t *testing.T) { + files := map[string]string{ + "/etc/sing-box/config.json": `{ + "inbounds":[ + {"tag":"in-relay","type":"vless","listen":"::","listen_port":20001,"users":[{"uuid":"u1"}]}, + {"tag":"in-direct","type":"vless","listen":"::","listen_port":20002,"users":[{"uuid":"u2"}]} + ], + "outbounds":[ + {"tag":"exit-b","type":"vless","server":"198.51.100.9","server_port":443}, + {"tag":"direct","type":"direct"} + ], + "route":{"rules":[ + {"inbound":["in-relay"],"action":"route","outbound":"exit-b"}, + {"inbound":["in-direct"],"action":"route","outbound":"direct"} + ]} + }`, + } + src := Source{ + Addr: "203.0.113.1", + runner: func(_ context.Context, _ string, args ...string) ([]byte, error) { + if args[len(args)-1] == "list" { + return nil, errors.New("sing-box: unknown flag --json") + } + return []byte(`{}`), nil + }, + 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-relay") + if err != nil { + t.Fatalf("Discover fallback: %v", err) + } + if inv.Status != "ok" || len(inv.Nodes) != 2 { + t.Fatalf("want 2 nodes ok, got status=%q nodes=%+v", inv.Status, inv.Nodes) + } + var relay, direct *model.SingBoxNode + for i := range inv.Nodes { + switch inv.Nodes[i].Name { + case "in-relay": + relay = &inv.Nodes[i] + case "in-direct": + direct = &inv.Nodes[i] + } + } + if relay == nil || direct == nil { + t.Fatalf("expected both inbounds present: %+v", inv.Nodes) + } + // The relayed inbound must resolve its outbound tag to the downstream exit. + if relay.OutboundRef != "exit-b" || relay.OutboundServer != "198.51.100.9" || + relay.OutboundPort != "443" || relay.OutboundType != "vless" { + t.Fatalf("relay outbound resolution wrong: %+v", relay) + } + // The direct inbound carries no downstream server/port. + if direct.OutboundRef != "direct" || direct.OutboundServer != "" || direct.OutboundPort != "" { + t.Fatalf("direct outbound must leave server/port empty: %+v", direct) + } +} + func contains(ss []string, want string) bool { for _, s := range ss { if s == want {