From c64b257bd2e9e70ff4cca6674c8d59a21b466a68 Mon Sep 17 00:00:00 2001 From: lr00rl Date: Sat, 18 Jul 2026 07:31:21 -0700 Subject: [PATCH 1/4] Add design-15 sing-box sidecar annotation and bounded inspect enrichment Discovery now annotates each reported line with the control-plane line_uuid and declared downstream_line_uuid from the v2 sidecar (/etc/sing-box/lattice-metadata.json, LATTICE_SINGBOX_META override), joined by inbound tag; missing/corrupt/legacy-v1 sidecars degrade quietly and never fail discovery. The primary list path is also enriched per line via bounded `sb --json inspect ` calls (cap 64, per-call timeout, first-call-failure detection for older sb builds), filling outbound tag/type, line_id, node_uuid, and the user roster the plain list omits. Fill-only-empty in both enrichers; existing config join keeps resolving outbound server/port. Pins lattice-sdk @60c69bd (draft PR lattice-sdk#7). --- README.md | 4 +- cmd/lattice-agent/main.go | 7 +- go.mod | 2 +- go.sum | 2 + internal/singboxdiscover/discover.go | 212 ++++++++++++- internal/singboxdiscover/discover_test.go | 295 ++++++++++++++++++ .../testdata/v1-legacy-upgrade.json | 8 + .../testdata/v2-valid-full.json | 35 +++ .../testdata/v2-valid-minimal.json | 12 + 9 files changed, 570 insertions(+), 7 deletions(-) create mode 100644 internal/singboxdiscover/testdata/v1-legacy-upgrade.json create mode 100644 internal/singboxdiscover/testdata/v2-valid-full.json create mode 100644 internal/singboxdiscover/testdata/v2-valid-minimal.json diff --git a/README.md b/README.md index bae2851..850eb6f 100644 --- a/README.md +++ b/README.md @@ -300,7 +300,9 @@ missing checksum manifest aborts the install before the binary is written. - `LATTICE_TERMINAL_TRANSPORT=poll|stream` selects the terminal transport. - `LATTICE_SSH_ALERTS=1` reports accepted sshd logins. - `LATTICE_SINGBOX_DISCOVER=1` and `LATTICE_SINGBOX_BIN=sb` enable sing-box - discovery. + discovery. `LATTICE_SINGBOX_META` overrides the design-15 sidecar path + (default `/etc/sing-box/lattice-metadata.json`), which annotates each line + with its control-plane `line_uuid` and declared downstream chain edge. - `LATTICE_PROXY_USAGE_FILE`, `LATTICE_PROXY_USAGE_URL`, and `LATTICE_PROXY_USAGE_XRAY_API` configure proxy usage reporting sources. diff --git a/cmd/lattice-agent/main.go b/cmd/lattice-agent/main.go index 6972541..7dab319 100644 --- a/cmd/lattice-agent/main.go +++ b/cmd/lattice-agent/main.go @@ -138,6 +138,7 @@ type agentConfig struct { ProxyUsageXrayPattern string SingBoxDiscover bool SingBoxBin string + SingBoxMeta string LogStateDir string } @@ -225,6 +226,7 @@ func main() { flag.StringVar(&cfg.ProxyUsageXrayPattern, "proxy-usage-xray-pattern", os.Getenv("LATTICE_PROXY_USAGE_XRAY_PATTERN"), "optional stat-name filter for -proxy-usage-xray-api (default \"user>>>\")") flag.BoolVar(&cfg.SingBoxDiscover, "singbox-discover", os.Getenv("LATTICE_SINGBOX_DISCOVER") == "1", "report on-box sing-box nodes each interval by running read-only `sb --json list` (adoption bridge; read-only, no node mutation)") flag.StringVar(&cfg.SingBoxBin, "singbox-bin", env("LATTICE_SINGBOX_BIN", "sb"), "sb management binary for -singbox-discover (default \"sb\" resolved on PATH)") + flag.StringVar(&cfg.SingBoxMeta, "singbox-meta", env("LATTICE_SINGBOX_META", ""), "design-15 sing-box sidecar metadata path for -singbox-discover (default /etc/sing-box/lattice-metadata.json)") flag.StringVar(&cfg.LogStateDir, "log-state-dir", os.Getenv("LATTICE_LOG_STATE_DIR"), "directory for log-tail checkpoints (empty disables checkpoint persistence; sources still tail from end)") flag.BoolVar(&printVersion, "version", false, "print lattice-agent version and exit") flag.BoolVar(&printCompat, "compat-json", false, "print embedded server/dashboard compatibility metadata and exit") @@ -887,8 +889,9 @@ func reportSingBoxInventory(cfg agentConfig) error { return nil } inv, derr := singboxdiscover.Discover(context.Background(), singboxdiscover.Source{ - Binary: cfg.SingBoxBin, - Addr: cfg.PublicIP, + Binary: cfg.SingBoxBin, + Addr: cfg.PublicIP, + MetaPath: cfg.SingBoxMeta, }, cfg.NodeID) // Always post what we have (ok list OR error status); the post error, if any, // is combined with any discovery error for the caller's log. diff --git a/go.mod b/go.mod index 5f6d8a9..84147f8 100644 --- a/go.mod +++ b/go.mod @@ -2,7 +2,7 @@ module github.com/LatticeNet/lattice-node-agent go 1.26 -require github.com/LatticeNet/lattice-sdk v0.2.17 +require github.com/LatticeNet/lattice-sdk v0.2.18-0.20260717071920-60c69bdf91c7 require github.com/creack/pty v1.1.24 diff --git a/go.sum b/go.sum index 8800aa3..32b3fc4 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,7 @@ github.com/LatticeNet/lattice-sdk v0.2.17 h1:BlzswmKrRxGLZtACwEPd+KyE4GT8HqZQHAAeNRLj6AM= github.com/LatticeNet/lattice-sdk v0.2.17/go.mod h1:7ENUQ4EoS/TSW/eNomCGfZGliUPJZ46uAvp7dVcEXoE= +github.com/LatticeNet/lattice-sdk v0.2.18-0.20260717071920-60c69bdf91c7 h1:Bcg/bFktpmFZt5f88MnMF/W/Lr2klIJGnrSPAkas978= +github.com/LatticeNet/lattice-sdk v0.2.18-0.20260717071920-60c69bdf91c7/go.mod h1:7ENUQ4EoS/TSW/eNomCGfZGliUPJZ46uAvp7dVcEXoE= github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= diff --git a/internal/singboxdiscover/discover.go b/internal/singboxdiscover/discover.go index 0e937be..eabe37e 100644 --- a/internal/singboxdiscover/discover.go +++ b/internal/singboxdiscover/discover.go @@ -12,6 +12,7 @@ import ( "context" "encoding/json" "fmt" + "log" "os" "os/exec" "path/filepath" @@ -27,6 +28,12 @@ const ( defaultBinary = "sb" defaultTimeout = 8 * time.Second maxOutputBytes = 1 << 20 // 1 MiB + // defaultMetaPath is the design-15 sidecar written by the server/sb next to + // (never inside) the sing-box -C directory; sing-box itself never reads it. + defaultMetaPath = "/etc/sing-box/lattice-metadata.json" + // maxInspectCalls bounds the per-line `sb --json inspect ` enrichment + // so a large fleet cannot stretch the discovery cycle. + maxInspectCalls = 64 ) // Source configures on-box sing-box discovery. @@ -39,6 +46,14 @@ type Source struct { Addr string // Timeout bounds each sb invocation; default 8s. Timeout time.Duration + // MetaPath is the design-15 sidecar path; default + // /etc/sing-box/lattice-metadata.json (LATTICE_SINGBOX_META in the agent). + MetaPath string + // MaxInspect bounds per-line `sb --json inspect` enrichment calls; default 64. + MaxInspect int + // Logf receives best-effort degradation notes (unavailable inspect, corrupt + // sidecar); default log.Printf. Discovery never fails on these. + Logf func(format string, args ...any) // Now is a test seam. Now func() time.Time // runner is a test seam; production uses runBoundedCommand. @@ -110,10 +125,16 @@ func Discover(ctx context.Context, source Source, nodeID string) (model.SingBoxI 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. + // `_lattice`. Best-effort enrich, first via per-line `sb --json inspect + // ` (bounded; sb builds predating the subcommand degrade silently), + // then from the on-box config (matched by inbound tag), which also resolves + // the outbound server/port that inspect does not carry. Neither overwrites a + // value sb already provided; both skip quietly when their source is missing. + enrichSingBoxNodesFromInspect(ctx, source, run, binary, base, timeout, inv.Nodes) enrichSingBoxNodesFromConfig(source, inv.Nodes) + // design-15 sidecar annotations (line_uuid + declared chain edges), joined by + // inbound tag. Read-only: a missing/corrupt file never fails discovery. + applySingBoxSidecar(source, inv.Nodes) // Best-effort core version/health; a failure here must not fail discovery. provCtx, cancel2 := context.WithTimeout(ctx, timeout) @@ -144,6 +165,9 @@ func discoverRuntimeConfig(source Source, nodeID string, at time.Time) (model.Si if inv.Nodes == nil { inv.Nodes = []model.SingBoxNode{} } + // The sidecar joins by inbound tag, so the config-fallback path annotates + // exactly like the primary path. + applySingBoxSidecar(source, inv.Nodes) return inv, nil } @@ -261,6 +285,188 @@ func enrichSingBoxNodesFromConfig(source Source, nodes []model.SingBoxNode) { } } +// sbInspectLine mirrors the `sb --json inspect ` line object (core.sh +// line_json_obj): outbound tag/protocol, user roster, and the _lattice identity +// that the plain list omits. The outbound server/port is NOT part of this +// shape — the config join resolves those from the outbound tag. +type sbInspectLine struct { + Tag string `json:"tag"` + ListenHost string `json:"listen_host"` + ListenPort int `json:"listen_port"` + Users []json.RawMessage `json:"users"` + Outbound struct { + Tag string `json:"tag"` + Protocol string `json:"protocol"` + } `json:"outbound"` + Metadata struct { + LineID string `json:"line_id"` + NodeUUID string `json:"node_uuid"` + } `json:"metadata"` +} + +// enrichSingBoxNodesFromInspect fills the per-line fields `sb --json list` +// omits (outbound tag/type, _lattice identity, user roster) by calling +// `sb --json inspect ` once per line. Bounded in call count +// (Source.MaxInspect, default maxInspectCalls) and in per-call time (the source +// timeout), so it cannot stretch the discovery cycle. If the FIRST inspect call +// fails or returns non-JSON, the deployed sb predates the subcommand and the +// remaining lines are left to the config join instead. Fill-only-empty: a value +// sb already provided is never overwritten. +func enrichSingBoxNodesFromInspect(ctx context.Context, source Source, run func(context.Context, string, ...string) ([]byte, error), binary string, base []string, timeout time.Duration, nodes []model.SingBoxNode) { + maxInspect := source.MaxInspect + if maxInspect <= 0 { + maxInspect = maxInspectCalls + } + calls := 0 + for i := range nodes { + if calls >= maxInspect { + break + } + name := strings.TrimSpace(nodes[i].Name) + if name == "" { + continue + } + // A newer sb already emits these fields in the list; don't spend an + // inspect call re-reading them. + if nodes[i].OutboundRef != "" && nodes[i].LineID != "" && nodes[i].UserKnown { + continue + } + calls++ + inspectCtx, cancel := context.WithTimeout(ctx, timeout) + out, err := run(inspectCtx, binary, append(append([]string(nil), base...), "inspect", name)...) + cancel() + if err != nil { + if calls == 1 { + logf(source, "sing-box inspect unavailable (%v); continuing without per-line inspect enrichment", boundedErr(err)) + return + } + continue + } + var resp struct { + Line sbInspectLine `json:"line"` + } + if err := json.Unmarshal(bytes.TrimSpace(out), &resp); err != nil { + if calls == 1 { + logf(source, "sing-box inspect output undecodable (%v); continuing without per-line inspect enrichment", boundedErr(err)) + return + } + continue + } + line := resp.Line + if nodes[i].ListenHost == "" { + nodes[i].ListenHost = strings.TrimSpace(line.ListenHost) + } + if nodes[i].Port == "" && line.ListenPort > 0 { + nodes[i].Port = strconv.Itoa(line.ListenPort) + } + if nodes[i].OutboundRef == "" { + nodes[i].OutboundRef = strings.TrimSpace(line.Outbound.Tag) + } + if nodes[i].OutboundType == "" { + nodes[i].OutboundType = strings.TrimSpace(line.Outbound.Protocol) + } + if nodes[i].LineID == "" { + nodes[i].LineID = strings.TrimSpace(line.Metadata.LineID) + } + if nodes[i].NodeIdentityUUID == "" { + nodes[i].NodeIdentityUUID = strings.TrimSpace(line.Metadata.NodeUUID) + } + if !nodes[i].UserKnown && line.Users != nil { + nodes[i].UserCount = len(line.Users) + nodes[i].UserKnown = true + } + } +} + +// singBoxSidecar mirrors the design-15 sidecar (lattice.singbox-metadata.v2). +// Only the join fields are decoded; unknown keys are the writer's business. +// v1 sidecars (flat object, no schema marker / inbounds array) carry no +// per-line data: they are accepted and ignored, exactly like a missing file. +type singBoxSidecar struct { + Schema string `json:"schema"` + Inbounds []struct { + Tag string `json:"tag"` + LineUUID string `json:"line_uuid"` + Chain *struct { + DownstreamLineUUID *string `json:"downstream_line_uuid"` + } `json:"chain"` + } `json:"inbounds"` +} + +// applySingBoxSidecar annotates discovered nodes with the design-15 line +// identity (line_uuid) and the declared chain edge (downstream_line_uuid, +// null in the file means single-exit and stays empty), joined by inbound tag +// (node.Name == sidecar inbounds[].tag). Degrades quietly: a missing file or a +// legacy v1 sidecar leaves every field empty; a corrupt file is logged and +// skipped. The sidecar is a read-only annotation and must never fail discovery. +func applySingBoxSidecar(source Source, nodes []model.SingBoxNode) { + if len(nodes) == 0 { + return + } + metaPath := strings.TrimSpace(source.MetaPath) + if metaPath == "" { + metaPath = defaultMetaPath + } + readFn := source.readFile + if readFn == nil { + readFn = os.ReadFile + } + raw, err := readFn(metaPath) + if err != nil { + return // no sidecar on this node: nothing to annotate + } + raw = bytes.TrimSpace(raw) + if len(raw) == 0 { + return + } + var meta singBoxSidecar + if err := json.Unmarshal(raw, &meta); err != nil { + logf(source, "sing-box sidecar %s unreadable (%v); reporting base inventory", metaPath, boundedErr(err)) + return + } + if meta.Schema != "lattice.singbox-metadata.v2" || len(meta.Inbounds) == 0 { + return // legacy v1 flat sidecar: no per-line annotations + } + type sidecarLine struct { + lineUUID string + downstreamLineUUID string + } + byTag := map[string]sidecarLine{} + for _, in := range meta.Inbounds { + tag := strings.TrimSpace(in.Tag) + if tag == "" { + continue + } + entry := sidecarLine{lineUUID: strings.TrimSpace(in.LineUUID)} + if in.Chain != nil && in.Chain.DownstreamLineUUID != nil { + entry.downstreamLineUUID = strings.TrimSpace(*in.Chain.DownstreamLineUUID) + } + byTag[tag] = entry + } + for i := range nodes { + entry, ok := byTag[strings.TrimSpace(nodes[i].Name)] + if !ok { + continue + } + if nodes[i].LineUUID == "" { + nodes[i].LineUUID = entry.lineUUID + } + if nodes[i].DownstreamLineUUID == "" { + nodes[i].DownstreamLineUUID = entry.downstreamLineUUID + } + } +} + +// logf routes a best-effort degradation note through the source's Logf seam +// (default log.Printf). Used only for non-fatal enrichment/annotation gaps. +func logf(source Source, format string, args ...any) { + if source.Logf != nil { + source.Logf(format, args...) + return + } + log.Printf(format, args...) +} + func singBoxRuntimeConfigFiles() []string { seen := map[string]bool{} var out []string diff --git a/internal/singboxdiscover/discover_test.go b/internal/singboxdiscover/discover_test.go index cee90e9..4f6dbba 100644 --- a/internal/singboxdiscover/discover_test.go +++ b/internal/singboxdiscover/discover_test.go @@ -3,6 +3,7 @@ package singboxdiscover import ( "context" "errors" + "fmt" "strings" "testing" @@ -293,3 +294,297 @@ func contains(ss []string, want string) bool { } return false } + +// --- design-15: per-line inspect enrichment + sidecar (lattice.singbox-metadata.v2) --- +// +// testdata/{v2-valid-full,v2-valid-minimal,v1-legacy-upgrade}.json are verbatim +// copies of lattice/docs/contracts/fixtures/ (design-15 S0); the schema there is +// the arbiter. + +// sidecarTestList is a list row the way a NEWER sb emits it (outbound_ref, +// line_id, user_known already set), so discovery skips the per-line inspect +// call and the test isolates the sidecar join. +const sidecarTestList = `{"ok":true,"count":1,"nodes":[ + {"name":"vless-31001","protocol":"vless","port":"31001","line_id":"l","outbound_ref":"direct","user_known":true} +]}` + +// listOnlyRunner answers list/provision and fails the test on anything else, +// which also proves no inspect call is spent on already-enriched rows. +func listOnlyRunner(t *testing.T, listJSON string) func(context.Context, string, ...string) ([]byte, error) { + t.Helper() + return func(_ context.Context, _ string, args ...string) ([]byte, error) { + switch args[len(args)-1] { + case "list": + return []byte(listJSON), nil + case "provision": + return []byte(`{}`), nil + } + t.Fatalf("unexpected command: %v", args) + return nil, nil + } +} + +func TestDiscoverAppliesSidecarV2(t *testing.T) { + listJSON := `{"ok":true,"count":3,"nodes":[ + {"name":"vless-31001","protocol":"vless","port":"31001","line_id":"l1","outbound_ref":"direct","user_known":true}, + {"name":"vless-8468","protocol":"vless","port":"8468","line_id":"l2","outbound_ref":"direct","user_known":true}, + {"name":"trojan-9999","protocol":"trojan","port":"9999","line_id":"l3","outbound_ref":"direct","user_known":true} + ]}` + src := Source{ + MetaPath: "testdata/v2-valid-full.json", + runner: listOnlyRunner(t, listJSON), + runtimeFiles: func() []string { return nil }, + } + inv, err := Discover(context.Background(), src, "node-hk") + if err != nil { + t.Fatalf("Discover: %v", err) + } + byName := map[string]model.SingBoxNode{} + for _, n := range inv.Nodes { + byName[n.Name] = n + } + // Tag hit with a declared chain edge: both identities join. + relay := byName["vless-31001"] + if relay.LineUUID != "9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d" || + relay.DownstreamLineUUID != "1eec4b5a-9c2f-4a1b-8d3e-5f6a7b8c9d0e" { + t.Fatalf("declared chain join wrong: %+v", relay) + } + // Tag hit with chain.downstream_line_uuid null: single-exit, stays empty. + single := byName["vless-8468"] + if single.LineUUID != "2af49c3e-1d5b-4e7a-8c9d-0e1f2a3b4c5d" || single.DownstreamLineUUID != "" { + t.Fatalf("null downstream_line_uuid must stay empty: %+v", single) + } + // Tag miss: the sidecar must not invent annotations. + if n := byName["trojan-9999"]; n.LineUUID != "" || n.DownstreamLineUUID != "" { + t.Fatalf("unlisted tag must stay unannotated: %+v", n) + } +} + +func TestDiscoverAppliesSidecarV2Minimal(t *testing.T) { + listJSON := `{"ok":true,"count":1,"nodes":[ + {"name":"trojan-41001","protocol":"trojan","port":"41001","line_id":"l","outbound_ref":"direct","user_known":true} + ]}` + src := Source{ + MetaPath: "testdata/v2-valid-minimal.json", + runner: listOnlyRunner(t, listJSON), + runtimeFiles: func() []string { return nil }, + } + inv, err := Discover(context.Background(), src, "node-aaitr") + if err != nil { + t.Fatalf("Discover: %v", err) + } + n := inv.Nodes[0] + if n.LineUUID != "7c3d8e2f-5a4b-4c6d-9e0f-1a2b3c4d5e6f" || n.DownstreamLineUUID != "" { + t.Fatalf("minimal sidecar (no chain block) join wrong: %+v", n) + } +} + +func TestDiscoverSidecarMissingIsSilent(t *testing.T) { + var logs []string + src := Source{ + MetaPath: "/nonexistent/lattice-metadata.json", + runner: listOnlyRunner(t, sidecarTestList), + runtimeFiles: func() []string { return nil }, + Logf: func(f string, a ...any) { logs = append(logs, fmt.Sprintf(f, a...)) }, + } + inv, err := Discover(context.Background(), src, "node-nometa") + if err != nil { + t.Fatalf("missing sidecar must not fail discovery: %v", err) + } + if inv.Nodes[0].LineUUID != "" || inv.Nodes[0].DownstreamLineUUID != "" { + t.Fatalf("missing sidecar must omit the fields: %+v", inv.Nodes[0]) + } + if len(logs) != 0 { + t.Fatalf("missing sidecar must stay silent, got %v", logs) + } +} + +func TestDiscoverSidecarCorruptLogsAndContinues(t *testing.T) { + var logs []string + src := Source{ + MetaPath: "/etc/sing-box/lattice-metadata.json", + runner: listOnlyRunner(t, sidecarTestList), + runtimeFiles: func() []string { return nil }, + readFile: func(path string) ([]byte, error) { + if path == "/etc/sing-box/lattice-metadata.json" { + return []byte(`{"schema":"lattice.singbox-metadata.v2","inbounds":[broken`), nil + } + return nil, errors.New("unexpected read: " + path) + }, + Logf: func(f string, a ...any) { logs = append(logs, fmt.Sprintf(f, a...)) }, + } + inv, err := Discover(context.Background(), src, "node-corrupt") + if err != nil { + t.Fatalf("corrupt sidecar must not fail discovery: %v", err) + } + if inv.Status != "ok" || inv.Nodes[0].LineUUID != "" { + t.Fatalf("corrupt sidecar must report the base inventory: %+v", inv) + } + if len(logs) != 1 || !strings.Contains(logs[0], "sidecar") { + t.Fatalf("corrupt sidecar must be logged once, got %v", logs) + } +} + +func TestDiscoverSidecarV1Ignored(t *testing.T) { + var logs []string + src := Source{ + MetaPath: "testdata/v1-legacy-upgrade.json", + runner: listOnlyRunner(t, sidecarTestList), + runtimeFiles: func() []string { return nil }, + Logf: func(f string, a ...any) { logs = append(logs, fmt.Sprintf(f, a...)) }, + } + inv, err := Discover(context.Background(), src, "node-v1") + if err != nil { + t.Fatalf("v1 sidecar must not fail discovery: %v", err) + } + // v1 is a flat per-node shape with no inbounds array: nothing to join. + if inv.Nodes[0].LineUUID != "" || inv.Nodes[0].DownstreamLineUUID != "" { + t.Fatalf("v1 sidecar carries no per-line data: %+v", inv.Nodes[0]) + } + if len(logs) != 0 { + t.Fatalf("v1 sidecar is an accepted legacy shape, not an error: %v", logs) + } +} + +func TestPrimaryPathEnrichesFromInspect(t *testing.T) { + var inspectNames []string + files := map[string]string{ + // inspect reports only the outbound tag/protocol; the config join below + // resolves the tag to the downstream server:port. + "/etc/sing-box/config.json": `{ + "inbounds":[], + "outbounds":[{"tag":"[openjobs]-qqpw-vds1-vless","type":"vless","server":"198.51.100.9","server_port":443}] + }`, + } + src := Source{ + Addr: "203.0.113.5", + runner: func(_ context.Context, _ string, args ...string) ([]byte, error) { + last := args[len(args)-1] + switch last { + case "list": + return []byte(`{"ok":true,"count":1,"nodes":[{"name":"VLESS-31001.json","protocol":"vless","port":"31001"}]}`), nil + case "provision": + return []byte(`{}`), nil + } + if len(args) >= 2 && args[len(args)-2] == "inspect" { + inspectNames = append(inspectNames, last) + // Shape per core.sh line_json_obj. + return []byte(`{"ok":true,"line":{ + "core":"sing-box", + "tag":"VLESS-31001.json", + "type":"vless", + "listen_host":"::", + "listen_port":31001, + "users":[{"name":"u_0123456789abcdef","uuid":"redacted"},{"name":"u_fedcba9876543210","uuid":"redacted"}], + "outbound":{"tag":"[openjobs]-qqpw-vds1-vless","protocol":"vless"}, + "metadata":{"line_id":"line-uuid-a","node_uuid":"node-uuid-a"} + }}`), nil + } + return nil, errors.New("unexpected command: " + strings.Join(args, " ")) + }, + 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-inspect") + if err != nil { + t.Fatalf("Discover: %v", err) + } + if len(inv.Nodes) != 1 { + t.Fatalf("unexpected inventory: %+v", inv) + } + n := inv.Nodes[0] + if n.OutboundRef != "[openjobs]-qqpw-vds1-vless" || n.OutboundType != "vless" { + t.Fatalf("inspect outbound enrichment wrong: %+v", n) + } + if n.OutboundServer != "198.51.100.9" || n.OutboundPort != "443" { + t.Fatalf("config join must resolve the server/port inspect omits: %+v", n) + } + if n.LineID != "line-uuid-a" || n.NodeIdentityUUID != "node-uuid-a" { + t.Fatalf("inspect identity enrichment wrong: %+v", n) + } + if n.ListenHost != "::" || !n.UserKnown || n.UserCount != 2 { + t.Fatalf("inspect listen/user enrichment wrong: %+v", n) + } + if len(inspectNames) != 1 || inspectNames[0] != "VLESS-31001.json" { + t.Fatalf("inspect must be called once per bare line by name, got %v", inspectNames) + } +} + +func TestInspectUnavailableFallsBackToConfig(t *testing.T) { + var logs []string + inspectCalls := 0 + 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"}}], + "outbounds":[{"tag":"exit-hk","type":"trojan","server":"198.51.100.9","server_port":8443}], + "route":{"rules":[{"inbound":["Trojan-41001.json"],"action":"route","outbound":"exit-hk"}]} + }`, + } + src := Source{ + runner: func(_ context.Context, _ string, args ...string) ([]byte, error) { + switch args[len(args)-1] { + case "list": + return []byte(`{"ok":true,"count":1,"nodes":[{"name":"Trojan-41001.json","protocol":"trojan","port":"41001"}]}`), nil + case "provision": + return []byte(`{}`), nil + } + inspectCalls++ + return nil, errors.New("sb: unknown command inspect") // old sb build + }, + runtimeFiles: func() []string { return []string{"/etc/sing-box/config.json"} }, + readFile: func(path string) ([]byte, error) { return []byte(files[path]), nil }, + Logf: func(f string, a ...any) { logs = append(logs, fmt.Sprintf(f, a...)) }, + } + inv, err := Discover(context.Background(), src, "node-oldsb") + if err != nil { + t.Fatalf("old sb without inspect must not fail discovery: %v", err) + } + n := inv.Nodes[0] + if n.OutboundRef != "exit-hk" || n.OutboundServer != "198.51.100.9" || n.LineID != "line-uuid-a" { + t.Fatalf("config join must still enrich when inspect is unavailable: %+v", n) + } + if inspectCalls != 1 { + t.Fatalf("first inspect failure must stop further inspect calls, got %d", inspectCalls) + } + if len(logs) != 1 || !strings.Contains(logs[0], "inspect unavailable") { + t.Fatalf("inspect degradation must be logged once, got %v", logs) + } +} + +func TestInspectBudgetTruncates(t *testing.T) { + names := []string{"n1.json", "n2.json", "n3.json", "n4.json", "n5.json"} + var rows []string + for _, name := range names { + rows = append(rows, fmt.Sprintf(`{"name":%q,"protocol":"vless","port":"31001"}`, name)) + } + listJSON := `{"ok":true,"count":5,"nodes":[` + strings.Join(rows, ",") + `]}` + inspectCalls := 0 + src := Source{ + MaxInspect: 3, + runner: func(_ context.Context, _ string, args ...string) ([]byte, error) { + last := args[len(args)-1] + switch last { + case "list": + return []byte(listJSON), nil + case "provision": + return []byte(`{}`), nil + } + inspectCalls++ + return []byte(`{"ok":true,"line":{"tag":"` + last + `","outbound":{"tag":"direct","protocol":"direct"}}}`), nil + }, + runtimeFiles: func() []string { return nil }, + } + inv, err := Discover(context.Background(), src, "node-fleet") + if err != nil { + t.Fatalf("Discover: %v", err) + } + if inspectCalls != 3 { + t.Fatalf("inspect calls must be bounded by MaxInspect=3, got %d", inspectCalls) + } + for i, n := range inv.Nodes { + enriched := n.OutboundRef == "direct" + if (i < 3) != enriched { + t.Fatalf("only the first 3 lines may be inspect-enriched: node %d %+v", i, n) + } + } +} diff --git a/internal/singboxdiscover/testdata/v1-legacy-upgrade.json b/internal/singboxdiscover/testdata/v1-legacy-upgrade.json new file mode 100644 index 0000000..060cc99 --- /dev/null +++ b/internal/singboxdiscover/testdata/v1-legacy-upgrade.json @@ -0,0 +1,8 @@ +{ + "managed": true, + "plugin": "latticenet.vpn-core", + "line_hash_id": "line_0123456789abcdef01234567", + "node_id": "hk-hub-01", + "comment": "legacy v1 sidecar, upgrades on read", + "updated_at": "2026-07-07T09:30:00Z" +} diff --git a/internal/singboxdiscover/testdata/v2-valid-full.json b/internal/singboxdiscover/testdata/v2-valid-full.json new file mode 100644 index 0000000..502a521 --- /dev/null +++ b/internal/singboxdiscover/testdata/v2-valid-full.json @@ -0,0 +1,35 @@ +{ + "schema": "lattice.singbox-metadata.v2", + "node_id": "hk-hub-01", + "node_uuid": "3f8a2c1e-7b4d-4e5f-9a0b-1c2d3e4f5a6b", + "updated_at": "2026-07-17T04:00:00Z", + "writer": "lattice-server", + "inbounds": [ + { + "tag": "vless-31001", + "line_uuid": "9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d", + "line_hash_id": "line_0123456789abcdef01234567", + "comment": "hk hub fan-out to qqpw", + "chain": { + "downstream_line_uuid": "1eec4b5a-9c2f-4a1b-8d3e-5f6a7b8c9d0e", + "downstream_node": "qqpw" + } + }, + { + "tag": "vless-8468", + "line_uuid": "2af49c3e-1d5b-4e7a-8c9d-0e1f2a3b4c5d", + "line_hash_id": "line_abcdef0123456789abcdef01", + "chain": { + "downstream_line_uuid": null + } + } + ], + "reserved": { + "in_config_key": "_lattice", + "fields": { + "line_uuid": "string", + "node_uuid": "string", + "line_hash_id": "string" + } + } +} diff --git a/internal/singboxdiscover/testdata/v2-valid-minimal.json b/internal/singboxdiscover/testdata/v2-valid-minimal.json new file mode 100644 index 0000000..cb7adc6 --- /dev/null +++ b/internal/singboxdiscover/testdata/v2-valid-minimal.json @@ -0,0 +1,12 @@ +{ + "schema": "lattice.singbox-metadata.v2", + "node_id": "aaitr-01", + "updated_at": "2026-07-17T04:00:00Z", + "writer": "sb", + "inbounds": [ + { + "tag": "trojan-41001", + "line_uuid": "7c3d8e2f-5a4b-4c6d-9e0f-1a2b3c4d5e6f" + } + ] +} From f8ee7eefc1f56da3769c855e902246a21ab3615a Mon Sep 17 00:00:00 2001 From: lr00rl Date: Wed, 22 Jul 2026 04:59:15 -0700 Subject: [PATCH 2/4] Make agent CI exercise its declared SDK contract The workspace checkout forced SDK main over the pseudo-version pinned for sidecar line identity, producing compile failures unrelated to the branch. Letting Go resolve go.mod keeps the tested dependency identical to the shipped one. Constraint: The sidecar branch consumes an SDK commit not yet present on SDK main Rejected: Always checkout SDK main | masks the pinned dependency and breaks coordinated prerelease work Confidence: high Scope-risk: narrow Directive: Keep agent CI aligned with go.mod unless an explicit integration matrix is introduced Tested: GOWORK=off go mod verify; go vet ./...; go test -race -cover ./...; YAML parse; git diff --check --- .github/workflows/ci.yml | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 671d521..dfc3481 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,18 +13,11 @@ jobs: uses: actions/checkout@v6 with: path: lattice-node-agent - - name: checkout lattice-sdk - uses: actions/checkout@v6 - with: - repository: LatticeNet/lattice-sdk - path: lattice-sdk - uses: actions/setup-go@v6 with: go-version: '1.26.x' check-latest: true cache: false - - name: create workspace - run: go work init ./lattice-sdk ./lattice-node-agent - name: gofmt working-directory: lattice-node-agent run: test -z "$(gofmt -l .)" || (gofmt -l . && exit 1) @@ -41,9 +34,10 @@ jobs: working-directory: lattice-node-agent run: go test -race -cover ./... - name: gosec + working-directory: lattice-node-agent run: | go install github.com/securego/gosec/v2/cmd/gosec@latest - gosec ./lattice-node-agent/... + gosec ./... - name: govulncheck working-directory: lattice-node-agent run: | From f9fc44098d0f4d27f73b5b08c4bc91165a9fa059 Mon Sep 17 00:00:00 2001 From: lr00rl Date: Wed, 22 Jul 2026 05:04:17 -0700 Subject: [PATCH 3/4] Keep the agent security scan on its proven checkout-root path The module-root gosec invocation surfaced a different legacy finding set even though the SDK resolution change was the only intended behavior. Restore the previously green repository-prefixed scan target. Constraint: The sidecar PR must fix SDK selection without redefining the existing gosec baseline Confidence: high Scope-risk: narrow Tested: Workflow diff review; prior successful main CI gosec evidence --- .github/workflows/ci.yml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dfc3481..0f1a25e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -34,10 +34,9 @@ jobs: working-directory: lattice-node-agent run: go test -race -cover ./... - name: gosec - working-directory: lattice-node-agent run: | go install github.com/securego/gosec/v2/cmd/gosec@latest - gosec ./... + gosec ./lattice-node-agent/... - name: govulncheck working-directory: lattice-node-agent run: | From beb25f93bd105b1647f46aa05f619fb4297a2735 Mon Sep 17 00:00:00 2001 From: lr00rl Date: Wed, 22 Jul 2026 05:50:28 -0700 Subject: [PATCH 4/4] Keep sidecar discovery bounded and identity-safe Treat the Design 15 sidecar as an atomic identity document: distinguish absence from read failure, reject invalid v2 annotations as a unit, and cap aggregate inspect latency with bounded concurrency. Build CI and prerelease artifacts against the exact SDK pinned in go.mod. Constraint: Discovery must not delay the task loop beyond a short aggregate budget. Rejected: Serial per-line eight-second inspection | it can starve task polling for minutes. Confidence: high Scope-risk: moderate Reversibility: clean Directive: Keep invalid sidecars fail-closed and release builds on the declared SDK pin. Tested: go test ./...; go vet ./...; release workflow checker; targeted singboxdiscover race tests Not-tested: HK filesystem permission failure, covered by unit seams and deferred to canary --- .github/workflows/release.yml | 11 +- cmd/lattice-agent/main.go | 8 +- cmd/lattice-agent/main_test.go | 12 +- go.mod | 2 +- go.sum | 6 +- internal/singboxdiscover/discover.go | 184 +++++++++++++++++++--- internal/singboxdiscover/discover_test.go | 124 ++++++++++++++- scripts/check-release-workflow.sh | 5 + 8 files changed, 298 insertions(+), 54 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1d171f4..0eb5994 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -46,18 +46,11 @@ jobs: uses: actions/checkout@v6 with: path: lattice-node-agent - - name: checkout lattice-sdk - uses: actions/checkout@v6 - with: - repository: LatticeNet/lattice-sdk - path: lattice-sdk - uses: actions/setup-go@v6 with: go-version: "1.26.x" check-latest: true cache: false - - name: create workspace - run: go work init ./lattice-sdk ./lattice-node-agent - name: build binary id: build working-directory: lattice-node-agent @@ -65,8 +58,8 @@ jobs: GOOS: ${{ matrix.goos }} GOARCH: ${{ matrix.goarch }} ARTIFACT: ${{ matrix.artifact }} - LATTICE_COMPAT_SERVER_MIN: v0.2.1 - LATTICE_COMPAT_DASHBOARD_MIN: v0.2.1 + LATTICE_COMPAT_SERVER_MIN: v0.2.2-alpha.2 + LATTICE_COMPAT_DASHBOARD_MIN: v0.2.2-alpha.7 run: | VERSION="${GITHUB_REF_NAME#v}" LATTICE_COMPAT_CHANNEL=stable diff --git a/cmd/lattice-agent/main.go b/cmd/lattice-agent/main.go index 7dab319..2d92fe8 100644 --- a/cmd/lattice-agent/main.go +++ b/cmd/lattice-agent/main.go @@ -33,10 +33,10 @@ import ( "github.com/LatticeNet/lattice-sdk/model" ) -var version = "0.2.9" -var compatServerMin = "v0.2.1" -var compatDashboardMin = "v0.2.1" -var compatChannel = "stable" +var version = "0.3.3-alpha.1" +var compatServerMin = "v0.2.2-alpha.2" +var compatDashboardMin = "v0.2.2-alpha.7" +var compatChannel = "alpha" type agentCompatibility struct { ServerMin string `json:"server_min"` diff --git a/cmd/lattice-agent/main_test.go b/cmd/lattice-agent/main_test.go index 5edc0bb..02f54df 100644 --- a/cmd/lattice-agent/main_test.go +++ b/cmd/lattice-agent/main_test.go @@ -24,8 +24,8 @@ func (f roundTripFunc) RoundTrip(r *http.Request) (*http.Response, error) { } func TestVersionMatchesCurrentRelease(t *testing.T) { - if version != "0.2.9" { - t.Fatalf("version = %q, want 0.2.9", version) + if version != "0.3.3-alpha.1" { + t.Fatalf("version = %q, want 0.3.3-alpha.1", version) } } @@ -34,11 +34,11 @@ func TestCompatibilityPayloadIsEmbedded(t *testing.T) { if got.ServerMin == "" || got.DashboardMin == "" || got.Channel == "" { t.Fatalf("compatibility metadata must be embedded: %+v", got) } - if got.Channel != "stable" { - t.Fatalf("compatibility channel = %q, want stable", got.Channel) + if got.Channel != "alpha" { + t.Fatalf("compatibility channel = %q, want alpha", got.Channel) } - if got.ServerMin != "v0.2.1" || got.DashboardMin != "v0.2.1" { - t.Fatalf("compatibility floor = %+v, want coordinated v0.2.1", got) + if got.ServerMin != "v0.2.2-alpha.2" || got.DashboardMin != "v0.2.2-alpha.7" { + t.Fatalf("compatibility floor = %+v, want coordinated design-15 alpha", got) } } diff --git a/go.mod b/go.mod index 84147f8..3e423a9 100644 --- a/go.mod +++ b/go.mod @@ -2,7 +2,7 @@ module github.com/LatticeNet/lattice-node-agent go 1.26 -require github.com/LatticeNet/lattice-sdk v0.2.18-0.20260717071920-60c69bdf91c7 +require github.com/LatticeNet/lattice-sdk v0.2.18-0.20260722123932-4a318f246d23 require github.com/creack/pty v1.1.24 diff --git a/go.sum b/go.sum index 32b3fc4..251f578 100644 --- a/go.sum +++ b/go.sum @@ -1,7 +1,5 @@ -github.com/LatticeNet/lattice-sdk v0.2.17 h1:BlzswmKrRxGLZtACwEPd+KyE4GT8HqZQHAAeNRLj6AM= -github.com/LatticeNet/lattice-sdk v0.2.17/go.mod h1:7ENUQ4EoS/TSW/eNomCGfZGliUPJZ46uAvp7dVcEXoE= -github.com/LatticeNet/lattice-sdk v0.2.18-0.20260717071920-60c69bdf91c7 h1:Bcg/bFktpmFZt5f88MnMF/W/Lr2klIJGnrSPAkas978= -github.com/LatticeNet/lattice-sdk v0.2.18-0.20260717071920-60c69bdf91c7/go.mod h1:7ENUQ4EoS/TSW/eNomCGfZGliUPJZ46uAvp7dVcEXoE= +github.com/LatticeNet/lattice-sdk v0.2.18-0.20260722123932-4a318f246d23 h1:2qpbnG8jO9lOlVRpp4OCeami34HUyV7VGmp3vj4IRQI= +github.com/LatticeNet/lattice-sdk v0.2.18-0.20260722123932-4a318f246d23/go.mod h1:7ENUQ4EoS/TSW/eNomCGfZGliUPJZ46uAvp7dVcEXoE= github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= diff --git a/internal/singboxdiscover/discover.go b/internal/singboxdiscover/discover.go index eabe37e..309d9ac 100644 --- a/internal/singboxdiscover/discover.go +++ b/internal/singboxdiscover/discover.go @@ -11,6 +11,7 @@ import ( "bytes" "context" "encoding/json" + "errors" "fmt" "log" "os" @@ -19,6 +20,7 @@ import ( "sort" "strconv" "strings" + "sync" "time" "github.com/LatticeNet/lattice-sdk/model" @@ -33,7 +35,9 @@ const ( defaultMetaPath = "/etc/sing-box/lattice-metadata.json" // maxInspectCalls bounds the per-line `sb --json inspect ` enrichment // so a large fleet cannot stretch the discovery cycle. - maxInspectCalls = 64 + maxInspectCalls = 64 + maxInspectWorkers = 4 + defaultInspectTotalTimeout = 2 * time.Second ) // Source configures on-box sing-box discovery. @@ -307,8 +311,8 @@ type sbInspectLine struct { // enrichSingBoxNodesFromInspect fills the per-line fields `sb --json list` // omits (outbound tag/type, _lattice identity, user roster) by calling // `sb --json inspect ` once per line. Bounded in call count -// (Source.MaxInspect, default maxInspectCalls) and in per-call time (the source -// timeout), so it cannot stretch the discovery cycle. If the FIRST inspect call +// (Source.MaxInspect, default maxInspectCalls), concurrency, and one aggregate +// deadline, so it cannot stretch the discovery cycle. If the FIRST inspect call // fails or returns non-JSON, the deployed sb predates the subcommand and the // remaining lines are left to the config join instead. Fill-only-empty: a value // sb already provided is never overwritten. @@ -317,9 +321,13 @@ func enrichSingBoxNodesFromInspect(ctx context.Context, source Source, run func( if maxInspect <= 0 { maxInspect = maxInspectCalls } - calls := 0 + type candidate struct { + index int + name string + } + candidates := make([]candidate, 0, maxInspect) for i := range nodes { - if calls >= maxInspect { + if len(candidates) >= maxInspect { break } name := strings.TrimSpace(nodes[i].Name) @@ -331,28 +339,32 @@ func enrichSingBoxNodesFromInspect(ctx context.Context, source Source, run func( if nodes[i].OutboundRef != "" && nodes[i].LineID != "" && nodes[i].UserKnown { continue } - calls++ - inspectCtx, cancel := context.WithTimeout(ctx, timeout) - out, err := run(inspectCtx, binary, append(append([]string(nil), base...), "inspect", name)...) - cancel() + candidates = append(candidates, candidate{index: i, name: name}) + } + if len(candidates) == 0 { + return + } + totalTimeout := timeout + if totalTimeout <= 0 || totalTimeout > defaultInspectTotalTimeout { + totalTimeout = defaultInspectTotalTimeout + } + inspectCtx, cancel := context.WithTimeout(ctx, totalTimeout) + defer cancel() + + inspect := func(c candidate) (sbInspectLine, error) { + out, err := run(inspectCtx, binary, append(append([]string(nil), base...), "inspect", c.name)...) if err != nil { - if calls == 1 { - logf(source, "sing-box inspect unavailable (%v); continuing without per-line inspect enrichment", boundedErr(err)) - return - } - continue + return sbInspectLine{}, err } var resp struct { Line sbInspectLine `json:"line"` } if err := json.Unmarshal(bytes.TrimSpace(out), &resp); err != nil { - if calls == 1 { - logf(source, "sing-box inspect output undecodable (%v); continuing without per-line inspect enrichment", boundedErr(err)) - return - } - continue + return sbInspectLine{}, fmt.Errorf("decode inspect: %w", err) } - line := resp.Line + return resp.Line, nil + } + apply := func(i int, line sbInspectLine) { if nodes[i].ListenHost == "" { nodes[i].ListenHost = strings.TrimSpace(line.ListenHost) } @@ -376,6 +388,61 @@ func enrichSingBoxNodesFromInspect(ctx context.Context, source Source, run func( nodes[i].UserKnown = true } } + + // Probe once before launching workers. Old sb builds lack `inspect`; this + // keeps their one-call degradation behavior while allowing supported builds + // to enrich the remaining fleet concurrently under one discovery deadline. + first, err := inspect(candidates[0]) + if err != nil { + logf(source, "sing-box inspect unavailable (%v); continuing without per-line inspect enrichment", boundedErr(err)) + return + } + apply(candidates[0].index, first) + if len(candidates) == 1 { + return + } + + jobs := make(chan candidate) + type result struct { + candidate candidate + line sbInspectLine + err error + } + results := make(chan result, len(candidates)-1) + workers := maxInspectWorkers + if workers > len(candidates)-1 { + workers = len(candidates) - 1 + } + var wg sync.WaitGroup + wg.Add(workers) + for w := 0; w < workers; w++ { + go func() { + defer wg.Done() + for c := range jobs { + line, err := inspect(c) + results <- result{candidate: c, line: line, err: err} + } + }() + } + go func() { + defer close(jobs) + for _, c := range candidates[1:] { + select { + case jobs <- c: + case <-inspectCtx.Done(): + return + } + } + }() + go func() { + wg.Wait() + close(results) + }() + for r := range results { + if r.err == nil { + apply(r.candidate.index, r.line) + } + } } // singBoxSidecar mirrors the design-15 sidecar (lattice.singbox-metadata.v2). @@ -413,7 +480,11 @@ func applySingBoxSidecar(source Source, nodes []model.SingBoxNode) { } raw, err := readFn(metaPath) if err != nil { - return // no sidecar on this node: nothing to annotate + if errors.Is(err, os.ErrNotExist) { + return // no sidecar on this node: nothing to annotate + } + logf(source, "sing-box sidecar %s unreadable (%v); reporting base inventory", metaPath, boundedErr(err)) + return } raw = bytes.TrimSpace(raw) if len(raw) == 0 { @@ -424,9 +495,13 @@ func applySingBoxSidecar(source Source, nodes []model.SingBoxNode) { logf(source, "sing-box sidecar %s unreadable (%v); reporting base inventory", metaPath, boundedErr(err)) return } - if meta.Schema != "lattice.singbox-metadata.v2" || len(meta.Inbounds) == 0 { + if meta.Schema == "" { return // legacy v1 flat sidecar: no per-line annotations } + if err := validateSingBoxSidecar(meta); err != nil { + logf(source, "sing-box sidecar %s invalid (%v); reporting base inventory", metaPath, boundedErr(err)) + return + } type sidecarLine struct { lineUUID string downstreamLineUUID string @@ -457,6 +532,71 @@ func applySingBoxSidecar(source Source, nodes []model.SingBoxNode) { } } +func validateSingBoxSidecar(meta singBoxSidecar) error { + if meta.Schema != "lattice.singbox-metadata.v2" { + return fmt.Errorf("unsupported schema %q", meta.Schema) + } + byTag := make(map[string]struct{}, len(meta.Inbounds)) + byUUID := make(map[string]struct{}, len(meta.Inbounds)) + next := make(map[string]string, len(meta.Inbounds)) + for _, in := range meta.Inbounds { + tag := strings.TrimSpace(in.Tag) + lineUUID := strings.ToLower(strings.TrimSpace(in.LineUUID)) + if tag == "" || !isUUIDv4(lineUUID) { + return fmt.Errorf("inbound has invalid tag or line_uuid") + } + if _, exists := byTag[tag]; exists { + return fmt.Errorf("duplicate inbound tag %q", tag) + } + if _, exists := byUUID[lineUUID]; exists { + return fmt.Errorf("duplicate line_uuid %q", lineUUID) + } + byTag[tag] = struct{}{} + byUUID[lineUUID] = struct{}{} + if in.Chain != nil && in.Chain.DownstreamLineUUID != nil { + downstream := strings.ToLower(strings.TrimSpace(*in.Chain.DownstreamLineUUID)) + if !isUUIDv4(downstream) { + return fmt.Errorf("inbound %q has invalid downstream_line_uuid", tag) + } + if downstream == lineUUID { + return fmt.Errorf("inbound %q has a self-referential chain", tag) + } + next[lineUUID] = downstream + } + } + for start := range next { + seen := map[string]struct{}{} + for current := start; current != ""; current = next[current] { + if _, local := byUUID[current]; !local { + break // a declared cross-node edge cannot be validated locally + } + if _, repeated := seen[current]; repeated { + return fmt.Errorf("sidecar contains a local chain cycle") + } + seen[current] = struct{}{} + } + } + return nil +} + +func isUUIDv4(value string) bool { + if len(value) != 36 || value[8] != '-' || value[13] != '-' || value[18] != '-' || value[23] != '-' || value[14] != '4' { + return false + } + if value[19] != '8' && value[19] != '9' && value[19] != 'a' && value[19] != 'b' { + return false + } + for i, r := range value { + if i == 8 || i == 13 || i == 18 || i == 23 { + continue + } + if !((r >= '0' && r <= '9') || (r >= 'a' && r <= 'f')) { + return false + } + } + return true +} + // logf routes a best-effort degradation note through the source's Logf seam // (default log.Printf). Used only for non-fatal enrichment/annotation gaps. func logf(source Source, format string, args ...any) { diff --git a/internal/singboxdiscover/discover_test.go b/internal/singboxdiscover/discover_test.go index 4f6dbba..5dab8e4 100644 --- a/internal/singboxdiscover/discover_test.go +++ b/internal/singboxdiscover/discover_test.go @@ -4,8 +4,12 @@ import ( "context" "errors" "fmt" + "os" "strings" + "sync" + "sync/atomic" "testing" + "time" "github.com/LatticeNet/lattice-sdk/model" ) @@ -425,6 +429,57 @@ func TestDiscoverSidecarCorruptLogsAndContinues(t *testing.T) { } } +func TestDiscoverSidecarReadPermissionErrorLogsAndContinues(t *testing.T) { + var logs []string + src := Source{ + MetaPath: "/etc/sing-box/lattice-metadata.json", + runner: listOnlyRunner(t, sidecarTestList), + runtimeFiles: func() []string { return nil }, + readFile: func(string) ([]byte, error) { + return nil, os.ErrPermission + }, + Logf: func(f string, a ...any) { logs = append(logs, fmt.Sprintf(f, a...)) }, + } + inv, err := Discover(context.Background(), src, "node-permission") + if err != nil || inv.Nodes[0].LineUUID != "" { + t.Fatalf("unreadable sidecar must return only base inventory: inv=%+v err=%v", inv, err) + } + if len(logs) != 1 || !strings.Contains(logs[0], "permission") { + t.Fatalf("permission error must be surfaced once, got %v", logs) + } +} + +func TestDiscoverSidecarInvalidV2IsRejectedAtomically(t *testing.T) { + for _, tc := range []struct { + name string + raw string + }{ + {"wrong schema", `{"schema":"lattice.singbox-metadata.v3","inbounds":[]}`}, + {"bad uuid", `{"schema":"lattice.singbox-metadata.v2","inbounds":[{"tag":"vless-31001","line_uuid":"not-a-uuid"}]}`}, + {"duplicate tag", `{"schema":"lattice.singbox-metadata.v2","inbounds":[{"tag":"vless-31001","line_uuid":"11111111-1111-4111-8111-111111111111"},{"tag":"vless-31001","line_uuid":"22222222-2222-4222-8222-222222222222"}]}`}, + {"self chain", `{"schema":"lattice.singbox-metadata.v2","inbounds":[{"tag":"vless-31001","line_uuid":"11111111-1111-4111-8111-111111111111","chain":{"downstream_line_uuid":"11111111-1111-4111-8111-111111111111"}}]}`}, + {"local cycle", `{"schema":"lattice.singbox-metadata.v2","inbounds":[{"tag":"a","line_uuid":"11111111-1111-4111-8111-111111111111","chain":{"downstream_line_uuid":"22222222-2222-4222-8222-222222222222"}},{"tag":"b","line_uuid":"22222222-2222-4222-8222-222222222222","chain":{"downstream_line_uuid":"11111111-1111-4111-8111-111111111111"}}]}`}, + } { + t.Run(tc.name, func(t *testing.T) { + var logs []string + src := Source{ + MetaPath: "/meta.json", + runner: listOnlyRunner(t, sidecarTestList), + runtimeFiles: func() []string { return nil }, + readFile: func(string) ([]byte, error) { return []byte(tc.raw), nil }, + Logf: func(f string, a ...any) { logs = append(logs, fmt.Sprintf(f, a...)) }, + } + inv, err := Discover(context.Background(), src, "node-invalid") + if err != nil || inv.Nodes[0].LineUUID != "" || inv.Nodes[0].DownstreamLineUUID != "" { + t.Fatalf("invalid v2 must be rejected as a whole: inv=%+v err=%v", inv, err) + } + if len(logs) != 1 || !strings.Contains(logs[0], "invalid") { + t.Fatalf("invalid v2 must be surfaced once, got %v", logs) + } + }) + } +} + func TestDiscoverSidecarV1Ignored(t *testing.T) { var logs []string src := Source{ @@ -512,7 +567,7 @@ func TestPrimaryPathEnrichesFromInspect(t *testing.T) { func TestInspectUnavailableFallsBackToConfig(t *testing.T) { var logs []string - inspectCalls := 0 + var inspectCalls atomic.Int32 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"}}], @@ -528,7 +583,7 @@ func TestInspectUnavailableFallsBackToConfig(t *testing.T) { case "provision": return []byte(`{}`), nil } - inspectCalls++ + inspectCalls.Add(1) return nil, errors.New("sb: unknown command inspect") // old sb build }, runtimeFiles: func() []string { return []string{"/etc/sing-box/config.json"} }, @@ -543,8 +598,8 @@ func TestInspectUnavailableFallsBackToConfig(t *testing.T) { if n.OutboundRef != "exit-hk" || n.OutboundServer != "198.51.100.9" || n.LineID != "line-uuid-a" { t.Fatalf("config join must still enrich when inspect is unavailable: %+v", n) } - if inspectCalls != 1 { - t.Fatalf("first inspect failure must stop further inspect calls, got %d", inspectCalls) + if inspectCalls.Load() != 1 { + t.Fatalf("first inspect failure must stop further inspect calls, got %d", inspectCalls.Load()) } if len(logs) != 1 || !strings.Contains(logs[0], "inspect unavailable") { t.Fatalf("inspect degradation must be logged once, got %v", logs) @@ -558,7 +613,7 @@ func TestInspectBudgetTruncates(t *testing.T) { rows = append(rows, fmt.Sprintf(`{"name":%q,"protocol":"vless","port":"31001"}`, name)) } listJSON := `{"ok":true,"count":5,"nodes":[` + strings.Join(rows, ",") + `]}` - inspectCalls := 0 + var inspectCalls atomic.Int32 src := Source{ MaxInspect: 3, runner: func(_ context.Context, _ string, args ...string) ([]byte, error) { @@ -569,7 +624,7 @@ func TestInspectBudgetTruncates(t *testing.T) { case "provision": return []byte(`{}`), nil } - inspectCalls++ + inspectCalls.Add(1) return []byte(`{"ok":true,"line":{"tag":"` + last + `","outbound":{"tag":"direct","protocol":"direct"}}}`), nil }, runtimeFiles: func() []string { return nil }, @@ -578,8 +633,8 @@ func TestInspectBudgetTruncates(t *testing.T) { if err != nil { t.Fatalf("Discover: %v", err) } - if inspectCalls != 3 { - t.Fatalf("inspect calls must be bounded by MaxInspect=3, got %d", inspectCalls) + if inspectCalls.Load() != 3 { + t.Fatalf("inspect calls must be bounded by MaxInspect=3, got %d", inspectCalls.Load()) } for i, n := range inv.Nodes { enriched := n.OutboundRef == "direct" @@ -588,3 +643,56 @@ func TestInspectBudgetTruncates(t *testing.T) { } } } + +func TestInspectUsesAggregateDeadlineAndBoundedConcurrency(t *testing.T) { + var rows []string + for i := 0; i < 17; i++ { + rows = append(rows, fmt.Sprintf(`{"name":"n%d.json","protocol":"vless","port":"31001"}`, i)) + } + listJSON := `{"ok":true,"count":17,"nodes":[` + strings.Join(rows, ",") + `]}` + var mu sync.Mutex + active, peak := 0, 0 + src := Source{ + Timeout: 120 * time.Millisecond, + MaxInspect: 17, + runner: func(ctx context.Context, _ string, args ...string) ([]byte, error) { + last := args[len(args)-1] + switch last { + case "list": + return []byte(listJSON), nil + case "provision": + return []byte(`{}`), nil + } + mu.Lock() + active++ + if active > peak { + peak = active + } + mu.Unlock() + defer func() { + mu.Lock() + active-- + mu.Unlock() + }() + select { + case <-time.After(40 * time.Millisecond): + return []byte(`{"ok":true,"line":{"outbound":{"tag":"direct"}}}`), nil + case <-ctx.Done(): + return nil, ctx.Err() + } + }, + runtimeFiles: func() []string { return nil }, + } + started := time.Now() + _, err := Discover(context.Background(), src, "node-deadline") + elapsed := time.Since(started) + if err != nil { + t.Fatalf("Discover: %v", err) + } + if elapsed > 280*time.Millisecond { + t.Fatalf("inspect aggregate deadline exceeded: %v", elapsed) + } + if peak < 2 || peak > maxInspectWorkers { + t.Fatalf("inspect concurrency peak = %d, want 2..%d", peak, maxInspectWorkers) + } +} diff --git a/scripts/check-release-workflow.sh b/scripts/check-release-workflow.sh index 31e8a9c..7c4ce2d 100644 --- a/scripts/check-release-workflow.sh +++ b/scripts/check-release-workflow.sh @@ -45,3 +45,8 @@ if grep -Fq -- "github.com/LatticeNet/lattice-sdk@v0.2.0" "$workflow"; then echo "release workflow must not pin a stale lattice-sdk workspace replace" >&2 exit 1 fi + +if grep -Fq -- "checkout lattice-sdk" "$workflow" || grep -Fq -- "go work init ./lattice-sdk" "$workflow"; then + echo "release workflow must build the SDK version pinned in go.mod" >&2 + exit 1 +fi