diff --git a/docs/superpowers/plans/2026-06-30-gondwana-p2p-clone-distribute-emancipate.md b/docs/superpowers/plans/2026-06-30-gondwana-p2p-clone-distribute-emancipate.md new file mode 100644 index 00000000..51eb7f58 --- /dev/null +++ b/docs/superpowers/plans/2026-06-30-gondwana-p2p-clone-distribute-emancipate.md @@ -0,0 +1,273 @@ +# Gondwana P2P — Clone · Distribute · Emancipate (consolidated roadmap) + +**Date:** 2026-06-30 +**Status:** Roadmap (consolidation of gondwana Phases 2–4 for the "make secubox.in P2P" goal) +**Scope:** Tie the *already-built* transport (Phase 1) and the *already-built* +trust ledger (`secubox-annuaire`) into one distributed appliance — config & +service distribution, redundancy, multi-node access, multi-peer backup, and +mesh/Tor egress — **without inventing a new architecture** and **without +breaking the existing module pattern**. + +This plan supersedes nothing: it is the bridge that wires together +`2026-06-29-gondwana-phase1-mesh-substrate-design.md` (transport, DONE), +`2026-06-30-annuaire-miroir-trust-substrate-design.md` (the ledger/directory), +and `2026-06-29-secubox-appstore-module-manager-sketch.md` (the federated +distribution UX). Each is grounded in a code audit (2026-06-30). + +--- + +## 0. Verdict & invariants + +**Feasible, incrementally, and most of the substrate already exists.** The hard +distributed-systems part is deliberately scoped *out* of v1. + +**Non-negotiable invariants (these keep it simple and compatible):** + +1. **Federation of sovereign nodes, not a cluster.** No leader election, no + consensus protocol. CAP → choose **AP** (availability + partition + tolerance) + **eventual consistency**. +2. **Single-writer per service + failover.** Each service has a *home* node + that writes; other nodes are read replicas / standby. No active-active + stateful writes in v1. +3. **The ledger is the directory.** Shared mesh state (peers, services, + config, names) lives in the `secubox-annuaire` append-only signed log, + replicated by pull-gossip. Per-node JSON registries stay as the local cache. +4. **Secrets never leave the node.** `/etc/secubox/secrets/*` is node-local; + each node generates its own. Sync moves *config & data*, never secrets. +5. **Unprivileged API + root helper.** The FastAPI runs as `secubox` + (NoNewPrivileges); privileged verbs go through a `sbx-*` root CLI + narrow + sudoers or a root job-queue (proven by `sbx-mesh-up`, `ctl`). +6. **CSPN discipline.** Config writes go through the 4R double-buffer + (shadow → validate → atomic swap → rollback); every distributed action is + appended to `/var/log/secubox/audit.log`; **no `waf_bypass`** — every + exposed vhost still routes through HAProxy → mitmproxy_inspector. + +**User-facing grammar (the four verbs):** + +| Verb | Meaning | Built on | +|------|---------|----------| +| **CLONE** | bring up a fresh node, identity-ready | image build + `node.id` + wg keypair | +| **JOIN** | enrol into the mesh + receive the directory | `master-link` (DONE) + directory pull | +| **DISTRIBUTE** | replicate config / packages / (opt.) data to peers | annuaire ledger + apt mirror + backup | +| **EMANCIPATE** | serve/expose a service from a peer or outward | `secubox-exposure` (Peek/Poke/Emancipate) made mesh-aware | + +--- + +## 1. The linchpin — marry `p2p` registries to the `annuaire` ledger + +This single bridge unlocks everything downstream. It is the concrete form of +the gondwana §8 "distributed directory (DNS-structured ledger)". + +**Today (audited):** +- `secubox-p2p` holds peers in local JSON (`/var/lib/secubox/p2p/wg_mesh.json`, + `peers.json`, `master-link/*.json`) — *not replicated*. +- `secubox-annuaire` is a signed, append-only, **replicable** log with + `did:plc` identities, a `ServiceOffer/Subscription` model, a `can()` + authorization resolver, and **pull federation** (`POST /services/pull` → + fetch a peer's `/services` → verify Ed25519 sig → ingest). + +**The bridge (minimal additions to annuaire):** +- New payload type `NodeRecord` `{did, node_id, boxname, pubkey_wg, mesh_ip, + ddns, endpoint?}` — the Phase-1 identity `(pubkey, node-id, boxname, DDNS)` + becomes a signed ledger record (gondwana Phase 2 "identity records"). +- New payload type `ConfigBlob` `{config_id, publisher_did, scope, version, + content_hash, payload|payload_uri, valid_from, valid_until?}` + + `Op.CONFIG_PUBLISH / CONFIG_REVOKE`. +- Generalize pull federation from `/services` to the whole `/log` (verify each + entry's sig before append, dedup by `entry_hash`, last-writer-wins per id). +- Extend `can()` rights with `config.publish` (already has `service.publish`, + `name.bind`). + +**The bridge (minimal additions to p2p):** +- On `wireguard/enable` and on join, publish this node's `NodeRecord` into the + local annuaire log. +- A thin **`sbx-dir-sync`** loop (systemd timer, runs as `secubox`) pulls each + peer's annuaire `/log` over the wg-mesh and ingests — the gossip layer. + Reuses the existing verify-then-append path; no new crypto. + +**Result:** every node holds the same signed directory of *who exists, what +they run, what config is current, and what name resolves where* — eventually +consistent, partition-tolerant, offline-verifiable. + +--- + +## 2. Architecture (one picture) + +``` + ┌────────────────────────────────────────────┐ + CONTROL PLANE │ secubox-annuaire (signed append-only log) │ + (the directory) │ peers · services · ConfigBlobs · names │ + └──────────────▲───────────────▲──────────────┘ + │ pull-gossip │ publish + ┌──────────────┴───────┐ ┌──────┴───────────────┐ + │ sbx-dir-sync (timer) │ │ p2p / exposure / etc │ + └───────────────────────┘ └──────────────────────┘ + ────────────────────────────────────────────────────────────────────── + DATA PLANE wg-mesh 10.10.0.0/24:51822 (secubox-p2p, DONE) + gk2 .1 (public ingress) ── c3box .2 ── amd64 .3 ── … + ┌───────────┐ ┌────────────┐ ┌──────────────┐ ┌───────────────────┐ + │ apt mirror│ │ backup→peer│ │ HAProxy/nginx │ │ exposure / tor │ + │ (P2P) │ │ (restic) │ │ mesh upstream │ │ mesh + relay egress│ + └───────────┘ └────────────┘ └──────────────┘ └───────────────────┘ +``` + +- **Control plane** = annuaire ledger, replicated by pull-gossip (small, signed, + eventually consistent). +- **Data plane** = wg-mesh transport (done) carrying everything: apt deltas, + backups, service traffic (`..secubox.in` hub-routed), + egress. + +--- + +## 3. Workstreams (each shippable on its own) + +### WS-A — Directory (the substrate) · gondwana Phase 2 (identity records) +- annuaire: `NodeRecord` + `ConfigBlob` payload types; generalized `/log` + pull; `config.publish` right. (~150 LoC, additive, back-compatible.) +- p2p: publish `NodeRecord` on enable/join; `sbx-dir-sync` timer pulling peers + over wg-mesh. (~150 LoC + 1 timer unit.) +- **Ships:** every node holds a signed, replicated peer+service directory. + +### WS-B — Config distribution · DISTRIBUTE(config) +- A `[mesh]` block added (additively) to module TOMLs: + `role = primary|replica|gateway`, `sync = none|config|config+data`. +- Primary signs a `ConfigBlob(scope=, version, payload)` → annuaire. + Replicas pull → write `/etc/secubox/.toml` **via the 4R double-buffer** + (shadow → validate → atomic swap → rollback). Conffile rule respected + (never clobber an operator edit without the new version winning by signed + `version`); secrets excluded. +- Lives either in `secubox-p2p` or a thin new `secubox-mesh-sync` module + (canonical skeleton). **Decision in §6.** +- **Ships:** edit config on the primary → propagates signed, with rollback. + +### WS-C — Package / app distribution · DISTRIBUTE(software) + CLONE-ready +- **P2P-mirrored apt** (appstore §7): each node holds a **GPG-signed** mirror + of `apt.secubox.in`, synced over wg-mesh (rsync / content-addressed deltas). + Install source preference: **local → mesh-peer → upstream gk2/public**. + Verify `InRelease`; never trust an unsigned peer mirror. +- **Federated catalog:** the `secubox-appstore` reads the 128 `debian/ + secubox.yaml` manifests + the directory → mesh-wide view ("install here" vs + "running on c3box"). The directory's first big consumer. +- **Ships:** install a module from the nearest mirror; the repo survives gk2 + being offline (access redundancy). + +### WS-D — Data replication + multi-peer backup · DISTRIBUTE(data, opt.) +- `secubox-backup` (already does tar + S3/SFTP + age/gpg + retention) gains a + **`peer` remote type** = restic/scp over wg-mesh to `10.10.0.x`. Each node + cross-backs-up to ≥1 peer. (~50 LoC, reuses the remote framework.) +- Opt-in live replicas per service (single-writer): SQLite → litestream / + periodic snapshot pull (LXC snapshot or quiesced tar); Postgres (peertube) + → standard streaming replica, 1 primary. +- **Ships:** multi-peer backups (cheap, high value); per-service read replicas + where wanted. + +### WS-E — Routing: LB, failover, multi-node browsing · USABLE +- Make HAProxy/nginx upstreams **directory-driven**: a generator reads the + directory (who-runs-what + health) and renders upstreams that point at the + owning node **over the wg-mesh** (`10.10.0.x`) instead of only `127.0.0.1`. + This is today's #1 gap (all upstreams are localhost-hardcoded). +- Per-node naming `..secubox.in` → DNS to gk2 (sole public + ingress) → HAProxy routes by `Host:` over the mesh to the owner (gondwana + Phase 4). Read-LB across replicas; dead home → failover to a replica. + **mitmproxy_inspector stays in path (no waf_bypass).** +- **Ships:** any client reaches any service from any node; failover; read-LB. + +### WS-F — Egress / Emancipate · EMANCIPATE +- `secubox-exposure` already implements Peek/Poke/**Emancipate** with Tor + + DNS/SSL real and a **Mesh channel that is currently a stub**. Make the Mesh + channel real: emancipating publishes a `ServiceOffer` into the directory and + wires HAProxy/mitmproxy to route to it over the mesh. +- **Relay egress:** `secubox-tor` (real hidden-service lifecycle) gains a + *relay* advertisement in the directory; a node can route its egress through + a peer's Tor/relay → gateway redundancy. Alternative relays + (yggdrasil / i2p / snowflake) slot in later as additional channels. +- **Ships:** emancipate a service to the mesh and/or outward (Tor/relay), + multi-channel, from any node. + +--- + +## 4. The minimal additions (the whole "new code" budget) + +| Component | Addition | Size | Risk | +|-----------|----------|------|------| +| annuaire | `NodeRecord`+`ConfigBlob` types, `/log` pull, `config.publish` | ~150 LoC | low (additive) | +| p2p | publish NodeRecord, `sbx-dir-sync` timer | ~150 LoC | low | +| mesh-sync / p2p | `[mesh]` role field, config apply via 4R | ~120 LoC | medium (writes /etc) | +| backup | `peer` remote type (restic over wg) | ~50 LoC | low | +| exposure | real Mesh channel (publish + route) | ~100 LoC | medium | +| haproxy/nginx | directory-driven upstream generator | **biggest piece** | medium-high | +| appstore | federated catalog + P2P apt mirror | own workstream | medium | + +Everything else is configuration and reuse. No new daemon paradigm; no new +crypto; no consensus engine. + +--- + +## 5. Sequencing (incremental — each step is useful alone) + +- **P0 — Unblock remote peers (operator, ~5 min):** Freebox UDP `51822 → + 192.168.1.200` forward. Until then the mesh joins only from the LAN. +- **P1 — Directory (WS-A):** the substrate everything consumes. *First.* +- **P2 — Config distribution (WS-B):** biggest ROI / lowest risk write path. +- **P3 — Backup-to-peers (WS-D, backups only):** cheap, high value, no service + impact. +- **P4 — Mesh-aware routing (WS-E):** LB + failover + per-node naming → the + "usable" multi-node experience. +- **P5 — P2P apt + federated appstore (WS-C):** distribution + access + redundancy of the repo itself. +- **P6 — Emancipate (WS-F):** real mesh exposure + relay egress. +- **P7 (deferred, hard):** stateful active-active, ZKP/HamCoin (gondwana + Phase 2 GK-HAM is its own track), conflict auto-heal. **Out of v1.** + +--- + +## 6. Open decisions (for the user) + +1. **mesh-sync home:** new `secubox-mesh-sync` package, or fold the sync + loop + `[mesh]` field into `secubox-p2p`? *(Recommend: fold into p2p — it + already owns the mesh + registries; fewer moving parts.)* +2. **Granularity first:** sovereign core only (annuaire, dns, hub, p2p + redundant among themselves), or all services from the start? *(Recommend: + sovereign core first.)* +3. **Config-only vs config+data in v1:** *(Recommend: config-only + backups; + data replicas opt-in per service after P4.)* +4. **Active-active:** confirm we hold the line at single-writer + failover for + v1 (and if any one service truly needs active-active, name it). +5. **Per-node DNS authorship (Phase 4 open question, inherited):** gk2 as an + authoritative `*.secubox.in` zone vs a registrar/provider API. + +--- + +## 7. Landmines (from the audits — the plan must respect these) + +- **`/run/secubox` socket wipe:** any new unit needs + `RuntimeDirectory=secubox` + `RuntimeDirectoryPreserve=yes`; never chown the + shared parent (1777 / 0755). +- **Traversal perms:** `/var/lib/secubox` parent stays `0755`, + `/var/log/secubox` stays `0755`; leaves may be `0750`. +- **Conffile handling:** the sync must not fight dpkg — a hand-edited + `/etc/secubox/*.toml` plus a package upgrade prompts non-interactively and + aborts; signed `version` ordering decides the winner, applied via 4R. +- **Secrets non-transmission:** `/etc/secubox/secrets/*` never syncs. +- **LXC consistency:** quiesce/snapshot before pulling container data. +- **gk2 SPOF:** single public ingress until Phase 4 hub-HA; document it. +- **Federation semantics:** pull-only, last-writer-wins, no auto-heal — fine + under single-writer; surprising under concurrent writers (which we forbid). + +--- + +## 8. Success criteria — what "clonable, distributable, emancipable, usable" means + +1. **Clone:** a fresh node boots, gets a stable `node.id` + wg keypair. +2. **Join:** `sbx-mesh-join ` → handshake + receives the + signed directory. +3. **Distribute:** the node receives current signed config (4R-applied) and can + install modules from the nearest signed mirror; **gk2 offline → installs and + already-running services still work.** +4. **Backup:** the node cross-backs-up to ≥1 peer and can restore from one. +5. **Use:** any LAN client reaches any mesh service via + `..secubox.in`; a dead home service fails over to a + replica; read traffic balances across replicas. +6. **Emancipate:** any node can expose a service to the mesh and/or outward + (Tor / relay), multi-channel, through the WAF. +7. **All in source;** a fresh image reproduces a node that does the above. diff --git a/packages/secubox-annuaire/annuaire/model.py b/packages/secubox-annuaire/annuaire/model.py index c41d20e4..d0a00a47 100644 --- a/packages/secubox-annuaire/annuaire/model.py +++ b/packages/secubox-annuaire/annuaire/model.py @@ -69,6 +69,10 @@ class Op(str, Enum): SERVICE_APPROVE = "service_approve" SERVICE_REJECT = "service_reject" SERVICE_REVOKE_SUB = "service_revoke_sub" + # Gondwana P1 directory (#768): the annuaire is the distributed directory. + NODE_PUBLISH = "node_publish" # signed mesh peer registry entry + CONFIG_PUBLISH = "config_publish" # signed, versioned config distribution + CONFIG_REVOKE = "config_revoke" class ProposalType(str, Enum): @@ -423,6 +427,75 @@ class Subscription(BaseModel): signer_did: Optional[str] = None +# --------------------------------------------------------------------------- +# NodeRecord — the signed mesh peer registry entry (gondwana P1, #768) +# --------------------------------------------------------------------------- + +class NodeRecord(BaseModel): + """A signed record of one mesh node, published into the directory. + + Self-certifying: authored by the node itself (entry.author == did). This is + the replicated form of secubox-p2p's local wg_mesh.json/peers.json — the + Phase-1 identity (pubkey_wg, node_id, boxname, DDNS) becomes a ledger + record (gondwana §8 "distributed directory"). NO secret material here: only + the WireGuard PUBLIC key. The sig covers canonical_bytes(payload_without_sig). + """ + model_config = ConfigDict(extra="forbid") + + did: str = Field(..., pattern=r"^did:plc:[0-9a-f]{32}$") + node_id: str = Field(..., description="stable node id, e.g. 'sb-'") + boxname: str = Field(..., description="human node name, e.g. 'gk2'") + pubkey_wg: str = Field(..., description="WireGuard PUBLIC key (base64) — never the private key") + mesh_ip: str = Field(..., description="assigned mesh address, e.g. '10.10.0.1'") + ddns: str = Field(..., description="name-based reachability, e.g. '.secubox.in'") + endpoint: Optional[str] = Field( + default=None, + description="public host:port if this node is reachable (rendezvous); None for NAT'd satellites", + ) + created_at: str = Field(default_factory=now_rfc3339) + sig: Optional[str] = Field( + default=None, + description="Ed25519 sig over canonical_bytes(payload_without_sig)", + ) + signer_did: Optional[str] = None + + +# --------------------------------------------------------------------------- +# ConfigBlob — signed, versioned config distribution entry (gondwana P1, #768) +# --------------------------------------------------------------------------- + +class ConfigBlob(BaseModel): + """A signed, versioned configuration blob published by a service's home node. + + Self-certifying: authored by the publisher (entry.author == publisher). + `version` is a monotonic integer driving last-writer-wins ordering across + the mesh (single-writer per scope by design). Small configs travel inline + in `payload`; large ones are referenced by `payload_uri` + `content_hash` + (BLAKE2b-256 hex). Secrets are NEVER carried — config only. The sig covers + canonical_bytes(payload_without_sig). + """ + model_config = ConfigDict(extra="forbid") + + config_id: str = Field(..., description="stable id for this config stream, e.g. 'cfg-'") + publisher: str = Field(..., pattern=r"^did:plc:[0-9a-f]{32}$") + scope: str = Field(..., description="what this configures, e.g. a module name 'yacy'") + version: int = Field(..., ge=0, description="monotonic; higher wins (last-writer-wins)") + content_hash: str = Field(..., description="BLAKE2b-256 hex of the canonical config content") + payload: Optional[Dict[str, Any]] = Field( + default=None, description="inline config (small blobs); mutually exclusive with payload_uri" + ) + payload_uri: Optional[str] = Field( + default=None, description="fetch location for large blobs; content verified against content_hash" + ) + valid_from: str = Field(default_factory=now_rfc3339) + valid_until: Optional[str] = None + sig: Optional[str] = Field( + default=None, + description="Ed25519 sig over canonical_bytes(payload_without_sig)", + ) + signer_did: Optional[str] = None + + # --------------------------------------------------------------------------- # LogEntry — the BLAKE2b-chained journal link # --------------------------------------------------------------------------- diff --git a/packages/secubox-annuaire/tests/test_directory.py b/packages/secubox-annuaire/tests/test_directory.py new file mode 100644 index 00000000..7e789f90 --- /dev/null +++ b/packages/secubox-annuaire/tests/test_directory.py @@ -0,0 +1,160 @@ +# SPDX-License-Identifier: LicenseRef-CMSD-1.0 +# Copyright (c) 2026 CyberMind — Gérald Kerma +# Source-Disclosed License — All rights reserved except as expressly granted. +# See LICENCE-CMSD-1.0.md for terms. +""" +SecuBox-Deb :: secubox-annuaire :: tests/test_directory.py +Pytest coverage for the P2P directory primitives (gondwana P1, issue #768). + +The annuaire becomes the distributed directory: alongside service offers it +carries signed NodeRecords (the mesh peer registry) and signed ConfigBlobs +(versioned config distribution). This first slice covers the models + Op enum; +verbs, /log pull federation and the p2p publisher land in later slices. +""" +import pytest +from pydantic import ValidationError + +from annuaire.crypto import did_from_pubkey, generate_keypair +from annuaire.model import ( + ConfigBlob, + NodeRecord, + Op, + now_rfc3339, +) + + +# --------------------------------------------------------------------------- +# Op enum — the new directory ops +# --------------------------------------------------------------------------- + +def test_op_enum_has_directory_members(): + assert Op.NODE_PUBLISH.value == "node_publish" + assert Op.CONFIG_PUBLISH.value == "config_publish" + assert Op.CONFIG_REVOKE.value == "config_revoke" + + +# --------------------------------------------------------------------------- +# NodeRecord — a signed mesh peer registry entry +# --------------------------------------------------------------------------- + +def test_node_record_constructable(): + _priv, pub = generate_keypair() + did = did_from_pubkey(pub) + rec = NodeRecord( + did=did, + node_id="sb-aabbccddeeff", + boxname="gk2", + pubkey_wg="X" * 44, + mesh_ip="10.10.0.1", + ddns="gk2.secubox.in", + endpoint="82.67.100.75:51822", + ) + assert rec.node_id == "sb-aabbccddeeff" + assert rec.mesh_ip == "10.10.0.1" + assert rec.did == did + # endpoint is optional (satellites behind NAT have none) + assert rec.sig is None and rec.signer_did is None + + +def test_node_record_endpoint_optional(): + _priv, pub = generate_keypair() + did = did_from_pubkey(pub) + rec = NodeRecord( + did=did, + node_id="sb-001122334455", + boxname="c3box", + pubkey_wg="Y" * 44, + mesh_ip="10.10.0.2", + ddns="c3box.secubox.in", + ) + assert rec.endpoint is None + + +def test_node_record_rejects_bad_did(): + with pytest.raises(ValidationError): + NodeRecord( + did="not-a-did", + node_id="sb-x", + boxname="x", + pubkey_wg="Z" * 44, + mesh_ip="10.10.0.9", + ddns="x.secubox.in", + ) + + +def test_node_record_forbids_extra_fields(): + _priv, pub = generate_keypair() + did = did_from_pubkey(pub) + with pytest.raises(ValidationError): + NodeRecord( + did=did, + node_id="sb-x", + boxname="x", + pubkey_wg="Z" * 44, + mesh_ip="10.10.0.9", + ddns="x.secubox.in", + secret="leak", # extra fields must be rejected (no accidental secret carriage) + ) + + +# --------------------------------------------------------------------------- +# ConfigBlob — a signed, versioned config distribution entry +# --------------------------------------------------------------------------- + +def test_config_blob_constructable_inline(): + _priv, pub = generate_keypair() + did = did_from_pubkey(pub) + blob = ConfigBlob( + config_id="cfg-abc123", + publisher=did, + scope="yacy", + version=3, + content_hash="b" * 64, + payload={"peer": {"mode": "freeworld"}}, + valid_from=now_rfc3339(), + ) + assert blob.scope == "yacy" + assert blob.version == 3 + assert blob.payload == {"peer": {"mode": "freeworld"}} + assert blob.payload_uri is None + assert blob.valid_until is None + + +def test_config_blob_constructable_by_uri(): + _priv, pub = generate_keypair() + did = did_from_pubkey(pub) + blob = ConfigBlob( + config_id="cfg-big", + publisher=did, + scope="nextcloud", + version=1, + content_hash="c" * 64, + payload_uri="https://gk2.secubox.in/api/v1/annuaire/config/cfg-big/blob", + ) + assert blob.payload is None + assert blob.payload_uri.endswith("/blob") + + +def test_config_blob_version_monotonic_type(): + # version is an int used for last-writer-wins ordering across the mesh. + _priv, pub = generate_keypair() + did = did_from_pubkey(pub) + with pytest.raises(ValidationError): + ConfigBlob( + config_id="cfg-x", + publisher=did, + scope="dns", + version=-1, # must be >= 0 + content_hash="d" * 64, + ) + + +def test_config_blob_rejects_bad_publisher(): + with pytest.raises(ValidationError): + ConfigBlob( + config_id="cfg-x", + publisher="nope", + scope="dns", + version=1, + content_hash="d" * 64, + )