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
60 changes: 54 additions & 6 deletions internal/singboxdiscover/discover.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{}
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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"`
}
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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"),
Expand All @@ -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
}
Expand Down
60 changes: 60 additions & 0 deletions internal/singboxdiscover/discover_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import (
"errors"
"strings"
"testing"

"github.com/LatticeNet/lattice-sdk/model"
)

func TestDiscoverParsesListAndVersion(t *testing.T) {
Expand Down Expand Up @@ -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 {
Expand Down
Loading