Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 0 additions & 20 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
@@ -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=
Expand Down
206 changes: 206 additions & 0 deletions internal/server/linemeta.go
Original file line number Diff line number Diff line change
@@ -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
}
Loading