From 81b8e829f7733c58349c6a29c88906041441032b Mon Sep 17 00:00:00 2001 From: lr00rl Date: Sat, 18 Jul 2026 05:30:01 -0700 Subject: [PATCH 1/3] Add design-15 line_uuid metadata model and sidecar renderer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit D1/§4 slice: persist the line_hash_id -> line_uuid mapping in the vpnmeta/lineuuid KV bucket, attach LineUUID/DownstreamLineUUID to the Lines read model (allocation degrades, never fails the read), render the lattice.singbox-metadata.v2 sidecar deterministically per node, and add the reviewed apply-task helper (atomic tmp+mv, .bak backup) without wiring any trigger yet. Pins lattice-sdk @60c69bd (draft PR lattice-sdk#7) for the new SingBoxNode line_uuid/downstream_line_uuid fields. --- go.mod | 2 +- go.sum | 2 + internal/server/linemeta.go | 206 +++++++++++++++++ internal/server/linemeta_test.go | 376 +++++++++++++++++++++++++++++++ internal/server/lines.go | 109 +++++---- internal/server/server.go | 4 + 6 files changed, 653 insertions(+), 46 deletions(-) create mode 100644 internal/server/linemeta.go create mode 100644 internal/server/linemeta_test.go diff --git a/go.mod b/go.mod index 8471a29..84c867b 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,7 @@ module github.com/LatticeNet/lattice-server go 1.26 require ( - github.com/LatticeNet/lattice-sdk v0.2.18-0.20260715051408-e510bd7489dd + github.com/LatticeNet/lattice-sdk v0.2.18-0.20260717071920-60c69bdf91c7 github.com/coreos/go-oidc/v3 v3.18.0 github.com/descope/virtualwebauthn v1.0.5 github.com/go-webauthn/webauthn v0.17.4 diff --git a/go.sum b/go.sum index a764420..a0b123d 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,7 @@ github.com/LatticeNet/lattice-sdk v0.2.18-0.20260715051408-e510bd7489dd h1:XqL/FmVwJlEjYODGxKX/2JivckwLV31PZa6GwOeB2UY= github.com/LatticeNet/lattice-sdk v0.2.18-0.20260715051408-e510bd7489dd/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/coreos/go-oidc/v3 v3.18.0 h1:V9orjXynvu5wiC9SemFTWnG4F45v403aIcjWo0d41+A= github.com/coreos/go-oidc/v3 v3.18.0/go.mod h1:DYCf24+ncYi+XkIH97GY1+dqoRlbaSI26KVTCI9SrY4= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= diff --git a/internal/server/linemeta.go b/internal/server/linemeta.go new file mode 100644 index 0000000..c29602c --- /dev/null +++ b/internal/server/linemeta.go @@ -0,0 +1,206 @@ +package server + +import ( + "encoding/json" + "errors" + "fmt" + "path" + "sort" + "strings" + "time" + + "github.com/LatticeNet/lattice-sdk/model" + "github.com/LatticeNet/lattice-server/internal/id" +) + +// design-15 D1/§4: line_uuid is the durable, control-plane-assigned identity of a +// Line (line_hash_id stays the recomputable topology fingerprint). The mapping is +// persisted in the KV bucket below, and the schema-v2 sidecar file rendered here +// carries it onto each sing-box node. +const ( + lineUUIDKVBucket = "vpnmeta/lineuuid" + lineMetadataSchemaV2 = "lattice.singbox-metadata.v2" + lineMetadataWriter = "lattice-server" + // lineMetadataPath is the on-box sidecar location. It must stay OUTSIDE the + // sing-box -C directory's *.json glob — stock sing-box rejects unknown config + // keys, so the metadata file is never consumed by the core (design-15 §4.3). + lineMetadataPath = "/etc/sing-box/lattice-metadata.json" +) + +// ensureLineUUID returns the persisted line_uuid for a line_hash_id, allocating +// and persisting a fresh UUIDv4 on first sight. Idempotent; serialized by +// s.lineUUIDMu so concurrent read-model builds cannot double-allocate. +func (s *Server) ensureLineUUID(lineHashID string) (string, error) { + lineHashID = strings.TrimSpace(lineHashID) + if lineHashID == "" { + return "", errors.New("line_hash_id is required") + } + s.lineUUIDMu.Lock() + defer s.lineUUIDMu.Unlock() + if e, ok := s.store.KVEntry(lineUUIDKVBucket, lineHashID); ok && strings.TrimSpace(e.Value) != "" { + return e.Value, nil + } + uuid, err := newProxyUUID() + if err != nil { + return "", err + } + if err := s.store.PutKV(model.KVEntry{Bucket: lineUUIDKVBucket, Key: lineHashID, Value: uuid}); err != nil { + return "", err + } + return uuid, nil +} + +// ── schema-v2 sidecar rendering (contract lattice.singbox-metadata.v2) ─────── + +type lineMetadataDocV2 struct { + Schema string `json:"schema"` + NodeID string `json:"node_id"` + NodeUUID string `json:"node_uuid,omitempty"` + UpdatedAt string `json:"updated_at"` + Writer string `json:"writer"` + Inbounds []lineMetadataInboundV2 `json:"inbounds"` + Reserved lineMetadataReservedV2 `json:"reserved"` +} + +type lineMetadataInboundV2 struct { + Tag string `json:"tag"` + LineUUID string `json:"line_uuid"` + LineHashID string `json:"line_hash_id,omitempty"` + Chain *lineMetadataChainV2 `json:"chain,omitempty"` +} + +// lineMetadataChainV2 is emitted whenever a line has a declared or resolvable +// downstream; downstream_line_uuid serializes as explicit null when unknown (the +// contract requires the key, nullable). +type lineMetadataChainV2 struct { + DownstreamLineUUID *string `json:"downstream_line_uuid"` + DownstreamNode string `json:"downstream_node,omitempty"` +} + +// lineMetadataReservedV2 is the frozen documentation-only block: no writer emits +// `_lattice` into sing-box config, no reader requires it (design-15 §4.4). +type lineMetadataReservedV2 struct { + InConfigKey string `json:"in_config_key"` + Fields lineMetadataReservedFields `json:"fields"` +} + +type lineMetadataReservedFields struct { + LineUUID string `json:"line_uuid"` + NodeUUID string `json:"node_uuid"` + LineHashID string `json:"line_hash_id"` +} + +// renderLineMetadataJSON renders the schema-v2 sidecar for one node from the +// current line read model. Output is deterministic (inbounds sorted by tag, fixed +// field order) so re-renders of unchanged state are byte-identical. A line whose +// line_uuid allocation degraded fails the render: a schema-invalid sidecar must +// never reach the box. +func (s *Server) renderLineMetadataJSON(nodeID string) ([]byte, error) { + if _, ok := s.store.Node(nodeID); !ok { + return nil, fmt.Errorf("node %q not found", nodeID) + } + groups := s.buildLineGroups() + lineOwner := map[string]string{} // line_hash_id -> node_id, for naming resolved downstreams + var lines []Line + for _, g := range groups { + for _, ln := range g.Lines { + lineOwner[ln.LineHashID] = g.NodeID + } + if g.NodeID == nodeID { + lines = g.Lines + } + } + inbounds := make([]lineMetadataInboundV2, 0, len(lines)) + for _, ln := range lines { + tag := firstNonEmpty(ln.Tag, ln.Name) + if tag == "" { + return nil, fmt.Errorf("line %s has no tag", ln.LineHashID) + } + if ln.LineUUID == "" { + return nil, fmt.Errorf("line %s has no line_uuid (allocation degraded)", ln.LineHashID) + } + ib := lineMetadataInboundV2{Tag: tag, LineUUID: ln.LineUUID, LineHashID: ln.LineHashID} + ds := strings.TrimSpace(ln.DownstreamLineUUID) + ref := strings.ToLower(strings.TrimSpace(ln.OutboundRef)) + if ds != "" || (ref != "" && ref != "direct") { + chain := &lineMetadataChainV2{} + if ds != "" { + chain.DownstreamLineUUID = &ds + } + // Name the known downstream node when the relay edge resolved fleet-wide. + for _, edge := range ln.JumpEdges { + if owner, ok := lineOwner[edge]; ok && owner != nodeID { + chain.DownstreamNode = s.nodeDisplayName(owner) + break + } + } + ib.Chain = chain + } + inbounds = append(inbounds, ib) + } + sort.Slice(inbounds, func(i, j int) bool { return inbounds[i].Tag < inbounds[j].Tag }) + doc := lineMetadataDocV2{ + Schema: lineMetadataSchemaV2, + NodeID: nodeID, + NodeUUID: s.nodeIdentityUUID(nodeID), + UpdatedAt: s.now().UTC().Format(time.RFC3339), + Writer: lineMetadataWriter, + Inbounds: inbounds, + Reserved: lineMetadataReservedV2{ + InConfigKey: "_lattice", + Fields: lineMetadataReservedFields{LineUUID: "string", NodeUUID: "string", LineHashID: "string"}, + }, + } + out, err := json.MarshalIndent(doc, "", " ") + if err != nil { + return nil, err + } + return append(out, '\n'), nil +} + +// ── sidecar apply task (reviewed script; trigger wiring is a later slice) ───── + +// lineMetadataApplyScript renders the on-box apply script for the v2 sidecar: +// atomic tmp+mv write, sibling .bak backup, 0644 root:root. sing-box never reads +// the file, so no service reload follows. +func lineMetadataApplyScript(payload []byte) string { + target := lineMetadataPath + candidate := target + ".lattice-new" + backup := target + ".bak" + return "set -e\n" + + "umask 022\n" + + "TARGET=" + shellQuote(target) + "\n" + + "CANDIDATE=" + shellQuote(candidate) + "\n" + + "BACKUP=" + shellQuote(backup) + "\n" + + "mkdir -p " + shellQuote(path.Dir(target)) + "\n" + + "trap 'rm -f \"$CANDIDATE\"' ERR\n" + + heredocWrite("\"$CANDIDATE\"", "LATTICE_LINEMETA", string(payload)) + + "chmod 0644 \"$CANDIDATE\"\n" + + "chown root:root \"$CANDIDATE\" 2>/dev/null || true\n" + + "if [ -f \"$TARGET\" ]; then cp -p \"$TARGET\" \"$BACKUP\"; fi\n" + + "mv -f \"$CANDIDATE\" \"$TARGET\"\n" + + "trap - ERR\n" + + "echo " + shellQuote("lattice linemeta: "+target+" applied") + "\n" +} + +// newLineMetadataApplyTask builds (but does NOT queue) the reviewed task that +// applies the rendered sidecar on one node. plan→approve→apply trigger wiring is +// a later design-15 slice; this helper only fixes the task shape. +func (s *Server) newLineMetadataApplyTask(p principal, nodeID string) (model.Task, error) { + payload, err := s.renderLineMetadataJSON(nodeID) + if err != nil { + return model.Task{}, err + } + return model.Task{ + ID: id.New("task"), + ActorID: p.ActorID, + TokenID: p.TokenID, + Targets: []string{nodeID}, + Interpreter: "sh", + Script: lineMetadataApplyScript(payload), + TimeoutSec: defaultTaskTimeoutSec, + OutputLimit: defaultTaskOutputLimit, + Status: model.TaskQueued, + CreatedAt: s.now(), + }, nil +} diff --git a/internal/server/linemeta_test.go b/internal/server/linemeta_test.go new file mode 100644 index 0000000..b18dd47 --- /dev/null +++ b/internal/server/linemeta_test.go @@ -0,0 +1,376 @@ +package server + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/LatticeNet/lattice-sdk/model" + "github.com/LatticeNet/lattice-server/internal/rbac" + "github.com/LatticeNet/lattice-server/internal/store" +) + +func newLinemetaTestServer(t *testing.T, st *store.Store) *Server { + t.Helper() + srv, err := New(Options{Store: st, AdminPassword: testAdminPass, DisableRenewalScheduler: true}) + if err != nil { + t.Fatal(err) + } + return srv +} + +// (a) Allocation is idempotent per line_hash_id and survives a store reopen. +func TestEnsureLineUUIDIdempotentAndPersistent(t *testing.T) { + dir := t.TempDir() + stPath := filepath.Join(dir, "state.json") + st, err := store.Open(stPath) + if err != nil { + t.Fatal(err) + } + srv := newLinemetaTestServer(t, st) + + const hash = "line_0123456789abcdef01234567" + u1, err := srv.ensureLineUUID(hash) + if err != nil { + t.Fatalf("ensure: %v", err) + } + u2, err := srv.ensureLineUUID(hash) + if err != nil { + t.Fatalf("ensure again: %v", err) + } + if u1 != u2 { + t.Fatalf("not idempotent: %q vs %q", u1, u2) + } + if err := st.Close(); err != nil { + t.Fatal(err) + } + + st2, err := store.Open(stPath) + if err != nil { + t.Fatal(err) + } + srv2 := newLinemetaTestServer(t, st2) + u3, err := srv2.ensureLineUUID(hash) + if err != nil { + t.Fatalf("ensure after reopen: %v", err) + } + if u3 != u1 { + t.Fatalf("uuid not persisted across reopen: %q vs %q", u3, u1) + } + if _, err := srv2.ensureLineUUID(" "); err == nil { + t.Fatal("empty line_hash_id: want error") + } +} + +// (b) Allocated ids are well-formed UUIDv4 (36 chars, hyphen layout, version and +// variant bits) using the shared stdlib generator — no third-party dep. +func TestEnsureLineUUIDFormatV4(t *testing.T) { + st, err := store.Open("") + if err != nil { + t.Fatal(err) + } + srv := newLinemetaTestServer(t, st) + u, err := srv.ensureLineUUID("line_abcdef0123456789abcdef01") + if err != nil { + t.Fatal(err) + } + if len(u) != 36 { + t.Fatalf("len = %d, want 36: %q", len(u), u) + } + for _, pos := range []int{8, 13, 18, 23} { + if u[pos] != '-' { + t.Fatalf("missing hyphen at %d: %q", pos, u) + } + } + if u[14] != '4' { + t.Fatalf("version nibble = %c, want 4: %q", u[14], u) + } + if c := u[19]; c != '8' && c != '9' && c != 'a' && c != 'b' { + t.Fatalf("variant nibble = %c, want 8/9/a/b: %q", c, u) + } + if !proxyUUIDRe.MatchString(u) { + t.Fatalf("proxyUUIDRe rejects %q", u) + } +} + +// seedLinemetaNodes sets up node-a (with a discovered hub line carrying a declared +// downstream_line_uuid, a relay line with no resolvable downstream, and a direct +// line) plus node-b hosting the hub's downstream endpoint. +func seedLinemetaNodes(t *testing.T, srv *Server) { + t.Helper() + if err := srv.store.UpsertNode(model.Node{ID: "node-a", LatticeIdentityUUID: "node-uuid-a", Name: "Node A", PublicIP: "203.0.113.5"}); err != nil { + t.Fatal(err) + } + if err := srv.store.UpsertNode(model.Node{ID: "node-b", Name: "Node B", PublicIP: "198.51.100.9"}); err != nil { + t.Fatal(err) + } + srv.singboxInvMu.Lock() + srv.singboxInv = map[string]model.SingBoxInventory{ + "node-a": { + NodeID: "node-a", At: srv.now(), Status: "ok", + Nodes: []model.SingBoxNode{ + {Name: "hub-a", Protocol: "vless", Network: "tcp", Address: "203.0.113.5", Port: "443", OutboundRef: "exit-b", OutboundServer: "198.51.100.9", OutboundPort: "8443", DownstreamLineUUID: "1eec4b5a-9c2f-4a1b-8d3e-5f6a7b8c9d0e"}, + {Name: "orphan-relay", Protocol: "vless", Network: "tcp", Address: "203.0.113.5", Port: "8080", OutboundRef: "relay-x", OutboundServer: "10.0.0.1", OutboundPort: "9999"}, + {Name: "direct-a", Protocol: "vless", Network: "tcp", Address: "203.0.113.5", Port: "80", OutboundRef: "direct"}, + }, + }, + "node-b": { + NodeID: "node-b", At: srv.now(), Status: "ok", + Nodes: []model.SingBoxNode{ + {Name: "exit-b-in", Protocol: "vless", Network: "tcp", Address: "198.51.100.9", Port: "8443"}, + }, + }, + } + srv.singboxInvMu.Unlock() +} + +func findLine(t *testing.T, groups []LineGroup, nodeID, tag string) Line { + t.Helper() + for _, g := range groups { + if g.NodeID != nodeID { + continue + } + for _, ln := range g.Lines { + if ln.Tag == tag { + return ln + } + } + } + t.Fatalf("line %s/%s not found: %+v", nodeID, tag, groups) + return Line{} +} + +// (c) buildLineGroups fills line_uuid on every line (persisted, stable across +// calls) and passes discovered downstream_line_uuid through. +func TestBuildLineGroupsFillsLineUUIDs(t *testing.T) { + st, err := store.Open("") + if err != nil { + t.Fatal(err) + } + srv := newLinemetaTestServer(t, st) + seedLinemetaNodes(t, srv) + + groups := srv.buildLineGroups() + hub := findLine(t, groups, "node-a", "hub-a") + direct := findLine(t, groups, "node-a", "direct-a") + exit := findLine(t, groups, "node-b", "exit-b-in") + for _, ln := range []Line{hub, direct, exit} { + if !proxyUUIDRe.MatchString(ln.LineUUID) { + t.Fatalf("line %s: line_uuid unset/invalid: %+v", ln.Tag, ln) + } + } + if hub.LineUUID == direct.LineUUID || hub.LineUUID == exit.LineUUID || direct.LineUUID == exit.LineUUID { + t.Fatalf("line_uuids must be distinct: %q %q %q", hub.LineUUID, direct.LineUUID, exit.LineUUID) + } + if hub.DownstreamLineUUID != "1eec4b5a-9c2f-4a1b-8d3e-5f6a7b8c9d0e" { + t.Fatalf("discovered downstream_line_uuid not passed through: %+v", hub) + } + if direct.DownstreamLineUUID != "" || exit.DownstreamLineUUID != "" { + t.Fatalf("lines without a declared downstream must stay empty: %+v %+v", direct, exit) + } + // Persisted mapping matches the read model; re-build keeps the same uuid. + e, ok := srv.store.KVEntry(lineUUIDKVBucket, hub.LineHashID) + if !ok || e.Value != hub.LineUUID { + t.Fatalf("kv mapping mismatch: %+v ok=%v want %q", e, ok, hub.LineUUID) + } + if again := findLine(t, srv.buildLineGroups(), "node-a", "hub-a"); again.LineUUID != hub.LineUUID { + t.Fatalf("line_uuid not stable across rebuilds: %q vs %q", again.LineUUID, hub.LineUUID) + } +} + +// (d) The renderer emits the exact contract shape (mirrors fixtures/v2-valid-full.json). +func TestRenderLineMetadataJSON(t *testing.T) { + st, err := store.Open("") + if err != nil { + t.Fatal(err) + } + srv := newLinemetaTestServer(t, st) + srv.now = func() time.Time { return time.Date(2026, 7, 17, 4, 0, 0, 0, time.UTC) } + seedLinemetaNodes(t, srv) + + raw, err := srv.renderLineMetadataJSON("node-a") + if err != nil { + t.Fatalf("render: %v", err) + } + + // Top-level key set matches the fixture shape. + var top map[string]json.RawMessage + if err := json.Unmarshal(raw, &top); err != nil { + t.Fatalf("decode: %v", err) + } + for _, key := range []string{"schema", "node_id", "node_uuid", "updated_at", "writer", "inbounds", "reserved"} { + if _, ok := top[key]; !ok { + t.Fatalf("missing top-level key %q in %s", key, raw) + } + } + + var doc struct { + Schema string `json:"schema"` + NodeID string `json:"node_id"` + NodeUUID string `json:"node_uuid"` + UpdatedAt string `json:"updated_at"` + Writer string `json:"writer"` + Inbounds []struct { + Tag string `json:"tag"` + LineUUID string `json:"line_uuid"` + LineHashID string `json:"line_hash_id"` + Chain *struct { + DownstreamLineUUID *string `json:"downstream_line_uuid"` + DownstreamNode string `json:"downstream_node"` + } `json:"chain"` + } `json:"inbounds"` + Reserved struct { + InConfigKey string `json:"in_config_key"` + Fields map[string]string `json:"fields"` + } `json:"reserved"` + } + if err := json.Unmarshal(raw, &doc); err != nil { + t.Fatalf("typed decode: %v", err) + } + if doc.Schema != "lattice.singbox-metadata.v2" || doc.Writer != "lattice-server" { + t.Fatalf("schema/writer: %+v", doc) + } + if doc.NodeID != "node-a" || doc.NodeUUID != "node-uuid-a" { + t.Fatalf("node identity: %+v", doc) + } + ts, err := time.Parse(time.RFC3339, doc.UpdatedAt) + if err != nil || !ts.Equal(srv.now()) { + t.Fatalf("updated_at %q: %v", doc.UpdatedAt, err) + } + if doc.Reserved.InConfigKey != "_lattice" || + doc.Reserved.Fields["line_uuid"] != "string" || doc.Reserved.Fields["node_uuid"] != "string" || + doc.Reserved.Fields["line_hash_id"] != "string" || len(doc.Reserved.Fields) != 3 { + t.Fatalf("reserved block: %+v", doc.Reserved) + } + + // Inbounds sorted by tag: direct-a < hub-a < orphan-relay. + if len(doc.Inbounds) != 3 { + t.Fatalf("want 3 inbounds, got %d: %s", len(doc.Inbounds), raw) + } + if doc.Inbounds[0].Tag != "direct-a" || doc.Inbounds[1].Tag != "hub-a" || doc.Inbounds[2].Tag != "orphan-relay" { + t.Fatalf("inbounds not sorted by tag: %s", raw) + } + groups := srv.buildLineGroups() + for _, ib := range doc.Inbounds { + if !proxyUUIDRe.MatchString(ib.LineUUID) { + t.Fatalf("inbound %s line_uuid invalid: %q", ib.Tag, ib.LineUUID) + } + if want := findLine(t, groups, "node-a", ib.Tag); ib.LineUUID != want.LineUUID || ib.LineHashID != want.LineHashID { + t.Fatalf("inbound %s diverges from read model: %+v vs %+v", ib.Tag, ib, want) + } + } + if doc.Inbounds[0].Chain != nil { + t.Fatalf("direct line must omit chain: %s", raw) + } + hubChain := doc.Inbounds[1].Chain + if hubChain == nil || hubChain.DownstreamLineUUID == nil || + *hubChain.DownstreamLineUUID != "1eec4b5a-9c2f-4a1b-8d3e-5f6a7b8c9d0e" || hubChain.DownstreamNode != "Node B" { + t.Fatalf("hub chain wrong: %s", raw) + } + orphanChain := doc.Inbounds[2].Chain + if orphanChain == nil || orphanChain.DownstreamLineUUID != nil || orphanChain.DownstreamNode != "" { + t.Fatalf("unresolved relay chain must carry explicit null uuid and no node: %s", raw) + } + // Explicit null, not a missing key, for the unresolved relay. + var ibs []map[string]json.RawMessage + if err := json.Unmarshal(top["inbounds"], &ibs); err != nil { + t.Fatal(err) + } + var orphanChainKeys map[string]json.RawMessage + if err := json.Unmarshal(ibs[2]["chain"], &orphanChainKeys); err != nil { + t.Fatal(err) + } + if v, ok := orphanChainKeys["downstream_line_uuid"]; !ok || string(v) != "null" { + t.Fatalf("downstream_line_uuid must be explicit null: %s", ibs[2]["chain"]) + } + if _, ok := orphanChainKeys["downstream_node"]; ok { + t.Fatalf("downstream_node must be omitted when unknown: %s", ibs[2]["chain"]) + } + + // Deterministic: a second render of unchanged state is byte-identical. + raw2, err := srv.renderLineMetadataJSON("node-a") + if err != nil { + t.Fatal(err) + } + if string(raw) != string(raw2) { + t.Fatal("render not deterministic") + } + // Node-b renders its own single-inbound doc; unknown node errors. + if _, err := srv.renderLineMetadataJSON("node-b"); err != nil { + t.Fatalf("render node-b: %v", err) + } + if _, err := srv.renderLineMetadataJSON("node-missing"); err == nil { + t.Fatal("unknown node: want error") + } +} + +// (e) A persistence failure degrades the read model (uuid left empty) instead of +// failing it; ensureLineUUID surfaces the error to direct callers. +func TestBuildLineGroupsDegradesWhenAllocationFails(t *testing.T) { + dir := t.TempDir() + stPath := filepath.Join(dir, "state.json") + st, err := store.Open(stPath) + if err != nil { + t.Fatal(err) + } + srv := newLinemetaTestServer(t, st) + seedLinemetaNodes(t, srv) + + // Break persistence: replace the state dir with a regular file so every + // Save fails inside MkdirAll. + if err := st.Close(); err != nil { + t.Fatal(err) + } + if err := os.RemoveAll(dir); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(dir, []byte("blocked"), 0o600); err != nil { + t.Fatal(err) + } + + if _, err := srv.ensureLineUUID("line_deadbeef0123456789abcdef"); err == nil { + t.Fatal("ensureLineUUID with broken store: want error") + } + groups := srv.buildLineGroups() + hub := findLine(t, groups, "node-a", "hub-a") + if hub.LineUUID != "" { + t.Fatalf("degraded read model must leave line_uuid empty, got %q", hub.LineUUID) + } + if hub.Tag != "hub-a" || hub.DownstreamLineUUID == "" { + t.Fatalf("read model must otherwise stay intact: %+v", hub) + } +} + +// The apply task helper renders the sidecar into a reviewed sh script (atomic +// tmp+mv, .bak backup, 0644) and shapes the task without queueing it. +func TestLineMetadataApplyTaskShape(t *testing.T) { + st, err := store.Open("") + if err != nil { + t.Fatal(err) + } + srv := newLinemetaTestServer(t, st) + seedLinemetaNodes(t, srv) + + task, err := srv.newLineMetadataApplyTask(principal{Principal: rbac.Principal{ActorID: "op-1"}}, "node-a") + if err != nil { + t.Fatalf("task: %v", err) + } + if len(task.Targets) != 1 || task.Targets[0] != "node-a" || task.Interpreter != "sh" || + task.Status != model.TaskQueued || task.ActorID != "op-1" { + t.Fatalf("task shape: %+v", task) + } + for _, want := range []string{ + "set -e", "/etc/sing-box/lattice-metadata.json", ".lattice-new", ".bak", + "chmod 0644", "mv -f \"$CANDIDATE\" \"$TARGET\"", "lattice.singbox-metadata.v2", + } { + if !strings.Contains(task.Script, want) { + t.Fatalf("script missing %q:\n%s", want, task.Script) + } + } + if _, err := srv.newLineMetadataApplyTask(principal{Principal: rbac.Principal{ActorID: "op-1"}}, "node-missing"); err == nil { + t.Fatal("unknown node: want error") + } +} diff --git a/internal/server/lines.go b/internal/server/lines.go index 6f4238d..15d490a 100644 --- a/internal/server/lines.go +++ b/internal/server/lines.go @@ -19,30 +19,32 @@ import ( // the server package, not the shared SDK). Secret-free: it carries only // connection-shape metadata, never private keys or passwords. type Line struct { - ID string `json:"id"` // == LineHashID (stable handle) - LineHashID string `json:"line_hash_id"` // stable across re-probes; see lineHash / stableLineHandle - LineID string `json:"line_id,omitempty"` - NodeID string `json:"node_id"` - NodeIdentityUUID string `json:"node_identity_uuid,omitempty"` - Core string `json:"core"` // sing-box | xray | mihomo - Source string `json:"source"` // managed | discovered | imported - Managed bool `json:"managed"` // under Lattice config management - Name string `json:"name"` - Tag string `json:"tag,omitempty"` - Type string `json:"type,omitempty"` // protocol - ListenHost string `json:"listen_host,omitempty"` - ListenPort int `json:"listen_port,omitempty"` - PublicHost string `json:"public_host,omitempty"` - Domain string `json:"domain,omitempty"` - OutboundRef string `json:"outbound_ref,omitempty"` // direct | | "" unknown - OutboundServer string `json:"outbound_server,omitempty"` // downstream server host the outbound routes to - OutboundPort int `json:"outbound_port,omitempty"` // downstream server port the outbound routes to - JumpEdges []string `json:"jump_edges,omitempty"` // line_hash_ids this line relays to - UserCount int `json:"user_count"` - UserKnown bool `json:"user_known"` // false ⇒ discovered line, count not yet inspected - Status string `json:"status,omitempty"` // ok | pending | error | stale - LastError string `json:"last_error,omitempty"` - Metadata map[string]string `json:"metadata,omitempty"` // sing-box `_lattice` block (future enrich) + ID string `json:"id"` // == LineHashID (stable handle) + LineHashID string `json:"line_hash_id"` // stable across re-probes; see lineHash / stableLineHandle + LineID string `json:"line_id,omitempty"` + NodeID string `json:"node_id"` + NodeIdentityUUID string `json:"node_identity_uuid,omitempty"` + LineUUID string `json:"line_uuid,omitempty"` // design-15 D1: durable control-plane identity (vpnmeta/lineuuid) + DownstreamLineUUID string `json:"downstream_line_uuid,omitempty"` // design-15 §6: declared chain edge target + Core string `json:"core"` // sing-box | xray | mihomo + Source string `json:"source"` // managed | discovered | imported + Managed bool `json:"managed"` // under Lattice config management + Name string `json:"name"` + Tag string `json:"tag,omitempty"` + Type string `json:"type,omitempty"` // protocol + ListenHost string `json:"listen_host,omitempty"` + ListenPort int `json:"listen_port,omitempty"` + PublicHost string `json:"public_host,omitempty"` + Domain string `json:"domain,omitempty"` + OutboundRef string `json:"outbound_ref,omitempty"` // direct | | "" unknown + OutboundServer string `json:"outbound_server,omitempty"` // downstream server host the outbound routes to + OutboundPort int `json:"outbound_port,omitempty"` // downstream server port the outbound routes to + JumpEdges []string `json:"jump_edges,omitempty"` // line_hash_ids this line relays to + UserCount int `json:"user_count"` + UserKnown bool `json:"user_known"` // false ⇒ discovered line, count not yet inspected + Status string `json:"status,omitempty"` // ok | pending | error | stale + LastError string `json:"last_error,omitempty"` + Metadata map[string]string `json:"metadata,omitempty"` // sing-box `_lattice` block (future enrich) } // LineGroup is the set of lines on one node — the unit the dashboard renders. @@ -155,27 +157,28 @@ func (s *Server) buildLineGroups() []LineGroup { lineID := firstNonEmpty(n.LineID, n.Metadata["line_id"]) nodeUUID := firstNonEmpty(n.NodeIdentityUUID, n.Metadata["node_uuid"], n.Metadata["lattice_identity_uuid"], s.nodeIdentityUUID(inv.NodeID)) ln := Line{ - LineID: lineID, - NodeID: inv.NodeID, - NodeIdentityUUID: nodeUUID, - Core: "sing-box", - Source: "discovered", - Managed: false, - Name: n.Name, - Tag: n.Name, - Type: n.Protocol, - ListenHost: n.ListenHost, - ListenPort: port, - PublicHost: n.Address, - Domain: firstNonEmpty(n.SNI, n.Host), - OutboundRef: n.OutboundRef, - OutboundServer: n.OutboundServer, - OutboundPort: atoiSafe(n.OutboundPort), - UserCount: n.UserCount, - UserKnown: n.UserKnown, - Status: status, - LastError: lastErr, - Metadata: n.Metadata, + LineID: lineID, + NodeID: inv.NodeID, + NodeIdentityUUID: nodeUUID, + DownstreamLineUUID: strings.TrimSpace(n.DownstreamLineUUID), + Core: "sing-box", + Source: "discovered", + Managed: false, + Name: n.Name, + Tag: n.Name, + Type: n.Protocol, + ListenHost: n.ListenHost, + ListenPort: port, + PublicHost: n.Address, + Domain: firstNonEmpty(n.SNI, n.Host), + OutboundRef: n.OutboundRef, + OutboundServer: n.OutboundServer, + OutboundPort: atoiSafe(n.OutboundPort), + UserCount: n.UserCount, + UserKnown: n.UserKnown, + Status: status, + LastError: lastErr, + Metadata: n.Metadata, } ln.LineHashID = stableLineHandle(ln.LineID) if ln.LineHashID == "" { @@ -222,6 +225,22 @@ func (s *Server) buildLineGroups() []LineGroup { byNode[nodeID] = lines } + // (4) design-15 D1: attach the durable control-plane line_uuid to every line. + // Allocation failure must degrade (uuid left empty + log) and never fail the + // whole read model. Managed lines have no downstream metadata source yet, so + // DownstreamLineUUID stays empty for them this slice. + for nodeID, lines := range byNode { + for i := range lines { + uuid, err := s.ensureLineUUID(lines[i].LineHashID) + if err != nil { + s.logger.Printf("linemeta: allocate line_uuid for %s: %v", lines[i].LineHashID, err) + continue + } + lines[i].LineUUID = uuid + } + byNode[nodeID] = lines + } + // Group, name, and sort deterministically (nodes by id, lines by port then tag). groups := make([]LineGroup, 0, len(byNode)) for nodeID, lines := range byNode { diff --git a/internal/server/server.go b/internal/server/server.go index d06a9f7..9786d7e 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -153,6 +153,10 @@ type Server struct { // returns 429 + Retry-After. Disk is independently bounded by the store caps. logIngestLimiter *ratelimit.Limiter proxyUsageMu sync.Mutex + // lineUUIDMu serializes the line_hash_id -> line_uuid check-and-set so + // concurrent read-model builds cannot allocate two UUIDs for one line + // (design-15 D1). + lineUUIDMu sync.Mutex // userLoginFail brakes FAILED password logins PER ACCOUNT (keyed on the // resolved user id), mirroring the per-user 2FA limiter in intent: an attacker // who already targets a known account cannot widen the password-guess budget by From 7804b663bdd53e14c5f5a89b99baf8b7f90e38cb Mon Sep 17 00:00:00 2001 From: lr00rl Date: Wed, 22 Jul 2026 04:59:15 -0700 Subject: [PATCH 2/3] Make CI verify the SDK version the server actually pins The prior workflow overrode go.mod with an SDK checkout chosen from the server branch name, then silently fell back to SDK main when that unrelated branch did not exist. Testing the declared module version keeps cross-repository slices reproducible and makes dependency pin failures honest. Constraint: Design-15 server branches depend on a pushed SDK pseudo-version with a different branch name Rejected: Add mirrored branch names in lattice-sdk | couples CI correctness to naming conventions Confidence: high Scope-risk: narrow Directive: Cross-repository CI must not override go.mod with an unrelated default-branch checkout Tested: GOWORK=off go mod verify; go vet ./...; go test -race -cover ./...; YAML parse; git diff --check --- .github/workflows/ci.yml | 23 ++--------------------- 1 file changed, 2 insertions(+), 21 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fc07439..58c5a14 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,33 +14,13 @@ jobs: uses: actions/checkout@v6 with: path: lattice-server - - name: checkout lattice-sdk matching ref - id: checkout-sdk-ref - continue-on-error: true - uses: actions/checkout@v6 - with: - repository: LatticeNet/lattice-sdk - ref: ${{ github.head_ref || github.ref_name }} - path: lattice-sdk - - name: reset failed sdk checkout - if: steps.checkout-sdk-ref.outcome == 'failure' - run: rm -rf lattice-sdk - - name: checkout lattice-sdk default ref - if: steps.checkout-sdk-ref.outcome == 'failure' - 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-dependency-path: | - lattice-sdk/go.mod lattice-server/go.mod lattice-server/go.sum - - name: create workspace - run: go work init ./lattice-sdk ./lattice-server - name: gofmt working-directory: lattice-server run: test -z "$(gofmt -l .)" || (gofmt -l . && exit 1) @@ -54,9 +34,10 @@ jobs: working-directory: lattice-server run: go test -race -cover ./... - name: gosec + working-directory: lattice-server run: | go install github.com/securego/gosec/v2/cmd/gosec@latest - gosec ./lattice-server/... + gosec ./... - name: govulncheck working-directory: lattice-server run: | From 3cc0a8dffdbb52942c921fbbf26aca219a4e208a Mon Sep 17 00:00:00 2001 From: lr00rl Date: Wed, 22 Jul 2026 05:04:16 -0700 Subject: [PATCH 3/3] Keep the server security scan on its proven checkout-root path Removing the SDK workspace does not require changing gosec's invocation root. Preserve the repository-prefixed scan target so the dependency fix does not also alter analyzer behavior. Constraint: CI dependency resolution and static-analysis semantics must change independently 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 58c5a14..a8c4e0a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -34,10 +34,9 @@ jobs: working-directory: lattice-server run: go test -race -cover ./... - name: gosec - working-directory: lattice-server run: | go install github.com/securego/gosec/v2/cmd/gosec@latest - gosec ./... + gosec ./lattice-server/... - name: govulncheck working-directory: lattice-server run: |