diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fc07439..a8c4e0a 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) 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