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
12 changes: 12 additions & 0 deletions docs/contracts/fixtures/invalid-bad-line-uuid.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
{
"schema": "lattice.singbox-metadata.v2",
"node_id": "hk-hub-01",
"updated_at": "2026-07-17T04:00:00Z",
"writer": "lattice-server",
"inbounds": [
{
"tag": "vless-31001",
"line_uuid": "not-a-uuid"
}
]
}
11 changes: 11 additions & 0 deletions docs/contracts/fixtures/invalid-missing-schema.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"node_id": "hk-hub-01",
"updated_at": "2026-07-17T04:00:00Z",
"writer": "lattice-server",
"inbounds": [
{
"tag": "vless-31001",
"line_uuid": "9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d"
}
]
}
8 changes: 8 additions & 0 deletions docs/contracts/fixtures/v1-legacy-upgrade.json
Original file line number Diff line number Diff line change
@@ -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"
}
35 changes: 35 additions & 0 deletions docs/contracts/fixtures/v2-valid-full.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
}
12 changes: 12 additions & 0 deletions docs/contracts/fixtures/v2-valid-minimal.json
Original file line number Diff line number Diff line change
@@ -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"
}
]
}
62 changes: 62 additions & 0 deletions docs/contracts/lattice-singbox-metadata-v2.schema.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://latticenet.github.io/schemas/lattice-singbox-metadata-v2.schema.json",
"title": "lattice.singbox-metadata.v2",
"description": "Sidecar line metadata for sing-box nodes (/etc/sing-box/lattice-metadata.json). Never consumed by sing-box itself. See design-15.",
"type": "object",
"required": ["schema", "node_id", "updated_at", "writer", "inbounds"],
"additionalProperties": true,
"properties": {
"schema": {"const": "lattice.singbox-metadata.v2"},
"node_id": {"type": "string", "minLength": 1, "maxLength": 128},
"node_uuid": {"type": "string", "pattern": "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$"},
"updated_at": {"type": "string", "format": "date-time"},
"writer": {"enum": ["lattice-server", "sb"]},
"inbounds": {
"type": "array",
"items": {
"type": "object",
"required": ["tag", "line_uuid"],
"additionalProperties": true,
"properties": {
"tag": {"type": "string", "minLength": 1, "maxLength": 128},
"line_uuid": {"type": "string", "pattern": "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$"},
"line_hash_id": {"type": "string", "pattern": "^line_[0-9a-f]{24}$"},
"comment": {"type": "string", "maxLength": 256},
"chain": {
"type": "object",
"required": ["downstream_line_uuid"],
"additionalProperties": false,
"properties": {
"downstream_line_uuid": {
"anyOf": [
{"type": "null"},
{"type": "string", "pattern": "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$"}
]
},
"downstream_node": {"type": "string", "maxLength": 128}
}
}
}
}
},
"reserved": {
"type": "object",
"required": ["in_config_key", "fields"],
"additionalProperties": false,
"properties": {
"in_config_key": {"const": "_lattice"},
"fields": {
"type": "object",
"required": ["line_uuid", "node_uuid", "line_hash_id"],
"additionalProperties": false,
"properties": {
"line_uuid": {"const": "string"},
"node_uuid": {"const": "string"},
"line_hash_id": {"const": "string"}
}
}
}
}
}
}
1 change: 1 addition & 0 deletions docs/designs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@ new build slice becomes a numbered `iterations/iter-NNN-*.md` (per
| 11 | [VPN Manage migration review](design-11-vpn-manage-plugin-migration-review.md) | Security/architecture review of the Proxy -> vpn-manage migration | Dynamic plugin sections are now supported, but the static Proxy section must remain until vpn-core/Sub-Store replacement surfaces reach parity; Sub-Store should be isolated as an internal service. |
| 13 | [WireGuard + NetGuard plugins](design-13-wireguard-and-netguard-plugins.md) | Split WireGuard and nftables into two signed first-party plugins with security-group-grade graphical control: zones + reusable security groups + reality-first authoring/drift, WG networks/topologies/external-device peers, blank-machine bootstrap, and adoption of existing on-box configs | Engine (approvals, plan-hash, watchdog/rollback scaffolding, task executor) stays CORE; the plugins own domain models, compilers-as-providers, RPC interfaces, and dashboard IA via `builtin` views — the proven vpn-core pattern. **G1 landed iter-068** (model/store/read-only legacy views); **G2 landed iter-069** (compiler *lowering* into the single `GenerateNFTPlan` renderer so byte-parity with the legacy baseline is structural and mutation-checked, blocking `lockout_risk_ssh` lint, `netguard:admin` write path, plan on the existing `nft` apply path); **W1+W2 landed iter-070** (`BuildTopology` with mesh render-parity + hub-and-spoke; WireGuard apply finally gains the snapshot → dead-man watchdog → selfcheck chain nft always had, plus a `wg syncconf` fast path and a shared watchdog window). Next: G3 reality/drift, G4 dashboard, W1b store/API/discovery. |
| 14 | [Plugin-owned vpn-core control](design-14-plugin-owned-vpn-core-control.md) | Restores the rich sing-box operator experience inside the vpn-core bundle while keeping the base dashboard generic and uninstall-safe | Dashboard owns only sandbox/navigation/step-up; vpn-core owns pages and workflows; server owns RBAC/approval/audit; agent remains the bounded executor. Endpoint, listen bind, and Reality SNI are distinct fields. |
| 15 | [line_uuid, per-line users, chain recognition, Sub-Store integration](design-15-line-uuid-users-chain-substore.md) | Control-plane `line_uuid` identity + sidecar metadata v2, audited per-line user CRUD (adopted-first dual track), declared-edge chain joins, Sub-Store preview/persisted-endpoint/auto-sync | Merge of Sub-Store into vpn-core explicitly rejected; stock sing-box strict parsing means metadata rides a sidecar, never in-config; unique per-user `name` is the join key for auth, routing, and stats. Contract: `docs/contracts/lattice-singbox-metadata-v2.schema.json` |

## Shared architecture (all five honor)

Expand Down
52 changes: 52 additions & 0 deletions docs/designs/adr-004-singbox-stats-grpc.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
# ADR-004: sing-box per-user stats via a vendored gRPC client in the node-agent

> Status: accepted 2026-07-19 (operator decision in the design-15 program).
> Context: design-15 §8, ADR-003 (xray stats transport).

## Context

design-15 §8 requires per-user traffic accounting for adopted sing-box nodes.
sing-box exposes per-user counters (`user>>><name>>>>traffic>>>uplink/downlink`)
only through its **experimental V2Ray Stats API**, which is gRPC
(`experimental.v2rayapi.StatsService`). There is no HTTP alternative on adopted
nodes: the Clash API reports live connections and global traffic only, the SSM
API covers Shadowsocks alone, and s-ui-style connection tracking requires
embedding the core.

The project's standing constraint is "pure Go, zero CGo, every new dep needs an
ADR". The node-agent previously needed no gRPC: ADR-003 chose the `xray api
statsquery` CLI for xray. sing-box has no equivalent CLI stats subcommand.

## Decision

1. The node-agent gains a sing-box stats collector using **gRPC over loopback**
against the core's experimental API. Two new module dependencies:
`google.golang.org/grpc` and `google.golang.org/protobuf`. No CGo is
involved; both are pure Go.
2. The proto is **vendored**: `internal/proxyusage/singboxstats/stats.proto` is
the sing-box upstream definition (service/messages byte-identical, only
`go_package` adjusted), with generated code produced by
`protoc-gen-go`/`protoc-gen-go-grpc` at development time — never at build
or install time. Regeneration is a documented one-liner.
3. The collector is **off by default**, enabled per node with
`LATTICE_SINGBOX_STATS_API=127.0.0.1:8080` (loopback only in this design;
the server-rendered config fragment binds the API to loopback). It issues
read-only `QueryStats(patterns:["user>>>"], reset:false)` calls — counters
stay monotonic; the server keeps its successive-snapshot diffing, exactly
like the xray path.
4. Name reversal stays server-side: the agent reports counters keyed by the
on-box `users[].name` (design-15 §5 `u_<sha256(user|line_uuid)[:16]>`); the
server recomputes the same names from its VpnUser×line index and folds them
into the existing `line_user_bytes` accounting. Unmatched names are counted
as ignored — never reported as zero traffic.

## Consequences

- The agent's dependency tree grows by gRPC+protobuf (pure Go, no CGo). The
attack surface added is a loopback, read-only, optional client.
- If sing-box upstream changes the experimental API, the vendored proto and
the collector fail closed (collector reports an error status; accounting
baselines are never overwritten — same discipline as ADR-003).
- Alternatives rejected: embedding sing-box as a library (largest blast
radius), Clash-API sampling (not cumulative per user), a separate node-side
exporter process (another component to deploy and secure).
Loading