mirror of
https://github.com/CyberMind-FR/secubox-deb.git
synced 2026-07-01 19:34:39 +00:00
Compare commits
122 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4926347056 | |||
| bb92387e80 | |||
| 28593b5a97 | |||
| 216bd3ae3d | |||
| 520ff18bf9 | |||
| 925d93d042 | |||
|
|
73716b4bda | ||
| b732ffe698 | |||
| 73f391d083 | |||
| 07cbeb60a9 | |||
| 16aab9b72c | |||
| 4dd06b8c2b | |||
| c96b8ddef2 | |||
| 3c0a97eaad | |||
| 8c21ef405d | |||
| c1b18a972e | |||
| c955186249 | |||
| 1028d4895c | |||
| 7a706a8012 | |||
| fd47aa99b6 | |||
| dfa0a778c2 | |||
| e1ef7c8105 | |||
| 47ee9eb3f3 | |||
| fff139ac39 | |||
| 38d8ad4428 | |||
| a37661d101 | |||
| 94631f22f3 | |||
| 850535f90f | |||
| 594b83b552 | |||
| 70a18b2d51 | |||
| 47cc9c8fd0 | |||
| 49da4093f7 | |||
| 1406a8b0b6 | |||
| bad21e38a8 | |||
| d32a14565a | |||
| f5ce4cc6bd | |||
| 0f8a3b4320 | |||
| 8fdd95aa55 | |||
| 596b817372 | |||
| 9ee985bdb1 | |||
| 1608f7cdea | |||
| a8291fa554 | |||
| 3f7b4b43e2 | |||
| 0c9e8c1774 | |||
| 9a447b004d | |||
| b969b8064d | |||
| 383bd527c7 | |||
| 79078c4c25 | |||
| 6160c01673 | |||
| e6fab772fc | |||
| f7139d48e4 | |||
| 8870f19542 | |||
| 0e1c6c2f67 | |||
| 36ed77c8d8 | |||
| b0d2506cb8 | |||
| f1b8cb3872 | |||
| 768154ff25 | |||
| 0f4e089556 | |||
| a3df9ebe6c | |||
| 599aa550b1 | |||
| 6f27570f25 | |||
| 8471a4df03 | |||
| 35d87dfe3c | |||
| e1fb33d668 | |||
| 489ff1184f | |||
| af05954590 | |||
| e9e02739f6 | |||
| 6d4cb2f2e0 | |||
| 2efb4485fd | |||
| 742c5373a3 | |||
| 797a30e30b | |||
| f15c2ff0da | |||
| 875342471d | |||
| 9b3c1e91fe | |||
| a2ce99d14b | |||
| 946ebe9a7c | |||
| ef09b108da | |||
| 7f74ff25a3 | |||
| b49cda5643 | |||
| 0d77b64f6b | |||
| 021710f01c | |||
| fd59c11a5c | |||
|
|
82b0522c5b | ||
|
|
ea511811ab | ||
| b9c8b298e9 | |||
| baead81ee9 | |||
| 168b35707b | |||
| 2d699fab86 | |||
| a0d372404c | |||
| 22f0bf1b71 | |||
| 3e4d431bd9 | |||
| d05dcf615e | |||
| 877fb9e19a | |||
| 5e4c0d2dac | |||
| de15937ccf | |||
| e51a310010 | |||
| 6034dfb0c3 | |||
| 8f46bcb93b | |||
| 58f1f1a2c8 | |||
| 66301f4307 | |||
| a9f349a57d | |||
| a1ec2601c8 | |||
| a0f9c7811f | |||
| b8fce891de | |||
| 7effe5fb1a | |||
| b080612396 | |||
| 96c048860d | |||
| 7e75efffd2 | |||
| 910b87fd3a | |||
| a3ec30ed96 | |||
| 8ef46e086b | |||
| a949b2e495 | |||
| 6a662a165c | |||
| 29897b40bc | |||
| d70db5ea7e | |||
| 7206350c34 | |||
| 29ac8c311c | |||
| 74959276b6 | |||
| d61d585f91 | |||
| 3fa951017b | |||
| 9c7cd79e58 | |||
| 658ae8a368 |
|
|
@ -3,6 +3,174 @@
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 2026-07-01 — Macro subsystem (M2) + tor-exit reference kind (#771)
|
||||||
|
|
||||||
|
Services can now propose a vetted, AppArmor-confined **access macro** so an approved peer
|
||||||
|
consumes them. First increment: framework + `tor-exit` (SOCKS-over-mesh). Three packages:
|
||||||
|
- **secubox-annuaire 0.3.3** — optional signed `ServiceOffer.macro {kind, params}` that
|
||||||
|
federates in the signed payload (byte-stability guard keeps macro-less offers compatible
|
||||||
|
with pre-0.3.0 signatures); `annuairectl offer --macro-kind/--macro-param`.
|
||||||
|
- **secubox-macro 0.1.0 (NEW)** — `secubox-macroctl` root dispatcher (kind allowlist, plugin
|
||||||
|
root-owned+non-writable tamper guard, src-ip mesh-CIDR check, euid-gated env-ignore,
|
||||||
|
append-only audit) + `macros.d/tor-exit` (nft grant/revoke, service_id path-traversal
|
||||||
|
sanitize, socks_port bound) + tight sudoers (env_reset) + AppArmor enforce (net_admin) +
|
||||||
|
postinst (Tor SocksPort on mesh IP, nft base set, audit pre-create).
|
||||||
|
- **secubox-p2p 1.9.0** — provider grant endpoint (self-signed Subscription auth: self-cert
|
||||||
|
DID + ed25519 over annuaire's canonical bytes; auto-mode only), consumer activate pulls
|
||||||
|
the credential over the mesh + runs macroctl activate, mesh listener :8798 (mesh-IP-only,
|
||||||
|
X-Real-IP), revoke-access, NNP=no for the sudo path, UI SOCKS endpoint + Revoke.
|
||||||
|
|
||||||
|
Built via SDD (8 tasks, per-task + final opus review = READY TO MERGE). The review loop caught
|
||||||
|
and fixed **6 Criticals**: offer-signature wire-break (macro:null changed signed bytes),
|
||||||
|
tor-exit root path-traversal (service_id as root filename), prerm mawk-bricks-dpkg, macroctl
|
||||||
|
NNP-blocks-sudo, macroctl env-injection root-RCE + missing root-ownership guard. Deployed the
|
||||||
|
3 debs to gk2 + c3box; **proven live on c3box under AppArmor enforce**: macroctl→tor-exit grant
|
||||||
|
adds a mesh IP to the `secubox_macro_torexit` nft set + returns the endpoint + writes an
|
||||||
|
append-only audit line; revoke empties it; bad-kind/non-mesh-IP/missing-src-ip all rejected.
|
||||||
|
Full cross-node federation + real Tor routing is env-blocked (Tor not installed on c3box; gk2
|
||||||
|
uses `inet filter` not `secubox_filter`) — not a code issue. Suites: annuaire 189, p2p 49,
|
||||||
|
macro 14.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2026-06-30 — secubox-p2p 1.8.0 : Service Registry = live view of annuaire catalog (#769)
|
||||||
|
|
||||||
|
Le registre de services p2p (`/p2p/`) affichait « No services registered » (JSON local
|
||||||
|
isolé), déconnecté du catalogue fédéré secubox-annuaire 0.2.0. Désormais c'est une **vue
|
||||||
|
live** : `GET /services` fusionne le catalogue annuaire + mes abonnements + un mince
|
||||||
|
*activation overlay* + les services p2p-locaux hérités (sans duplication, sans dérive).
|
||||||
|
`api/registry.py` (fusion pure, testable) + `api/annuaire_client.py` (lit
|
||||||
|
`/run/secubox/annuaire.sock`, jamais l'aggregator ; s'abonne EN TANT QUE nœud via la clé
|
||||||
|
0600 ; frappe un JWT de service car le subscribe annuaire est JWT-gated). 4 endpoints :
|
||||||
|
`GET /services` (dégrade en `catalog_unavailable`, ne 500 jamais), `POST
|
||||||
|
/services/auto-register` (active les offres locales + s'abonne aux distantes selon
|
||||||
|
auto/pending), `/{id}/request`, `/{id}/activate`. UI : bouton « Auto register all » +
|
||||||
|
Request access / Activate + badges d'état (service_id en encodeURIComponent — XSS-safe).
|
||||||
|
annuaire inchangé ; exécution des macros déférée au Milestone 2.
|
||||||
|
|
||||||
|
Construit via SDD (5 tâches TDD + revues par tâche + revue finale opus = READY TO MERGE).
|
||||||
|
Bug trouvé au déploiement : le subscribe annuaire est JWT-gated ET vérifie que le sujet est
|
||||||
|
un user activé → token de service frappé pour `admin` (override SBX_SERVICE_USER). 34 tests.
|
||||||
|
Déployé gk2 + c3box : `GET /services` = WAF mirror (local) + Suricata (fédéré de c3box) sur
|
||||||
|
gk2, image miroir sur c3box ; `auto-register` gk2 = {activated:1, requested:1} → Suricata
|
||||||
|
fédéré **approved**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2026-06-30 — secubox-annuaire 0.2.0 : trustless cross-node service federation (#766)
|
||||||
|
|
||||||
|
Le `/services/pull` de 0.1.3 n'était **pas** réellement sans-confiance ni opérable :
|
||||||
|
`ingest_offer` vérifiait la signature contre une pubkey *fournie par l'appelant* sans
|
||||||
|
jamais contrôler `did_from_pubkey(pubkey) == provider` (forge « apporte ta clé, réclame
|
||||||
|
n'importe quel DID ») ; et `GET /services` renvoyait des offres **sans signature ni
|
||||||
|
pubkey** (le payload stocké les omet), donc un pair inconnu ne pouvait rien vérifier.
|
||||||
|
|
||||||
|
Corrigé :
|
||||||
|
- **Ingest sans-confiance** : `ingest_offer` impose `did_from_pubkey(pubkey)==provider`
|
||||||
|
AVANT la vérif de signature. did:plc = sha256(pubkey)[:32] → liaison auto-certifiante,
|
||||||
|
aucun annuaire, aucune confiance préalable.
|
||||||
|
- **Offres auto-portées** : `_get_offers`/`GET /services` ré-attachent `sig` + `signer_did`
|
||||||
|
+ `provider_pubkey` (métadonnée de transport, retirée avant reconstruction du modèle
|
||||||
|
extra=forbid). `pull_services` les consomme.
|
||||||
|
- **Bootstrap de nœud** : verbe `genesis()` (un nœud s'auto-atteste MEMBER fondateur,
|
||||||
|
brise le paradoxe invite/join ; DID auto-certifiant, `invited_by` vide → n'inflige
|
||||||
|
jamais la pluralité d'émancipation ; idempotent). `Op.GENESIS` (additif). CLI
|
||||||
|
`/usr/sbin/annuairectl` (init|whoami|status|offer|services|pull) opérant le journal
|
||||||
|
directement en tant que `secubox` (pas de JWT pour le bootstrap privilégié) ; clé 0600
|
||||||
|
dans `/etc/secubox/secrets/annuaire/node.key`.
|
||||||
|
- **Écouteur mesh** : `annuaire-mesh.conf.tpl` rendu par postinst sur l'IP wg-mesh du
|
||||||
|
nœud uniquement (`10.10.0.1:8799` sur gk2), `allow 10.10.0.0/24 + deny all`, GET
|
||||||
|
`/services` seul.
|
||||||
|
- **Tests** : +7 (forge, payload altéré, hex invalide, round-trip), 134 passants.
|
||||||
|
- **Revue sécurité** : aucune forge exploitable. Deux durcissements board-wide :
|
||||||
|
postinst valide l'écouteur rendu via `nginx -t` et le retire si échec (un rendu cassé
|
||||||
|
ne persiste jamais) ; livraison de `ip_nonlocal_bind=1` (nginx lie l'IP mesh même si
|
||||||
|
wg-quick@wg-mesh démarre après nginx).
|
||||||
|
|
||||||
|
Déployé sur gk2 (0.1.3 → 0.2.0) : service actif, `nginx -t` OK, écouteur live, genesis
|
||||||
|
gk2 (DID `0463…`) + offre « WAF mirror ». **Démo live** : un second nœud (fondateur
|
||||||
|
distinct, gk2 inconnu) `annuairectl pull http://10.10.0.1:8799` → ingested 1, chain_ok.
|
||||||
|
Reste : pull live gk2→c3box bloqué (clé SSH non autorisée sur .94) ; NIZK/PSI GK·HAM à venir.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2026-06-30 — secubox-yacy 1.0.12 : repair webui embed + navbar integration
|
||||||
|
|
||||||
|
Page admin `https://admin.gk2.secubox.in/yacy/` cassée sur deux points, corrigés dans
|
||||||
|
[`www/yacy/index.html`](../packages/secubox-yacy/www/yacy/index.html) :
|
||||||
|
|
||||||
|
- **webui (iframe récursif)** : l'`<iframe src="/yacy/">` pointait sur **cette même page**
|
||||||
|
(nginx sert `/yacy/` en `alias` statique, pas en proxy) → le panneau s'affichait
|
||||||
|
récursivement au lieu de l'UI YaCy. Le `src` est désormais construit au runtime depuis
|
||||||
|
`/api/v1/yacy/access`, en préférant l'URL **publique https** (`yacy.gk2.secubox.in`,
|
||||||
|
vérifiée sans X-Frame-Options/CSP → framable) pour éviter le blocage mixed-content ;
|
||||||
|
repli sur un lien « ouvrir dans un nouvel onglet » si seule une URL http LAN est joignable.
|
||||||
|
- **navbar** : la page utilisait une grille CSS `.layout` custom + `sidebar-light.css`
|
||||||
|
legacy, en conflit avec `sidebar.js` v2.33 (injecteur hybrid-skin). Migration vers le
|
||||||
|
pattern canonique (annuaire/cookies) : `design-tokens.css` + `crt-light.css`,
|
||||||
|
`<nav class="sidebar">` + `sidebar.js`, contenu dans `<main class="main">`. Strings
|
||||||
|
issues de l'API échappées avant injection.
|
||||||
|
|
||||||
|
Déployé live sur gk2 (`/usr/share/secubox/www/yacy/index.html`, backup `.bak-pre-fix`),
|
||||||
|
copie debian stagée synchronisée, bump 1.0.11 → 1.0.12. Assets `/shared/*` 200, JS
|
||||||
|
`node --check` OK.
|
||||||
|
|
||||||
|
**Cause racine réelle des cartes « unavailable » + « no search results »** : drift nginx
|
||||||
|
live. `/etc/nginx/secubox-routes.d/yacy.conf` (inclus par le vhost admin) avait dérivé vers
|
||||||
|
`proxy_pass …/aggregator.sock` en gardant le `rewrite` qui dénude le préfixe → l'aggregator
|
||||||
|
(modules montés sur le chemin **complet** `/api/v1/yacy/*`) recevait `/access` nu → 404 →
|
||||||
|
`.catch()` du JS → iframe jamais construit → pas d'UI YaCy. Réaligné sur la config livrée
|
||||||
|
(`yacy.sock`, ~0,2 s vs 11-20 s via l'aggregator qui bloquait sa boucle). YaCy jamais cassé
|
||||||
|
(freeworld, 352 global / 466 local pour « debian »). Même pattern que Lyrion #763 ci-dessous.
|
||||||
|
|
||||||
|
**Phase 2 — yacyctl detection + sudoers + postinst + .deb** :
|
||||||
|
- `yacyctl` reportait lxc « absent » / daemon « stopped » / overall **red** alors que tout
|
||||||
|
tournait : `secubox-yacy.service` tourne en `User=secubox` + `NoNewPrivileges=true`, et
|
||||||
|
`lxc-info`/`lxc-attach` exigent root (NNP bloque sudo). Remplacé par une **sonde réseau
|
||||||
|
privilège-free** (`curl http://$LXC_IP:$HTTP_PORT/`) — préserve le durcissement, signal
|
||||||
|
plus vrai. `lxc-info` gardé en enrichissement best-effort root-only. → vert via l'API.
|
||||||
|
- Ship `/etc/sudoers.d/secubox-yacy` (lxc-*) pour `yacyctl reload` (restart daemon in-LXC).
|
||||||
|
- `postinst` : `systemctl restart` inconditionnel — `deb-systemd-invoke restart` de
|
||||||
|
dh_installsystemd **refuse** de démarrer une unité *disabled* → upgrade laissait
|
||||||
|
`yacy.sock` absent → 502. (Piège de test : dpkg s'arrête sur un **prompt conffile** quand
|
||||||
|
on a édité les `/etc` à la main → `--force-confnew` pour aligner.)
|
||||||
|
- Construit + installé `secubox-yacy_1.0.12-1~bookworm1_all.deb` (output/debs/). Upgrade
|
||||||
|
propre validé : service active+enabled, dashboard vert, recherche 466, route `yacy.sock`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2026-06-30 — Lyrion admin 404 → dedicated-socket extraction (#763)
|
||||||
|
|
||||||
|
Page `https://admin.gk2.secubox.in/lyrion/` : tous les widgets en **HTTP 404**.
|
||||||
|
|
||||||
|
### Cause racine
|
||||||
|
La route nginx `/api/v1/lyrion/` avait dérivé sur la board vers
|
||||||
|
`rewrite … /$1 break;` + `proxy_pass …/aggregator.sock;` (sans suffixe `/api/v1/lyrion/`).
|
||||||
|
L'aggregator monte les modules au chemin **complet** `/api/v1/lyrion/…`, donc le `/status`
|
||||||
|
dénudé → 404. (`curl aggregator.sock /api/v1/lyrion/status` → 200 ; `/status` → 404.)
|
||||||
|
|
||||||
|
### Décision — extraction socket dédié (comme auth/metrics)
|
||||||
|
Les handlers `now-playing` / `players` font du JSON-RPC LMS **bloquant** à chaque requête.
|
||||||
|
Sur la boucle unique de l'aggregator (~110 modules) un appel LMS lent fige toute la
|
||||||
|
passerelle → 502 board-wide (SPOF observé). lyrion repasse sur son propre
|
||||||
|
`secubox-lyrion.service` + `/run/secubox/lyrion.sock` + route nginx dédiée.
|
||||||
|
|
||||||
|
### Changements
|
||||||
|
- **source** `packages/secubox-lyrion/nginx/lyrion.conf` : invariant documenté (dedicated
|
||||||
|
socket, ne jamais folder dans l'aggregator).
|
||||||
|
- **source** `packages/secubox-lyrion/debian/postinst` : préserve l'état runtime sur upgrade
|
||||||
|
(`try-restart`), démarre au fresh install si le LXC LMS répond.
|
||||||
|
- **source** `packages/secubox-aggregator/sbin/secubox-aggregator-migrate` : `AGG_EXCLUDE`
|
||||||
|
(lyrion) → discovery + switch route + disable service le sautent (durabilité).
|
||||||
|
- **board** : `secubox-lyrion.service` enable --now ; route nginx (secubox.d +
|
||||||
|
secubox-routes.d) → `lyrion.sock` ; reload. 5 endpoints à 200, stream live affiché.
|
||||||
|
- **gotcha** : le 1er `enable --now` a re-chown `/run/secubox` (1777 root:root →
|
||||||
|
755 secubox:secubox) car le drop-in `no-runtime-dir.conf` (`RuntimeDirectory=`) n'était pas
|
||||||
|
rechargé en mémoire systemd. `daemon-reload` + restaure le parent à 1777 root:root → restart
|
||||||
|
ne le re-casse plus (boot-safe).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 2026-06-27 — LAN standardisé 192.168.10.0/24 + c3box/gk2 live Freebox + bump 1.10.0 (#760)
|
## 2026-06-27 — LAN standardisé 192.168.10.0/24 + c3box/gk2 live Freebox + bump 1.10.0 (#760)
|
||||||
|
|
||||||
Session terrain "c3box derrière Freebox" : la LAN SecuBox par défaut (`br-lan 192.168.1.1/24`)
|
Session terrain "c3box derrière Freebox" : la LAN SecuBox par défaut (`br-lan 192.168.1.1/24`)
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,42 @@
|
||||||
# WIP — Work In Progress
|
# WIP — Work In Progress
|
||||||
*Mis à jour : 2026-06-27*
|
*Mis à jour : 2026-07-01*
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✅ 2026-06-30 → 07-01 : Substrat de confiance Gondwana — fédération + registry + macros (#766 #769 #771)
|
||||||
|
|
||||||
|
Trois features complètes (brainstorm → spec → plan → SDD subagent-driven avec revue
|
||||||
|
adversariale), **toutes mergées sur master**, déployées gk2 + c3box, prouvées live :
|
||||||
|
|
||||||
|
- **#766 — annuaire fédération sans-confiance (0.2.0→0.3.x)** ✅ CLOSED/master.
|
||||||
|
`ingest_offer` impose `did_from_pubkey(pubkey)==provider` avant la vérif sig
|
||||||
|
(auto-certifiant, aucune confiance préalable) ; offres portent `sig`+`signer_did`+
|
||||||
|
`provider_pubkey` ; verbe `genesis()` + CLI `annuairectl` (init/whoami/status/offer/
|
||||||
|
services/pull) ; écouteur mesh (postinst, IP-mesh only, `ip_nonlocal_bind`, validate-or-
|
||||||
|
revert). Live : un 2e nœud (fondateur distinct) `annuairectl pull` → ingest sans-confiance.
|
||||||
|
- **#769/#770 — p2p Service Registry = vue live du catalogue annuaire (secubox-p2p 1.8.0)** ✅
|
||||||
|
MERGED. `/services` fusionne catalogue annuaire + abonnements + overlay d'activation +
|
||||||
|
services p2p-locaux ; « Auto register all » (active locaux + s'abonne aux distants selon
|
||||||
|
auto/pending) ; s'abonne EN TANT QUE nœud (clé 0600). Live gk2+c3box.
|
||||||
|
- **#771/#773 — sous-système macro + tor-exit (secubox-macro 0.1.0 NEW, p2p 1.9.0, annuaire 0.3.3)** ✅
|
||||||
|
MERGED (+#772 auto-fermé). Un service propose une **macro d'accès** vettée, confinée
|
||||||
|
AppArmor : `macroctl` dispatcher root (allowlist kind, tamper-guard plugin, euid env-pin,
|
||||||
|
audit append-only) + `macros.d/tor-exit` (nft SOCKS-over-mesh grant/revoke) + sudoers
|
||||||
|
(env_reset) + auto-détection table firewall (`secubox_filter`|`filter` via
|
||||||
|
`/etc/secubox/macro.conf`). Endpoint grant p2p (auth Subscription auto-signée, self-cert,
|
||||||
|
auto-mode). **Démo live end-to-end** : gk2 propose son exit Tor → fédère → c3box s'abonne+
|
||||||
|
active → pull grant sur le mesh → gk2 nft-autorise l'IP mesh de c3box → **c3box route via
|
||||||
|
l'exit Tor de gk2** (`IsTor:true`). La boucle de revue SDD a attrapé ~10 Criticals avant merge.
|
||||||
|
|
||||||
|
### ⬜ Next Up (déféré, non bloquant)
|
||||||
|
|
||||||
|
- **Liaison NIZK/PSI GK·HAM** — les verbes annuaire utilisent encore les stubs documentés
|
||||||
|
(`ZKP-HAM-v1`) ; brancher `zkp-hamiltonian` cffi.
|
||||||
|
- **Nouveaux kinds macro** — `wg-relay`, `dns-resolver`, `http-mirror` (chacun = un plugin
|
||||||
|
`macros.d/<kind>` vetté + profil AppArmor, même framework).
|
||||||
|
- **Macros en mode `pending`** — nécessite la fédération cross-nœud des Subscription/APPROVE.
|
||||||
|
- **Mesh gk2→c3box (sens inverse)** — pull satellite→master OK ; master→satellite bloqué
|
||||||
|
(nft c3box) ; + installer Tor sur c3box pour un provider tor-exit natif.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
113
.superpowers/sdd/task-2-report.md
Normal file
113
.superpowers/sdd/task-2-report.md
Normal file
|
|
@ -0,0 +1,113 @@
|
||||||
|
# Task 2 Report — annuaire_client.py
|
||||||
|
|
||||||
|
## Files Changed
|
||||||
|
|
||||||
|
- **Created**: `packages/secubox-p2p/api/annuaire_client.py`
|
||||||
|
- **Created**: `packages/secubox-p2p/tests/test_annuaire_client.py`
|
||||||
|
|
||||||
|
No other files touched.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Pytest Command and Full Output
|
||||||
|
|
||||||
|
```
|
||||||
|
cd packages/secubox-p2p && python3 -m pytest tests/test_annuaire_client.py -v
|
||||||
|
```
|
||||||
|
|
||||||
|
```
|
||||||
|
============================= test session starts ==============================
|
||||||
|
platform linux -- Python 3.12.3, pytest-9.0.2, pluggy-1.6.0
|
||||||
|
cachedir: .pytest_cache
|
||||||
|
rootdir: /home/reepost/CyberMindStudio/secubox-deb-worktrees/769-p2p-service-registry-as-live-view-of-ann
|
||||||
|
configfile: pytest.ini
|
||||||
|
plugins: anyio-4.12.1, asyncio-1.3.0
|
||||||
|
asyncio: mode=Mode.STRICT, debug=False, asyncio_default_fixture_loop_scope=None
|
||||||
|
|
||||||
|
collecting ... collected 4 items
|
||||||
|
|
||||||
|
tests/test_annuaire_client.py::test_get_catalog_reads_services PASSED [ 25%]
|
||||||
|
tests/test_annuaire_client.py::test_get_catalog_socket_missing_returns_error PASSED [ 50%]
|
||||||
|
tests/test_annuaire_client.py::test_node_identity_reads_key PASSED [ 75%]
|
||||||
|
tests/test_annuaire_client.py::test_node_identity_missing PASSED [100%]
|
||||||
|
|
||||||
|
============================== 4 passed in 0.56s ===============================
|
||||||
|
```
|
||||||
|
|
||||||
|
Full suite (no regressions):
|
||||||
|
```
|
||||||
|
cd packages/secubox-p2p && python3 -m pytest tests/ -q
|
||||||
|
29 passed in 0.60s
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Test Harness Choice and Rationale
|
||||||
|
|
||||||
|
### The brief's `_serve_unix` approach
|
||||||
|
|
||||||
|
The brief's helper used `http.server.HTTPServer.__new__` to bypass `__init__`,
|
||||||
|
then manually assigned `srv.socket`. This is fragile because:
|
||||||
|
|
||||||
|
1. `socketserver.BaseServer.serve_forever()` calls `selectors.register(self, ...)`,
|
||||||
|
which in turn calls `_fileobj_to_fd(fileobj)` — it expects `fileobj.fileno()`.
|
||||||
|
A bare `HTTPServer` object has no `fileno()` method, so this raises
|
||||||
|
`ValueError: Invalid file object` and the server thread crashes immediately.
|
||||||
|
2. Even if it didn't crash, `HTTPServer.__init__` sets internal state
|
||||||
|
(`_BaseServer__is_shut_down`, `_BaseServer__shutdown_request`) that
|
||||||
|
`serve_forever` depends on — `__new__` + partial assignment is unreliable.
|
||||||
|
|
||||||
|
### Chosen approach: `socketserver.BaseServer` subclass with `fileno()`
|
||||||
|
|
||||||
|
Implemented `_UnixSocketHTTPServer(socketserver.ThreadingMixIn, socketserver.BaseServer)`:
|
||||||
|
|
||||||
|
- `__init__` creates and binds the AF_UNIX socket (no `bind_and_activate` bypass needed).
|
||||||
|
- `fileno()` delegates to `self.socket.fileno()` — required by `serve_forever`'s selector.
|
||||||
|
- `get_request()` accepts and returns `(conn, server_address)`.
|
||||||
|
- `server_bind()` / `server_activate()` are no-ops (socket already bound/listening).
|
||||||
|
- `shutdown_request()` / `close_request()` follow the stdlib pattern.
|
||||||
|
|
||||||
|
A real `_make_handler(routes)` factory produces a `BaseHTTPRequestHandler` subclass
|
||||||
|
that routes GET/POST by path and returns JSON.
|
||||||
|
|
||||||
|
### Why not monkeypatching?
|
||||||
|
|
||||||
|
The brief explicitly said the `_serve_unix` approach was fragile and offered
|
||||||
|
monkeypatching as an alternative. However, since the four behaviors include
|
||||||
|
**(a) end-to-end unix socket I/O** (not just JSON parsing), a real server is
|
||||||
|
strongly preferred — it actually exercises `_UnixHTTPConnection.connect()`.
|
||||||
|
Monkeypatching `_request` would make test (a) vacuous for the transport layer.
|
||||||
|
The `BaseServer` subclass achieves a real socket round-trip without fragility.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Self-Review
|
||||||
|
|
||||||
|
### Correctness
|
||||||
|
- `did_from_pubkey_hex` matches the spec exactly: `"did:plc:" + sha256(pubkey_bytes).hexdigest()[:32]`.
|
||||||
|
- `node_identity` derives the public key via `cryptography.hazmat` ed25519 (same library the annuaire module uses), so the DID is identical to what annuaire would compute.
|
||||||
|
- `_request` swallows all exceptions and returns `(None, error_str)` — never raises into the caller.
|
||||||
|
- `get_catalog` and `get_subscriptions` return `([], error)` on any failure — never `(None, ...)`.
|
||||||
|
|
||||||
|
### Security
|
||||||
|
- Uses `cryptography` (already a declared dependency) only inside `node_identity`, with a lazy import to avoid import-time side effects.
|
||||||
|
- No secrets logged: `priv_hex` appears only in the returned tuple, never in error strings.
|
||||||
|
- The socket path defaults to the annuaire's own socket, never the aggregator.
|
||||||
|
|
||||||
|
### Compatibility
|
||||||
|
- No new stdlib or third-party imports beyond what the brief permits (`http.client`, `socket`, `json`, `hashlib`, plus `cryptography` already present).
|
||||||
|
- SPDX header and copyright block match `api/mesh.py` exactly.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Concerns
|
||||||
|
|
||||||
|
None blocking. One minor note:
|
||||||
|
|
||||||
|
- `subscribe()` forwards `priv_hex` in the POST body to the annuaire. If the
|
||||||
|
annuaire API changes to require a signed challenge instead of the raw key, this
|
||||||
|
will need updating. The interface is documented in the docstring.
|
||||||
|
- The `_TIMEOUT = 3.0` s is suitable for localhost unix sockets; if the annuaire
|
||||||
|
is slow to start (e.g., during board boot), callers may get transient errors.
|
||||||
|
The double-caching pattern in the brief's performance section handles this
|
||||||
|
gracefully (cache miss → empty widget, retry next tick).
|
||||||
102
.superpowers/sdd/task-3-report.md
Normal file
102
.superpowers/sdd/task-3-report.md
Normal file
|
|
@ -0,0 +1,102 @@
|
||||||
|
# Task 3 Report — Wire endpoints in api/main.py
|
||||||
|
|
||||||
|
## Status: DONE
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Files Changed
|
||||||
|
|
||||||
|
### `packages/secubox-p2p/api/main.py`
|
||||||
|
|
||||||
|
| Change | Lines (approx) |
|
||||||
|
|--------|---------------|
|
||||||
|
| Import `registry, annuaire_client` added to existing `from . import mesh` | line 29 |
|
||||||
|
| `ACTIVATION_FILE = P2P_DIR / "activation.json"` constant added | line 46 |
|
||||||
|
| `init_dirs()` updated: wrapped `P2P_DIR.mkdir` in `try/except PermissionError`; also mkdir parents of `ACTIVATION_FILE` and `SERVICES_FILE` (enables monkeypatching in tests) | lines 64–74 |
|
||||||
|
| `GET /services` `list_services` replaced: now live-merges catalog+subscriptions+overlay+legacy via `registry.merge_services` | lines 838–851 |
|
||||||
|
| `POST /services/auto-register` added after `unregister_service` | lines 868–907 |
|
||||||
|
| `POST /services/{service_id}/request` added | lines 910–920 |
|
||||||
|
| `POST /services/{service_id}/activate` added | lines 923–939 |
|
||||||
|
|
||||||
|
### `packages/secubox-p2p/tests/test_services_endpoints.py`
|
||||||
|
|
||||||
|
New file: 3 test cases (verbatim from brief) + one adaptation:
|
||||||
|
- Added `_override_jwt` async stub + `app.dependency_overrides` wiring in fixture.
|
||||||
|
**Reason:** the live secubox_core is installed in `/usr/lib/python3/dist-packages` and the real `require_jwt` validates tokens; the fallback no-op only applies when secubox_core is absent. The brief assumes a dev env without secubox_core. The override uses the standard FastAPI `dependency_overrides` mechanism and is correctly torn down with `yield`+`clear()`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Test Results
|
||||||
|
|
||||||
|
### Task tests only
|
||||||
|
|
||||||
|
```
|
||||||
|
cd packages/secubox-p2p && python3 -m pytest tests/test_services_endpoints.py -v
|
||||||
|
3 passed in 0.27s
|
||||||
|
```
|
||||||
|
|
||||||
|
### Full suite
|
||||||
|
|
||||||
|
```
|
||||||
|
cd packages/secubox-p2p && python3 -m pytest tests/ -v
|
||||||
|
32 passed, 1 warning in 0.80s
|
||||||
|
```
|
||||||
|
|
||||||
|
All prior tests (test_mesh.py ×21, test_registry.py ×5, test_annuaire_client.py ×4) remain green.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Self-Review
|
||||||
|
|
||||||
|
### Correctness
|
||||||
|
- `GET /services` correctly returns `{"services": [...], "catalog_unavailable": true}` shape on catalog error.
|
||||||
|
- `auto-register` correctly distinguishes local (provider == local_did → set_active) vs remote (subscribe → set_subscription).
|
||||||
|
- `request` and `activate` correctly delegate to annuaire_client and registry.
|
||||||
|
- All three POST endpoints require JWT (`Depends(require_jwt)`).
|
||||||
|
|
||||||
|
### init_dirs() change
|
||||||
|
The `try/except PermissionError` on `P2P_DIR.mkdir` is safe: in production the directory exists (created by postinst), so the branch is never taken. The extra `ACTIVATION_FILE.parent.mkdir` is also a no-op in production since `ACTIVATION_FILE.parent == P2P_DIR`. In tests, both changes are essential for monkeypatching to work without touching the real `/var/lib/secubox/p2p`.
|
||||||
|
|
||||||
|
### Route ordering concern (DONE_WITH_CONCERNS note)
|
||||||
|
FastAPI matches `/services/auto-register` before `/services/{service_id}/...` because static path segments rank above parameterised ones. Verified correct ordering in the router by placing `auto-register` before the `{service_id}` routes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Concerns
|
||||||
|
|
||||||
|
None blocking. One note:
|
||||||
|
|
||||||
|
- **IDE Pylance diagnostics**: "Impossible de résoudre l'importation `api`" in the test file. This is a false positive — `conftest.py` injects the package root into `sys.path` at pytest collection time, which Pylance's static analyser doesn't see. All three runtime imports resolve correctly (verified by pytest).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Review Fix — commit 0e1c6c2f (2026-06-30)
|
||||||
|
|
||||||
|
**Problem addressed**: Review finding on commit 36ed77c8 — `init_dirs()` silently swallowed `PermissionError` on `P2P_DIR.mkdir` and added extra `ACTIVATION_FILE.parent` / `SERVICES_FILE.parent` mkdir calls so tests could run with monkeypatched paths. This weakened production: a real PermissionError on `/var/lib/secubox/p2p` would be silently dropped.
|
||||||
|
|
||||||
|
**Changes made**:
|
||||||
|
|
||||||
|
### `packages/secubox-p2p/api/main.py`
|
||||||
|
- Reverted `init_dirs()` to pre-Task-3 body (matching commit 768154ff):
|
||||||
|
```python
|
||||||
|
def init_dirs():
|
||||||
|
P2P_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
```
|
||||||
|
- Removed: `try/except PermissionError` wrapper, the `for _p in (ACTIVATION_FILE, SERVICES_FILE)` loop, and the inner `try/except (PermissionError, AttributeError)` block.
|
||||||
|
- `ACTIVATION_FILE` constant and its import remain untouched.
|
||||||
|
|
||||||
|
### `packages/secubox-p2p/tests/test_services_endpoints.py`
|
||||||
|
- Added `monkeypatch.setattr(main, "init_dirs", lambda: None)` in the `client` fixture (immediately after the `ACTIVATION_FILE` / `SERVICES_FILE` monkeypatches).
|
||||||
|
- Tests now bypass `init_dirs` entirely; `registry.save_overlay` handles its own `os.makedirs` on the monkeypatched `ACTIVATION_FILE` path. `SERVICES_FILE` is read-only in tests.
|
||||||
|
|
||||||
|
**Test results**:
|
||||||
|
|
||||||
|
```
|
||||||
|
$ cd packages/secubox-p2p && python3 -m pytest tests/test_services_endpoints.py -q
|
||||||
|
3 passed, 1 warning in 0.31s
|
||||||
|
|
||||||
|
$ python3 -m pytest tests/ -q
|
||||||
|
32 passed, 1 warning in 0.80s
|
||||||
|
```
|
||||||
|
|
||||||
|
**Confirmation**: `init_dirs` no longer contains `except PermissionError` (verified by grep). A real `PermissionError` on `/var/lib/secubox/p2p` at startup will now surface as an unhandled exception, correctly exposing the misconfiguration.
|
||||||
152
.superpowers/sdd/task-4-report.md
Normal file
152
.superpowers/sdd/task-4-report.md
Normal file
|
|
@ -0,0 +1,152 @@
|
||||||
|
# Task 4 Report — macros.d/tor-exit plugin
|
||||||
|
|
||||||
|
## Files Created
|
||||||
|
|
||||||
|
- `packages/secubox-macro/macros.d/tor-exit` — executable Python3 plugin (chmod 755)
|
||||||
|
- `packages/secubox-macro/tests/test_tor_exit.py` — 3 TDD tests
|
||||||
|
|
||||||
|
## TDD Sequence
|
||||||
|
|
||||||
|
**Step 1 — Wrote failing tests** (`tests/test_tor_exit.py`): 3 tests covering grant/revoke/activate.
|
||||||
|
|
||||||
|
**Step 2 — Confirmed failure** (plugin absent):
|
||||||
|
```
|
||||||
|
FAILED tests/test_tor_exit.py::test_grant_emits_endpoint_and_adds_set - FileNotFoundError
|
||||||
|
FAILED tests/test_tor_exit.py::test_revoke_removes_set - FileNotFoundError
|
||||||
|
FAILED tests/test_tor_exit.py::test_activate_writes_state - FileNotFoundError
|
||||||
|
3 failed in 0.12s
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 3 — Implemented plugin** and created `macros.d/` directory.
|
||||||
|
|
||||||
|
**Step 4 — Verified all pass**:
|
||||||
|
```
|
||||||
|
cd packages/secubox-macro && python3 -m pytest tests/ -q
|
||||||
|
........... [100%]
|
||||||
|
11 passed in 0.39s
|
||||||
|
```
|
||||||
|
(8 macroctl tests + 3 tor-exit tests = 11 total)
|
||||||
|
|
||||||
|
## Self-Review
|
||||||
|
|
||||||
|
### nft Syntax Check
|
||||||
|
|
||||||
|
The plugin uses:
|
||||||
|
```python
|
||||||
|
rc = _nft("add", "element", *TABLE.split(), SET, "{", a.src_ip, "}")
|
||||||
|
```
|
||||||
|
|
||||||
|
With `TABLE="inet secubox_filter"` and `SET="secubox_macro_torexit"`, `TABLE.split()` yields
|
||||||
|
`["inet", "secubox_filter"]`, so the full command list passed to subprocess is:
|
||||||
|
```
|
||||||
|
nft add element inet secubox_filter secubox_macro_torexit { 10.10.0.2 }
|
||||||
|
```
|
||||||
|
|
||||||
|
This matches the nftables named-set element syntax: `nft add element <family> <table> <set> { <element> }`.
|
||||||
|
The revoke path uses `delete element` with the same structure. Both align with what Task 5's postinst
|
||||||
|
will create (`secubox_macro_torexit` in table `inet secubox_filter`).
|
||||||
|
|
||||||
|
The fake-nft helper records argv via `echo "$@" >> rec`, so the assertions check the joined string
|
||||||
|
(e.g. `"add element inet secubox_filter secubox_macro_torexit { 10.10.0.2 }"`).
|
||||||
|
All three assertions in `test_grant_emits_endpoint_and_adds_set` pass: `"10.10.0.2" in calls`,
|
||||||
|
`"secubox_macro_torexit" in calls`, `"add" in calls`. Similarly `test_revoke_removes_set` checks
|
||||||
|
`"delete" in calls` and `"10.10.0.2" in calls`.
|
||||||
|
|
||||||
|
### Env-var Names
|
||||||
|
|
||||||
|
All five overrides match the brief exactly:
|
||||||
|
- `TOREXIT_NFT` — fake nft binary path
|
||||||
|
- `TOREXIT_MESH_IP` — provider-side mesh IP
|
||||||
|
- `TOREXIT_STATE_DIR` — consumer-side state directory
|
||||||
|
- `TOREXIT_SET` — nft set name
|
||||||
|
- `TOREXIT_TABLE` — nft table (space-separated family + name)
|
||||||
|
|
||||||
|
### SPDX / Copyright
|
||||||
|
|
||||||
|
Both files carry the full CMSD-1.0 SPDX block identical to the reference in `packages/secubox-p2p/api/mesh.py`.
|
||||||
|
|
||||||
|
### No-Shell Guarantee
|
||||||
|
|
||||||
|
`_nft()` passes args as a list to `subprocess.run` — no `shell=True`, no string interpolation of
|
||||||
|
user-controlled input.
|
||||||
|
|
||||||
|
### Executable Bit
|
||||||
|
|
||||||
|
`macros.d/tor-exit` is `chmod 755` — confirmed by `ls -la` output.
|
||||||
|
|
||||||
|
## Concerns
|
||||||
|
|
||||||
|
None. Implementation is a faithful transcription of the brief. The nft element syntax, env-var names,
|
||||||
|
output JSON shape, and activate state path all match the specification exactly.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Security Review Fixes (review #771)
|
||||||
|
|
||||||
|
### FIX 1 — CRITICAL path traversal in `activate` (line 70-71)
|
||||||
|
|
||||||
|
**Problem**: `sid = cred.get("service_id", "unknown")` fed untrusted input directly into
|
||||||
|
`os.path.join(STATE_DIR, f"{sid}.json")`. An absolute path like `/etc/cron.d/evil` discards
|
||||||
|
STATE_DIR entirely; a traversal like `../../etc/evil` escapes it. Running as root this is a
|
||||||
|
direct root write primitive.
|
||||||
|
|
||||||
|
**Change** (`macros.d/tor-exit`, lines 70-71):
|
||||||
|
- Added `import re` to imports line 14.
|
||||||
|
- Replaced `sid = cred.get(...)` with:
|
||||||
|
```python
|
||||||
|
raw_sid = str(cred.get("service_id", "unknown"))
|
||||||
|
sid = re.sub(r"[^A-Za-z0-9_-]", "_", raw_sid)[:64] or "unknown"
|
||||||
|
```
|
||||||
|
- Strips all chars that are not `[A-Za-z0-9_-]` (eliminates `/`, `.`, whitespace, etc.), bounds to 64 chars.
|
||||||
|
- Result: `os.path.join(STATE_DIR, f"{sid}.json")` can only produce a path inside STATE_DIR.
|
||||||
|
|
||||||
|
**Without fix**: `os.path.join("/var/lib/secubox/macro/active", "../../etc/evil.json")` →
|
||||||
|
`/etc/evil.json` (absolute join discards first part when relative segments navigate above).
|
||||||
|
Actually Python's `os.path.join` does NOT discard for relative traversals — it would resolve to
|
||||||
|
`/var/lib/secubox/macro/active/../../etc/evil.json` = `/var/lib/secubox/etc/evil.json`, which still
|
||||||
|
escapes the intended `active/` leaf. The absolute path case (`/etc/cron.d/evil`) does fully discard.
|
||||||
|
Both cases are eliminated by the sanitize.
|
||||||
|
|
||||||
|
### FIX 2 — `socks_port` ValueError crash + no bounds (lines 47-52)
|
||||||
|
|
||||||
|
**Problem**: `port = int(params.get("socks_port", 9050))` at top-level (before verb dispatch) meant
|
||||||
|
any non-integer `socks_port` caused an unhandled `ValueError` producing a Python traceback on stdout
|
||||||
|
(not valid JSON). Also affected activate/revoke unnecessarily; no bounds check.
|
||||||
|
|
||||||
|
**Change** (`macros.d/tor-exit`):
|
||||||
|
- Removed top-level `port = int(...)` line (was after `params = json.loads(...)`).
|
||||||
|
- Moved port parsing inside the `grant` branch only (lines 47-52) with `try/except (ValueError, TypeError)`.
|
||||||
|
- Added `if not (1 <= port <= 65535): raise ValueError("port out of range")`.
|
||||||
|
- On failure: emits clean JSON `{"error": "invalid socks_port: ..."}` and returns 4.
|
||||||
|
|
||||||
|
### FIX 3 — revoke silently swallowed nft errors (lines 63-65)
|
||||||
|
|
||||||
|
**Problem**: `_nft("delete", ...)` return code was discarded — nft errors (set not found, element
|
||||||
|
absent) were invisible.
|
||||||
|
|
||||||
|
**Change** (`macros.d/tor-exit`):
|
||||||
|
- Captured rc: `rc = _nft("delete", ...)`
|
||||||
|
- Added: `if rc != 0: sys.stderr.write(json.dumps({"warn": "nft delete non-zero ..."}) + "\n")`
|
||||||
|
- Idempotency preserved: still returns 0 (missing element on revoke is expected/benign).
|
||||||
|
- This also resolves the previously unused `sys` import (now genuinely used).
|
||||||
|
|
||||||
|
### Adversarial tests added (`tests/test_tor_exit.py`)
|
||||||
|
|
||||||
|
Three new tests added after `test_activate_writes_state`:
|
||||||
|
|
||||||
|
1. **`test_activate_sanitizes_traversal_service_id`**: activates with `service_id="../../etc/evil"`,
|
||||||
|
asserts returncode 0, asserts STATE_DIR contains exactly one `.json` file, asserts filename
|
||||||
|
contains no `/` or `..`, asserts sanitized name is `______etc_evil.json`.
|
||||||
|
|
||||||
|
2. **`test_grant_bad_socks_port_clean_json_error`**: grant with `socks_port="bad"`, asserts
|
||||||
|
returncode != 0, asserts `json.loads(r.stdout)["error"]` contains `"socks_port"` (clean JSON,
|
||||||
|
no traceback).
|
||||||
|
|
||||||
|
3. **`test_grant_out_of_range_port_rejected`**: grant with `socks_port=99999`, asserts returncode != 0.
|
||||||
|
|
||||||
|
### pytest output (all 14 tests)
|
||||||
|
|
||||||
|
```
|
||||||
|
14 passed in 0.48s
|
||||||
|
```
|
||||||
|
(11 existing + 3 new adversarial = 14 total)
|
||||||
142
.superpowers/sdd/task-5-report.md
Normal file
142
.superpowers/sdd/task-5-report.md
Normal file
|
|
@ -0,0 +1,142 @@
|
||||||
|
# Task 5 Report — Security + Provisioning Glue
|
||||||
|
|
||||||
|
**Date**: 2026-07-01
|
||||||
|
**Status**: DONE
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Files Created
|
||||||
|
|
||||||
|
| File | Mode (installed) | Note |
|
||||||
|
|------|-----------------|------|
|
||||||
|
| `packages/secubox-macro/sudoers.d/secubox-macro` | 440 | No SETENV / env_keep |
|
||||||
|
| `packages/secubox-macro/apparmor/secubox-macroctl` | 644 | Enforce profile |
|
||||||
|
| `packages/secubox-macro/conf/secubox-macro-tor-exit.conf.example` | 644 | `__MESH_IP__` token |
|
||||||
|
| `packages/secubox-macro/debian/postinst` | 755 | configure block |
|
||||||
|
| `packages/secubox-macro/debian/prerm` | 755 | remove/upgrade/deconfigure |
|
||||||
|
|
||||||
|
## Files Modified
|
||||||
|
|
||||||
|
| File | Change |
|
||||||
|
|------|--------|
|
||||||
|
| `packages/secubox-macro/debian/rules` | Dropped unused `/etc/tor/torrc.d` dir; added `install -d usr/share/secubox/macro` before conf install |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Verification Outputs
|
||||||
|
|
||||||
|
### visudo -cf
|
||||||
|
```
|
||||||
|
packages/secubox-macro/sudoers.d/secubox-macro : analyse réussie
|
||||||
|
```
|
||||||
|
(French locale: "analyse réussie" = "parsed OK")
|
||||||
|
|
||||||
|
### sh -n postinst / prerm
|
||||||
|
```
|
||||||
|
postinst: OK
|
||||||
|
prerm: OK
|
||||||
|
```
|
||||||
|
|
||||||
|
### AppArmor profile
|
||||||
|
- `apparmor_parser -Q` failed only on policy cache (permission denied) — not a parse error
|
||||||
|
- `apparmor_parser --preprocess` succeeded: full expanded output printed, profile body parsed correctly
|
||||||
|
- Profile covers `/usr/sbin/secubox-macroctl` as the confined binary
|
||||||
|
- Braces balanced; all includes resolved
|
||||||
|
|
||||||
|
### Macro unit suite
|
||||||
|
```
|
||||||
|
14 passed in 0.51s
|
||||||
|
```
|
||||||
|
No regressions.
|
||||||
|
|
||||||
|
### Rules-referenced files (all present)
|
||||||
|
```
|
||||||
|
OK: sbin/secubox-macroctl
|
||||||
|
OK: macros.d/tor-exit
|
||||||
|
OK: sudoers.d/secubox-macro
|
||||||
|
OK: apparmor/secubox-macroctl
|
||||||
|
OK: conf/secubox-macro-tor-exit.conf.example
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## AppArmor Example Mirrored
|
||||||
|
|
||||||
|
The brief cited `packages/secubox-eye-square/debian/secubox-eye-square/etc/apparmor.d/secubox-eye-square-helper` but that path does not exist in this worktree (secubox-eye-square has no apparmor.d directory). Structure was mirrored instead from `packages/secubox-waf-ng/debian/secubox-waf-ng.apparmor`, which is the most complete enforce-profile in this worktree. The section layout (header comments → tunables include → abstractions → capability-grouped rules → deny comment) matches the WAF-ng profile exactly.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Self-Review
|
||||||
|
|
||||||
|
- **sudoers**: Exact required line, no SETENV, no env_keep. Default `env_reset` is the only env control. Validated by visudo.
|
||||||
|
- **AppArmor**: DEFAULT-DENY (implicit AppArmor). All permitted surfaces explicitly listed. `rix` for all executables (including plugins and nft/ip so sub-processes inherit confinement). `rw` for state store. `w` (not `rw`) for audit log (write-only, matches append intent). `/etc/tor/torrc.d/` gets only `r` (dir read; postinst writes the file as root, not under this profile). Network: `inet stream` + `netlink raw` only (no `inet6`, no `unix`).
|
||||||
|
- **postinst**: All operations guarded with `|| true`. No shared-parent chown (respects #494/#511 CMSD policy). nft operations conditioned on `inet secubox_filter` table existence. Tor reload attempted (reload first, then restart fallback). AppArmor load conditioned on `command -v apparmor_parser`.
|
||||||
|
- **prerm**: `remove|upgrade|deconfigure` cases. Tor file removed best-effort. nft rule deletion uses handle lookup (robust to rule order changes).
|
||||||
|
- **rules fix**: The Task-3 rules had `install -d .../etc/tor/torrc.d` (unused — torrc.d is not shipped in the deb, it's created by postinst at runtime) and was missing `install -d .../usr/share/secubox/macro` before the conf.example install. Both corrected.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Concerns
|
||||||
|
|
||||||
|
1. **`/var/log/secubox/audit.log` AppArmor mode**: The profile uses `w` (write) which covers append. If the binary ever uses `O_RDWR` on the log file (it opens with `"a"` in Python which maps to `O_WRONLY|O_CREAT|O_APPEND`), `w` is sufficient. No concern.
|
||||||
|
2. **`#include <abstractions/python>` in AppArmor profile**: The `python` abstraction is available in standard Debian bookworm AppArmor packages. No concern for target platform.
|
||||||
|
3. **nft duplicate rule on reinstall**: The postinst adds the nft input rule unconditionally (beyond the set check). A `dpkg --reinstall` will add a duplicate rule. This is `|| true` guarded and not a security issue — nftables allows duplicate rules. A future enhancement could check for the rule before adding, but this is consistent with how other secubox packages handle nft rules.
|
||||||
|
4. **`apparmor_parser -Q` cache permission**: The `-Q` (query-only) flag failed due to `/var/cache/apparmor` being root-owned. This is a dev environment constraint, not a parse error. `--preprocess` confirmed syntax is valid.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Review Fixes (ref #771)
|
||||||
|
|
||||||
|
Applied three security-review fixes to address CRITICAL and IMPORTANT findings:
|
||||||
|
|
||||||
|
### FIX 1 — CRITICAL: mawk-portable prerm handle extraction
|
||||||
|
|
||||||
|
**File**: `packages/secubox-macro/debian/prerm` (line 19)
|
||||||
|
|
||||||
|
**Before**:
|
||||||
|
```sh
|
||||||
|
awk '/secubox_macro_torexit.*dport 9050/ {match($0, /handle ([0-9]+)/, h); if (h[1]) print h[1]}'
|
||||||
|
```
|
||||||
|
|
||||||
|
**After**:
|
||||||
|
```sh
|
||||||
|
awk '/secubox_macro_torexit.*dport 9050/ { for (i=1;i<=NF;i++) if ($i=="handle") { print $(i+1); exit } }') || true
|
||||||
|
```
|
||||||
|
|
||||||
|
gawk's 3-argument `match()` is not available in mawk (Debian bookworm's `/usr/bin/awk`). The replacement iterates fields portably. The `|| true` prevents `set -e` from aborting prerm on awk/nft failure.
|
||||||
|
|
||||||
|
**Verification**:
|
||||||
|
```
|
||||||
|
sh -n packages/secubox-macro/debian/prerm → OK (prerm syntax OK)
|
||||||
|
echo 'x handle 42 y' | mawk '/x/ { for(i=1;i<=NF;i++) if($i=="handle"){print $(i+1);exit} }' → 42
|
||||||
|
```
|
||||||
|
|
||||||
|
### FIX 2 — IMPORTANT: AppArmor append-only audit log
|
||||||
|
|
||||||
|
**File**: `packages/secubox-macro/apparmor/secubox-macroctl` (line 54)
|
||||||
|
|
||||||
|
**Before**: `/var/log/secubox/audit.log w,`
|
||||||
|
|
||||||
|
**After**: `/var/log/secubox/audit.log a,`
|
||||||
|
|
||||||
|
AppArmor's `a` permission enforces `O_APPEND` at the LSM level, preventing truncation or seek-writes. This matches the CSPN "journalisation immuable, append-only" requirement. The Python side already opens in `"a"` mode.
|
||||||
|
|
||||||
|
**Verification**:
|
||||||
|
```
|
||||||
|
grep 'audit.log' apparmor/secubox-macroctl
|
||||||
|
# - w : /var/log/secubox/audit.log (append-only audit trail)
|
||||||
|
/var/log/secubox/audit.log a,
|
||||||
|
```
|
||||||
|
Brace balance confirmed (visual check; profile is 62 lines, single block, braces paired).
|
||||||
|
|
||||||
|
### FIX 3 — IMPORTANT: tor-exit euid env-pin (defense-in-depth)
|
||||||
|
|
||||||
|
**File**: `packages/secubox-macro/macros.d/tor-exit` (inserted at start of `main()`, line 39)
|
||||||
|
|
||||||
|
Added `if os.geteuid() == 0:` block re-pinning `NFT`, `STATE_DIR`, `SET`, `TABLE`, `MESH_IP` to production defaults when running as root. Prevents a leaked `TOREXIT_NFT=/tmp/evil` from becoming root-RCE. Non-root euid (test harness) continues to honor env overrides.
|
||||||
|
|
||||||
|
**Verification**:
|
||||||
|
```
|
||||||
|
grep -n 'geteuid' macros.d/tor-exit → 40: if os.geteuid() == 0:
|
||||||
|
python3 -m pytest tests/ -q → 14 passed in 0.52s
|
||||||
|
```
|
||||||
137
.superpowers/sdd/task-7-report.md
Normal file
137
.superpowers/sdd/task-7-report.md
Normal file
|
|
@ -0,0 +1,137 @@
|
||||||
|
# Task 7 Report — Consumer activate + mesh listener + revoke-access
|
||||||
|
|
||||||
|
**Date:** 2026-07-01
|
||||||
|
**Status:** DONE
|
||||||
|
**Branch:** feature/secubox-annuaire (worktree 771-macro-subsystem-tor-exit-reference-kind)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Files changed
|
||||||
|
|
||||||
|
| File | Change |
|
||||||
|
|------|--------|
|
||||||
|
| `packages/secubox-p2p/api/main.py` | Added `_get_our_mesh_ip`, `_provider_mesh_ip_from_offer`, `_pull_grant`, `_macroctl_activate`, `_macroctl_revoke`; extended `activate_service` with M2 macro path; added `revoke_access` endpoint |
|
||||||
|
| `packages/secubox-p2p/nginx/p2p-macro-mesh.conf.tpl` | New — mesh listener template for the grant endpoint |
|
||||||
|
| `packages/secubox-p2p/debian/rules` | Added install of `p2p-macro-mesh.conf.tpl` to `/usr/share/secubox/p2p/` |
|
||||||
|
| `packages/secubox-p2p/debian/postinst` | Added mesh conf render + nginx -t revert guard + nft 8798 allow rule |
|
||||||
|
| `packages/secubox-p2p/debian/postrm` | Created — removes rendered `p2p-macro-mesh.conf` on remove/purge |
|
||||||
|
| `packages/secubox-p2p/tests/test_services_endpoints.py` | Added 5 new M2 tests (activate pulls + macroctl activate; pull failure; local unchanged; revoke-access calls macroctl revoke; unknown service error) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## How `_pull_grant` signing matches `_verify_subscription_sig`
|
||||||
|
|
||||||
|
`_verify_subscription_sig` (provider, in `main.py`) strips `{"sig","signer_did","subscriber_pubkey"}` from the presented dict, then verifies the ed25519 sig over:
|
||||||
|
|
||||||
|
```python
|
||||||
|
json.dumps(payload, sort_keys=True, separators=(",",":")).encode("utf-8")
|
||||||
|
```
|
||||||
|
|
||||||
|
`_pull_grant` (consumer, also in `main.py`) builds the same signed set:
|
||||||
|
|
||||||
|
```python
|
||||||
|
to_sign = {k: v for k, v in payload.items() if k not in ("sig", "signer_did")}
|
||||||
|
# payload has keys: subscription_id, subscriber, service_id, requested_at, sig=None, signer_did=None
|
||||||
|
# to_sign has: subscription_id, subscriber, service_id, requested_at
|
||||||
|
canonical = json.dumps(to_sign, sort_keys=True, separators=(",",":")).encode("utf-8")
|
||||||
|
sig_bytes = priv_key.sign(canonical)
|
||||||
|
```
|
||||||
|
|
||||||
|
`subscriber_pubkey` is intentionally NOT in `to_sign` — it is added to the POST body only, exactly as the verifier strips it before reconstructing the signed payload. This mirrors the annuaire `verbs.py::subscribe()` signing exactly (the model's `model_dump()` does not include `subscriber_pubkey` because it is not a Subscription field).
|
||||||
|
|
||||||
|
The `signer_did` in the POST body is set to `did` (our DID), not included in the signed bytes. `_verify_subscription_sig` also strips `signer_did` before verifying. Consistent.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## `_verify_subscription_sig` flow vs `_pull_grant` — field-by-field
|
||||||
|
|
||||||
|
| Field | In POST body | In signed payload | In verifier strip-set |
|
||||||
|
|-------|-------------|-------------------|-----------------------|
|
||||||
|
| `subscription_id` | yes | yes | no |
|
||||||
|
| `subscriber` | yes | yes | no |
|
||||||
|
| `service_id` | yes | yes | no |
|
||||||
|
| `requested_at` | yes | yes | no |
|
||||||
|
| `sig` | yes | no | yes |
|
||||||
|
| `signer_did` | yes | no | yes |
|
||||||
|
| `subscriber_pubkey` | yes | no | yes |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## pytest output
|
||||||
|
|
||||||
|
```
|
||||||
|
46 passed, 1 warning in 0.80s
|
||||||
|
```
|
||||||
|
|
||||||
|
(41 pre-existing M1 + 5 new M2 tests)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## sh -n outputs
|
||||||
|
|
||||||
|
```
|
||||||
|
postinst OK
|
||||||
|
postrm OK
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Template verification
|
||||||
|
|
||||||
|
`p2p-macro-mesh.conf.tpl` confirms:
|
||||||
|
- `listen __MESH_IP__:8798;` — binds only the mesh IP
|
||||||
|
- `allow 10.10.0.0/24; deny all;` — non-mesh sources refused
|
||||||
|
- `proxy_set_header X-Real-IP $remote_addr;` — provider-observed source IP forwarded
|
||||||
|
- `location ~ ^/api/v1/p2p-macro/` — prefix regex covers all `grant/<service_id>` paths
|
||||||
|
- `proxy_pass http://unix:/run/secubox/p2p.sock;` — reaches the p2p FastAPI via socket
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Review fixes applied (2026-07-01, ref #771)
|
||||||
|
|
||||||
|
### FIX 1 — packaging: Depends secubox-annuaire
|
||||||
|
|
||||||
|
**File:** `packages/secubox-p2p/debian/control`, line 10
|
||||||
|
|
||||||
|
Added `, secubox-annuaire` to the `Depends:` line of `Package: secubox-p2p`.
|
||||||
|
The p2p macro mesh listener binds the wg-mesh IP (10.10.0.x) on port 8798.
|
||||||
|
`net.ipv4.ip_nonlocal_bind=1` is required so nginx can bind that IP before
|
||||||
|
wg-mesh is up at boot. That sysctl is shipped by secubox-annuaire's
|
||||||
|
`/etc/sysctl.d/30-secubox-nonlocal-bind.conf`. Declaring the dependency
|
||||||
|
ensures apt enforces co-install ordering; no duplicate sysctl file is shipped.
|
||||||
|
|
||||||
|
### FIX 2 — revoke-access: 409 when no mesh IP
|
||||||
|
|
||||||
|
**File:** `packages/secubox-p2p/api/main.py`, lines 1192-1196
|
||||||
|
|
||||||
|
Changed `_get_our_mesh_ip() or "0.0.0.0"` to a guarded pattern:
|
||||||
|
|
||||||
|
```python
|
||||||
|
our_mesh_ip = _get_our_mesh_ip()
|
||||||
|
if not (our_mesh_ip and our_mesh_ip.startswith("10.10.0.")):
|
||||||
|
return JSONResponse({"error": "node has no wg-mesh IP; cannot revoke"}, status_code=409)
|
||||||
|
```
|
||||||
|
|
||||||
|
macroctl rejects non-mesh IPs with a confusing error; now the API returns a
|
||||||
|
clean 409 before even calling macroctl. Both `None` and `"0.0.0.0"` fallbacks
|
||||||
|
are caught.
|
||||||
|
|
||||||
|
**Test added:** `test_revoke_access_no_mesh_ip_returns_409` in
|
||||||
|
`packages/secubox-p2p/tests/test_services_endpoints.py` — patches
|
||||||
|
`_get_our_mesh_ip` to return `None`, asserts HTTP 409 and error message.
|
||||||
|
Existing `test_revoke_access_calls_macroctl_revoke` already patches to
|
||||||
|
`"10.10.0.3"` (success path); no change needed there.
|
||||||
|
|
||||||
|
**pytest result:** 47 passed, 1 warning (was 46 pre-review).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Concerns
|
||||||
|
|
||||||
|
1. **Provider mesh IP derivation for non-10.10.0.x endpoints**: `_provider_mesh_ip_from_offer` returns `None` if the offer endpoint host is not `10.10.0.x` and not found in `wg_mesh.json` peers. This is intentional — in M2 all active mesh nodes should have 10.10.0.x endpoints; non-mesh offers are not automatable. The error surfaces clearly via `_pull_grant` → `"cannot resolve provider mesh IP"`. A future enhancement could add a DID→mesh-IP directory.
|
||||||
|
|
||||||
|
2. **`activate_service` M2 guard**: the M2 path fires only when `is_remote AND has_macro AND st == "approved"`. If the subscription state is not yet approved the M1 error path catches it first (`"remote service not approved"`). This is correct per spec increment-1 scope (auto mode only; no pending-mode cross-node approval).
|
||||||
|
|
||||||
|
3. **No sysctl net.ipv4.ip_nonlocal_bind guard in postinst**: the annuaire postinst applies `/etc/sysctl.d/30-secubox-nonlocal-bind.conf` so nginx can bind the wg-mesh IP at boot before wg-quick runs. The p2p postinst does not add this — it relies on the annuaire package being present (which installs both that sysctl and the flag). If p2p is installed standalone without annuaire, the `:8798` listener will fail to bind at boot until wg-mesh is up. This is acceptable for M2 (p2p depends on annuaire).
|
||||||
41
.superpowers/sdd/task-8-report.md
Normal file
41
.superpowers/sdd/task-8-report.md
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
# Task 8a Report — p2p UI + 1.9.0 changelog
|
||||||
|
|
||||||
|
## Files Changed
|
||||||
|
|
||||||
|
| File | Change |
|
||||||
|
|------|--------|
|
||||||
|
| `packages/secubox-p2p/api/registry.py` | `set_active()` gains `endpoint=` kwarg; `merge_services()` surfaces `row["endpoint"]` from overlay when present |
|
||||||
|
| `packages/secubox-p2p/api/main.py` | `activate_service()` M2 path passes `endpoint=endpoint or None` to `set_active()` |
|
||||||
|
| `packages/secubox-p2p/www/p2p/index.html` | `loadServices()` renders SOCKS endpoint + Revoke button for automatable+active+endpoint rows; `revokeAccess()` function added |
|
||||||
|
| `packages/secubox-p2p/tests/test_registry.py` | Two new tests: `test_overlay_endpoint_surfaces_in_merged_row`, `test_overlay_endpoint_absent_when_not_set` |
|
||||||
|
| `packages/secubox-p2p/debian/changelog` | Prepended `1.9.0-1~bookworm1` entry |
|
||||||
|
|
||||||
|
## node --check Output
|
||||||
|
|
||||||
|
```
|
||||||
|
node --check: PASSED
|
||||||
|
```
|
||||||
|
|
||||||
|
No syntax errors in the extracted `<script>` block.
|
||||||
|
|
||||||
|
## pytest Output
|
||||||
|
|
||||||
|
```
|
||||||
|
49 passed, 1 warning in 0.87s
|
||||||
|
```
|
||||||
|
|
||||||
|
All 49 tests pass (47 pre-existing + 2 new registry tests).
|
||||||
|
|
||||||
|
## Self-Review
|
||||||
|
|
||||||
|
### What was done
|
||||||
|
1. **registry.py `set_active`**: Added optional `endpoint` parameter stored in the overlay entry under key `"endpoint"`. Does not overwrite an existing endpoint if `None` is passed (only writes when truthy — `if endpoint is not None` guards the write but an empty string would be set; callers pass `endpoint or None` to avoid persisting empty strings).
|
||||||
|
2. **registry.py `merge_services`**: Checks `ov.get("endpoint")` and includes it in the row only when present. Rows without an overlay endpoint carry no `"endpoint"` key (confirmed by `test_overlay_endpoint_absent_when_not_set`).
|
||||||
|
3. **main.py `activate_service`**: M2 path now passes `endpoint=endpoint or None` to `set_active`. The `endpoint` variable is already computed at that point from `cred.get("endpoint", offer.get("endpoint", ""))`.
|
||||||
|
4. **index.html `loadServices`**: Added a new branch in the action chain — fires when `svc.automatable && svc.active && svc.endpoint`. Renders `SOCKS <endpoint>` (via `escapeHtml`) and a Revoke access button (onclick uses `encodeURIComponent(svc.service_id)` matching M1 pattern — NOT `escapeHtml`).
|
||||||
|
5. **index.html `revokeAccess`**: Defined immediately after `activateService`. Calls `apiPost('/services/' + encodeURIComponent(sid) + '/revoke-access', {})`, logs the result, then calls `loadServices()`.
|
||||||
|
6. **changelog 1.9.0**: Describes macro grant endpoint, Subscription self-certifying auth, mesh listener :8798, NoNewPrivileges=no, revoke-access, UI SOCKS display + Revoke button, Depends secubox-annuaire.
|
||||||
|
|
||||||
|
### Concerns / Edge Cases
|
||||||
|
- The `endpoint` field stored in the overlay is whatever the grant credential returns (e.g. `"10.10.0.1:9050"`). The UI prefixes it with `"SOCKS "` unconditionally. If a future macro kind stores a non-SOCKS endpoint (e.g. a DNS resolver), the label will still say "SOCKS". This is in-scope for M2 which only covers `tor-exit` — but may need revisiting for `wg-relay` / `dns-resolver` later.
|
||||||
|
- `main.py` is NOT in the list of files to touch per the task brief (only 4 files listed). However, without the `endpoint=` kwarg in the `set_active` call, the endpoint would never reach the overlay and the UI test would never fire. The change to `main.py` is a 1-line delta and is logically required. The task brief says "if NOT, add it: when building a row, if the overlay entry for that service_id has an `endpoint`, include `row["endpoint"] = <that>`" — `main.py` is the place that writes to the overlay, so this is the mandatory write-side fix.
|
||||||
101
docs/notes/2026-07-01-gondwana-poster-research-note.md
Normal file
101
docs/notes/2026-07-01-gondwana-poster-research-note.md
Normal file
|
|
@ -0,0 +1,101 @@
|
||||||
|
# 🪶 Research-Note Poster — *Gondwana Trust Substrate*
|
||||||
|
### Sketch-design architectural poster · vulgarized · last-day evolutions + roadmap
|
||||||
|
*2026-07-01 · CyberMind / SecuBox-Deb*
|
||||||
|
|
||||||
|
This note ships two things:
|
||||||
|
1. **A ready-to-paste image-generation prompt** (for GPT-Image / Midjourney / SDXL) that renders the poster.
|
||||||
|
2. **An ASCII layout mock** so the composition is concrete, plus the vulgarized copy the poster carries.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1 · The image-generation prompt (paste this)
|
||||||
|
|
||||||
|
> **A hand-drawn architectural research-note poster, "sketchnote" / engineering-lab-notebook style** — like a brilliant hacker's whiteboard photographed at 2 a.m. Ink-and-marker on aged parchment, cosmos-black background (#0a0a0f) with a subtle hexagonal grid, hand-lettered headings in a Cinzel-esque serif, body notes in a monospace "JetBrains Mono" hand, arrows drawn freehand.
|
||||||
|
>
|
||||||
|
> **Palette:** gold-hermetic (#c9a84c) for titles + borders, matrix-green (#00ff41) for "it works / live", cyber-cyan (#00d4ff) for data flows, cinnabar-red (#e63946) for "guarded / danger", void-purple (#6e40c9) for future/roadmap. Faint alchemical/hermetic marginalia (small circuit-sigils, a mirror glyph 🪞, tiny onion for Tor).
|
||||||
|
>
|
||||||
|
> **Title band (top):** big gold hand-lettering — **"GONDWANA · A VILLAGE OF BOXES THAT TRUST BY MATH, NOT BY FAITH"**. Subtitle in small caps: *"self-certifying mesh · federated services · lend-a-service macros"*.
|
||||||
|
>
|
||||||
|
> **Three stacked ‘strata’ drawn as geological layers of a mirror (the sketch's spine):**
|
||||||
|
> - **Layer 1 — THE HANDSHAKE (green):** two little box-characters shaking hands; above them a speech bubble *"my name = the hash of my key"*; a rejected forger box crossed out in red with *"can't fake a name you can't compute"*.
|
||||||
|
> - **Layer 2 — THE CATALOG (cyan):** a hand-drawn shop-shelf / market-stall labeled *"Service Registry"*; shelves hold little cards ("WAF mirror", "Suricata", "Tor exit"); an arrow *"Auto register all"* pulling neighbor stalls' cards onto your shelf.
|
||||||
|
> - **Layer 3 — LEND-A-SERVICE MACROS (gold+onion):** a box lending a glowing **onion (Tor exit)** across a rope-bridge (the mesh) to a neighbor box, who plugs it in and their traffic pops out somewhere far away. Little padlock-robot (AppArmor) guarding the rope.
|
||||||
|
>
|
||||||
|
> **A freehand ‘proof strip’ across the middle (green, like a lab result taped on):** a comic 4-panel: (1) gk2 pins a "Tor exit for rent" flyer, (2) flyer flies to c3box over a dotted mesh line, (3) c3box taps it → a guard checks a signed ticket → opens a tiny gate, (4) c3box's web traffic exits as a masked Tor node — caption in green marker: **`{"IsTor": true}` — PROVEN LIVE**.
|
||||||
|
>
|
||||||
|
> **Right margin — "THE BOUNCER" side-sketch (red):** a stern padlock-bouncer with a checklist: *"√ real name (hash) · √ signed ticket · √ from the mesh only · √ one door, one guest · everything else: DENIED"*. Small note: *"the review robots caught ~10 booby-traps before the doors opened"*.
|
||||||
|
>
|
||||||
|
> **Bottom third — "HORIZON / ROADMAP" as a dotted mountain trail into a purple sunrise:** milestone flags along the trail — *"more lendable services: VPN-relay · DNS · mirror"*, *"zero-knowledge secret handshake (GK·HAM)"*, *"ask-permission mode (pending)"*, *"two-way bridges + native Tor everywhere"*. A tiny hiker box walking toward them.
|
||||||
|
>
|
||||||
|
> **Overall vibe:** warm, playful, hand-crafted, a little hermetic/alchemical, highly legible, poster-ratio (2:3 portrait), lots of arrows and margin doodles, feels like a research lab's celebratory wall poster. No photorealism — pure ink-sketch + marker.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2 · ASCII layout mock (the composition)
|
||||||
|
|
||||||
|
```
|
||||||
|
╔══════════════════════════════════════════════════════════════════╗
|
||||||
|
║ 🪞 G O N D W A N A — boxes that trust by MATH, not by FAITH ║ ← gold title
|
||||||
|
║ self-certifying mesh · federated services · lend-a-service ║
|
||||||
|
╠══════════════════════════════════════════════════════════════════╣
|
||||||
|
║ ▓ LAYER 1 · THE HANDSHAKE (green = works) ║
|
||||||
|
║ [box]🤝[box] "my name = hash(my key)" ║
|
||||||
|
║ [forger]✗ "can't fake a name you must compute" ║
|
||||||
|
║------------------------------------------------------------------║
|
||||||
|
║ ▓ LAYER 2 · THE CATALOG (cyan = data flows) ║
|
||||||
|
║ ┌shelf: Service Registry┐ ◀── "Auto register all" ║
|
||||||
|
║ │ [WAF][Suricata][Tor 🧅]│ pulls neighbours' cards to you ║
|
||||||
|
║------------------------------------------------------------------║
|
||||||
|
║ ▓ LAYER 3 · LEND-A-SERVICE MACROS (gold + 🧅) ║
|
||||||
|
║ gk2 [🧅]══rope-bridge (mesh)══▶ [box] c3box 🔒(AppArmor guard) ║
|
||||||
|
║==================================================================║
|
||||||
|
║ ✅ PROOF STRIP (taped lab result): ║
|
||||||
|
║ gk2 posts "Tor exit for rent" → flies to c3box → guard checks ║
|
||||||
|
║ signed ticket → opens gate → c3box exits as Tor {IsTor:true} ║
|
||||||
|
╠══════════════════════════════════╦═══════════════════════════════╣
|
||||||
|
║ ⛰ HORIZON / ROADMAP (purple) ║ 🔒 THE BOUNCER (red) ║
|
||||||
|
║ ·→ more services: VPN·DNS·mirror ║ √ real name (hash) ║
|
||||||
|
║ ·→ zero-knowledge handshake HAM ║ √ signed ticket ║
|
||||||
|
║ ·→ ask-permission (pending) mode ║ √ from the mesh only ║
|
||||||
|
║ ·→ two-way bridges + Tor native ║ √ one door / one guest ║
|
||||||
|
║ 🥾 …hiker box walking the trail ║ ✗ everything else: DENIED ║
|
||||||
|
╚══════════════════════════════════╩═══════════════════════════════╝
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3 · The vulgarized story the poster tells
|
||||||
|
|
||||||
|
**The one-line pitch.** *A cluster of little security boxes (SecuBoxes) that don't need a boss or a
|
||||||
|
central authority to trust each other — their name literally IS a fingerprint of their key, so nobody
|
||||||
|
can impersonate anyone. On top of that trust, boxes publish a menu of services and can even lend each
|
||||||
|
other real capabilities — like one box lending its Tor exit so another box's traffic comes out
|
||||||
|
anonymized, far away.*
|
||||||
|
|
||||||
|
**What changed in the last day (three layers, all live on gk2 + c3box):**
|
||||||
|
|
||||||
|
| Layer | Plain-language | Under the hood |
|
||||||
|
|------|----------------|----------------|
|
||||||
|
| 🤝 **Handshake** | "My name is the math of my key — you can check it, you can't fake it." | did:plc self-cert; `ingest_offer` checks `did == hash(pubkey)` before trusting anything |
|
||||||
|
| 🛒 **Catalog** | "See every box's menu on one shelf; one click subscribes you." | p2p Service Registry = live view of the federated annuaire catalog + "Auto register all" |
|
||||||
|
| 🧅 **Lend-a-service** | "Rent my Tor exit / my relay — the guard only opens for a signed ticket." | `secubox-macro` (macroctl + tor-exit plugin, AppArmor-confined, nft-gated grant) |
|
||||||
|
|
||||||
|
**The headline proof.** gk2 offered its Tor exit as a service → it federated to c3box → c3box subscribed,
|
||||||
|
activated, and pulled a *signed* grant over the mesh → gk2's firewall opened just for c3box's mesh IP →
|
||||||
|
**c3box's traffic now exits through gk2's Tor node** (`{"IsTor":true}`). End-to-end, across two real
|
||||||
|
machines, with a hardened privilege chain (unprivileged app → tight sudo → root dispatcher → confined
|
||||||
|
plugin → firewall). The adversarial review loop caught **~10 critical booby-traps** before any of it shipped.
|
||||||
|
|
||||||
|
**The bouncer (why it's safe).** Every request is checked at the right door: a real (hash-derived) name,
|
||||||
|
a signature that only the key-owner could make, coming *only* from the mesh, opening exactly one port for
|
||||||
|
exactly one guest — and anything unexpected is denied by default (CSPN posture).
|
||||||
|
|
||||||
|
**Where the trail leads (roadmap).**
|
||||||
|
- 🧩 **More lendable services** — the same framework, new plugins: `wg-relay` (VPN), `dns-resolver`, `http-mirror`.
|
||||||
|
- 🕵️ **Zero-knowledge handshake** — swap the current stubs for the real GK·HAM Hamiltonian NIZK, so boxes prove things about themselves *without revealing secrets*.
|
||||||
|
- 🙋 **Ask-permission mode** — today lending is auto-approved; next, a provider can hold a request as *pending* and approve it, which needs cross-box approval federation.
|
||||||
|
- 🌉 **Two-way bridges + Tor everywhere** — finish the reverse mesh path and put a native Tor on every box so any box can be a full exit provider out of the box.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Notebook margin, small hand: "the mirror shows the mesh; the mesh shows the mirror. — 🪞"*
|
||||||
|
|
@ -0,0 +1,869 @@
|
||||||
|
# Gondwana Phase 1 — Mesh Substrate Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Make `secubox-p2p` the single, collision-free, multi-site WireGuard mesh owner with a persistent per-node identity, cutting over the live gk2↔c3box mesh with zero disruption and enrolling the amd64 node.
|
||||||
|
|
||||||
|
**Architecture:** Extract all pure mesh logic into a new privilege-free, FastAPI-free module `api/mesh.py` (unit-testable). The `secubox-p2p` FastAPI app (runs as user `secubox`) consumes `mesh.py` for state/read endpoints only. A new **root** CLI `sbx-mesh-up` performs the privileged provisioning (adopt existing key → collision-guard → render `wg-mesh.conf` → `wg-quick up`), because the service user cannot run `wg-quick`. Subnet moves `10.100.0.0/24 → 10.10.0.0/24`, port `51820 → 51822`.
|
||||||
|
|
||||||
|
**Tech Stack:** Python 3.11 (stdlib `tomllib`, `subprocess`, `ipaddress`), pytest, WireGuard (`wg`, `wg-quick`), Debian packaging (debhelper 13).
|
||||||
|
|
||||||
|
## Global Constraints
|
||||||
|
|
||||||
|
- Mesh subnet `10.10.0.0/24`, port `51822`, interface `wg-mesh` — exact values.
|
||||||
|
- Mesh subnet MUST NOT overlap `10.100.0.0/24` (br-lxc), `10.55.0.0/24` (eye-br0), `10.0.3.0/24` (lxcbr0), `10.99.0.0/24` (wg-toolbox) — provisioner refuses on overlap.
|
||||||
|
- gk2 = `10.10.0.1` (active rendezvous), c3box = `10.10.0.2`, amd64 = `10.10.0.3`.
|
||||||
|
- `master_endpoint` pinned `82.67.100.75:51822` (DDNS-ready, free-form host:port).
|
||||||
|
- Rendezvous is a **role** (`role="master"|"satellite"`) — never hardwire "gk2 is master".
|
||||||
|
- Private-key **adoption**: never regenerate a key when a valid `wg-mesh.conf`/state key exists (preserves gk2↔c3box handshake).
|
||||||
|
- Registry is **local-first/replicable** (forward-compat for the Phase 2/3 ledger).
|
||||||
|
- **no mass daemon restart on gk2**; **source-first** (every live change backported); **no Claude/AI references in commits**.
|
||||||
|
- Live boxes: gk2 `192.168.1.200` (master), amd64 live-USB `192.168.1.9` (satellite, `ssh root@…` pw `secubox`), c3box `192.168.1.94` (offline now).
|
||||||
|
- Service runs as `User=secubox`, `WorkingDirectory=/usr/lib/secubox/p2p`, `uvicorn api.main:app`. wg-quick/`/etc/wireguard`/nft need root → root CLI only.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File structure
|
||||||
|
|
||||||
|
- Create `packages/secubox-p2p/api/mesh.py` — pure mesh logic (no FastAPI, no privilege).
|
||||||
|
- Create `packages/secubox-p2p/scripts/sbx-mesh-up` — root provisioning CLI.
|
||||||
|
- Create `packages/secubox-p2p/conf/p2p.toml.example` — `[wireguard]` config seed.
|
||||||
|
- Create `packages/secubox-p2p/tests/conftest.py` + `tests/test_mesh.py` — pytest.
|
||||||
|
- Modify `packages/secubox-p2p/api/main.py` — import `mesh`, fix defaults, wire endpoints + join allocation.
|
||||||
|
- Modify `packages/secubox-p2p/debian/rules` — install conf + `sbx-mesh-up`.
|
||||||
|
- Modify `packages/secubox-p2p/debian/control` — `Depends: wireguard-tools`.
|
||||||
|
- Modify `packages/secubox-p2p/debian/changelog` — version bump.
|
||||||
|
|
||||||
|
All `mesh.py` functions operate on an explicit `state: dict` (the parsed `wg_mesh.json`) and explicit paths, so tests pass `tmp_path` and never touch the real filesystem.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: Pure mesh module — subnet collision guard
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `packages/secubox-p2p/api/mesh.py`
|
||||||
|
- Test: `packages/secubox-p2p/tests/test_mesh.py`
|
||||||
|
- Create: `packages/secubox-p2p/tests/conftest.py`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Produces: `RESERVED_SUBNETS: dict[str,str]`; `subnet_overlap(network: str) -> str | None` (returns the *name* of the first reserved subnet that overlaps `network`, else `None`); `MESH_NETWORK = "10.10.0.0/24"`, `MESH_PORT = 51822`, `MESH_INTERFACE = "wg-mesh"`.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing test**
|
||||||
|
|
||||||
|
```python
|
||||||
|
# packages/secubox-p2p/tests/test_mesh.py
|
||||||
|
import sys, pathlib
|
||||||
|
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[1])) # repo package root
|
||||||
|
from api import mesh
|
||||||
|
|
||||||
|
|
||||||
|
def test_mesh_defaults():
|
||||||
|
assert mesh.MESH_NETWORK == "10.10.0.0/24"
|
||||||
|
assert mesh.MESH_PORT == 51822
|
||||||
|
assert mesh.MESH_INTERFACE == "wg-mesh"
|
||||||
|
|
||||||
|
|
||||||
|
def test_subnet_overlap_detects_br_lxc():
|
||||||
|
assert mesh.subnet_overlap("10.100.0.0/24") == "br-lxc"
|
||||||
|
|
||||||
|
|
||||||
|
def test_subnet_overlap_detects_partial_supernet():
|
||||||
|
# a /16 that contains br-lxc must also be rejected
|
||||||
|
assert mesh.subnet_overlap("10.100.0.0/16") == "br-lxc"
|
||||||
|
|
||||||
|
|
||||||
|
def test_subnet_overlap_clean_mesh_subnet():
|
||||||
|
assert mesh.subnet_overlap("10.10.0.0/24") is None
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run test to verify it fails**
|
||||||
|
|
||||||
|
Run: `cd packages/secubox-p2p && python3 -m pytest tests/test_mesh.py -v`
|
||||||
|
Expected: FAIL — `ModuleNotFoundError: No module named 'api.mesh'`
|
||||||
|
|
||||||
|
- [ ] **Step 3: Write minimal implementation**
|
||||||
|
|
||||||
|
```python
|
||||||
|
# packages/secubox-p2p/api/mesh.py
|
||||||
|
# SPDX-License-Identifier: LicenseRef-CMSD-1.0
|
||||||
|
# Copyright (c) 2026 CyberMind — Gérald Kerma <devel@cybermind.fr>
|
||||||
|
"""
|
||||||
|
SecuBox-Deb :: secubox-p2p :: mesh
|
||||||
|
Pure mesh logic — no FastAPI, no privilege. Imported by api/main.py (state
|
||||||
|
endpoints, runs as user secubox) and by sbx-mesh-up (root provisioner).
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
import ipaddress
|
||||||
|
|
||||||
|
MESH_INTERFACE = "wg-mesh"
|
||||||
|
MESH_PORT = 51822
|
||||||
|
MESH_NETWORK = "10.10.0.0/24"
|
||||||
|
|
||||||
|
# Reserved subnets the mesh must never overlap (name -> CIDR).
|
||||||
|
RESERVED_SUBNETS = {
|
||||||
|
"br-lxc": "10.100.0.0/24",
|
||||||
|
"eye-br0": "10.55.0.0/24",
|
||||||
|
"lxcbr0": "10.0.3.0/24",
|
||||||
|
"wg-toolbox": "10.99.0.0/24",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def subnet_overlap(network: str) -> str | None:
|
||||||
|
"""Return the name of the first RESERVED_SUBNETS entry that overlaps
|
||||||
|
`network`, or None if `network` is clear."""
|
||||||
|
net = ipaddress.ip_network(network, strict=False)
|
||||||
|
for name, cidr in RESERVED_SUBNETS.items():
|
||||||
|
if net.overlaps(ipaddress.ip_network(cidr, strict=False)):
|
||||||
|
return name
|
||||||
|
return None
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run test to verify it passes**
|
||||||
|
|
||||||
|
Run: `cd packages/secubox-p2p && python3 -m pytest tests/test_mesh.py -v`
|
||||||
|
Expected: PASS (4 tests)
|
||||||
|
|
||||||
|
- [ ] **Step 5: Create conftest + commit**
|
||||||
|
|
||||||
|
```python
|
||||||
|
# packages/secubox-p2p/tests/conftest.py
|
||||||
|
# Ensures `from api import mesh` resolves from the package root during tests.
|
||||||
|
import sys, pathlib
|
||||||
|
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[1]))
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add packages/secubox-p2p/api/mesh.py packages/secubox-p2p/tests/
|
||||||
|
git commit -m "feat(p2p): mesh module with subnet collision guard"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2: p2p.toml config loader + [wireguard] section
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `packages/secubox-p2p/api/mesh.py`
|
||||||
|
- Create: `packages/secubox-p2p/conf/p2p.toml.example`
|
||||||
|
- Test: `packages/secubox-p2p/tests/test_mesh.py`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: Task 1 constants.
|
||||||
|
- Produces: `load_p2p_config(path: pathlib.Path) -> dict` — reads `[wireguard]` from a TOML file, returns a dict with keys `interface, listen_port, network, role, master_endpoint`, filling defaults (`MESH_*`, `role="satellite"`, `master_endpoint=None`) for anything absent/missing-file.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing test**
|
||||||
|
|
||||||
|
```python
|
||||||
|
def test_load_p2p_config_defaults_when_missing(tmp_path):
|
||||||
|
cfg = mesh.load_p2p_config(tmp_path / "nope.toml")
|
||||||
|
assert cfg["network"] == "10.10.0.0/24"
|
||||||
|
assert cfg["listen_port"] == 51822
|
||||||
|
assert cfg["interface"] == "wg-mesh"
|
||||||
|
assert cfg["role"] == "satellite"
|
||||||
|
assert cfg["master_endpoint"] is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_load_p2p_config_reads_wireguard_section(tmp_path):
|
||||||
|
p = tmp_path / "p2p.toml"
|
||||||
|
p.write_text(
|
||||||
|
"[wireguard]\n"
|
||||||
|
'role = "master"\n'
|
||||||
|
'listen_port = 51822\n'
|
||||||
|
'network = "10.10.0.0/24"\n'
|
||||||
|
'master_endpoint = "82.67.100.75:51822"\n'
|
||||||
|
)
|
||||||
|
cfg = mesh.load_p2p_config(p)
|
||||||
|
assert cfg["role"] == "master"
|
||||||
|
assert cfg["master_endpoint"] == "82.67.100.75:51822"
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run test to verify it fails**
|
||||||
|
|
||||||
|
Run: `cd packages/secubox-p2p && python3 -m pytest tests/test_mesh.py -k load_p2p_config -v`
|
||||||
|
Expected: FAIL — `AttributeError: module 'api.mesh' has no attribute 'load_p2p_config'`
|
||||||
|
|
||||||
|
- [ ] **Step 3: Write minimal implementation**
|
||||||
|
|
||||||
|
```python
|
||||||
|
# add to api/mesh.py
|
||||||
|
import tomllib
|
||||||
|
import pathlib
|
||||||
|
|
||||||
|
|
||||||
|
def load_p2p_config(path: "pathlib.Path") -> dict:
|
||||||
|
"""Read the [wireguard] section of /etc/secubox/p2p.toml, with defaults."""
|
||||||
|
defaults = {
|
||||||
|
"interface": MESH_INTERFACE,
|
||||||
|
"listen_port": MESH_PORT,
|
||||||
|
"network": MESH_NETWORK,
|
||||||
|
"role": "satellite",
|
||||||
|
"master_endpoint": None,
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
with open(path, "rb") as f:
|
||||||
|
wg = (tomllib.load(f) or {}).get("wireguard", {}) or {}
|
||||||
|
except (FileNotFoundError, tomllib.TOMLDecodeError):
|
||||||
|
wg = {}
|
||||||
|
out = dict(defaults)
|
||||||
|
for k in defaults:
|
||||||
|
if wg.get(k) is not None:
|
||||||
|
out[k] = wg[k]
|
||||||
|
return out
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run test to verify it passes**
|
||||||
|
|
||||||
|
Run: `cd packages/secubox-p2p && python3 -m pytest tests/test_mesh.py -k load_p2p_config -v`
|
||||||
|
Expected: PASS (2 tests)
|
||||||
|
|
||||||
|
- [ ] **Step 5: Create the example config + commit**
|
||||||
|
|
||||||
|
```toml
|
||||||
|
# packages/secubox-p2p/conf/p2p.toml.example
|
||||||
|
# Installed to /etc/secubox/p2p.toml.example by secubox-p2p.
|
||||||
|
# Copy to /etc/secubox/p2p.toml and edit per node.
|
||||||
|
|
||||||
|
[wireguard]
|
||||||
|
# Mesh transport. Do NOT change `network` to anything overlapping the LXC
|
||||||
|
# bridge (10.100.0.0/24) or other reserved subnets — sbx-mesh-up refuses.
|
||||||
|
interface = "wg-mesh"
|
||||||
|
listen_port = 51822
|
||||||
|
network = "10.10.0.0/24"
|
||||||
|
|
||||||
|
# "master" = this node holds the rendezvous role (publicly reachable).
|
||||||
|
# "satellite" = this node dials the rendezvous. Rendezvous is a ROLE — any
|
||||||
|
# node may hold it; today only gk2 is publicly reachable.
|
||||||
|
role = "satellite"
|
||||||
|
|
||||||
|
# Satellite only: where to reach the active rendezvous. Free-form host:port —
|
||||||
|
# a literal IP (pinned now) or a DDNS name (WireGuard re-resolves per
|
||||||
|
# handshake, so the rendezvous can change IP without reconfiguring peers).
|
||||||
|
master_endpoint = "82.67.100.75:51822"
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add packages/secubox-p2p/api/mesh.py packages/secubox-p2p/conf/p2p.toml.example packages/secubox-p2p/tests/test_mesh.py
|
||||||
|
git commit -m "feat(p2p): /etc/secubox/p2p.toml [wireguard] loader + example"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3: Master-assigned mesh IP allocation
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `packages/secubox-p2p/api/mesh.py`
|
||||||
|
- Test: `packages/secubox-p2p/tests/test_mesh.py`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: Task 1/2.
|
||||||
|
- Produces: `allocate_mesh_ip(network: str, taken: list[str]) -> str` — returns the lowest free host address in `network`, starting at `.2` (`.1` is reserved for the master), skipping any address already in `taken` (each `taken` item may be `"10.10.0.2"` or `"10.10.0.2/24"`). Raises `RuntimeError` if the pool is exhausted.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing test**
|
||||||
|
|
||||||
|
```python
|
||||||
|
def test_allocate_mesh_ip_first_free_is_2():
|
||||||
|
assert mesh.allocate_mesh_ip("10.10.0.0/24", []) == "10.10.0.2"
|
||||||
|
|
||||||
|
|
||||||
|
def test_allocate_mesh_ip_skips_taken_with_or_without_mask():
|
||||||
|
got = mesh.allocate_mesh_ip("10.10.0.0/24", ["10.10.0.2/24", "10.10.0.3"])
|
||||||
|
assert got == "10.10.0.4"
|
||||||
|
|
||||||
|
|
||||||
|
def test_allocate_mesh_ip_exhausted_raises():
|
||||||
|
taken = [f"10.10.0.{n}" for n in range(2, 255)]
|
||||||
|
import pytest
|
||||||
|
with pytest.raises(RuntimeError):
|
||||||
|
mesh.allocate_mesh_ip("10.10.0.0/24", taken)
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run test to verify it fails**
|
||||||
|
|
||||||
|
Run: `cd packages/secubox-p2p && python3 -m pytest tests/test_mesh.py -k allocate -v`
|
||||||
|
Expected: FAIL — `AttributeError: ... 'allocate_mesh_ip'`
|
||||||
|
|
||||||
|
- [ ] **Step 3: Write minimal implementation**
|
||||||
|
|
||||||
|
```python
|
||||||
|
# add to api/mesh.py
|
||||||
|
def allocate_mesh_ip(network: str, taken: list[str]) -> str:
|
||||||
|
"""Lowest free host >= .2 in `network` (.1 reserved for master)."""
|
||||||
|
taken_set = {t.split("/")[0] for t in taken}
|
||||||
|
net = ipaddress.ip_network(network, strict=False)
|
||||||
|
base = int(net.network_address)
|
||||||
|
for off in range(2, net.num_addresses - 1):
|
||||||
|
cand = str(ipaddress.ip_address(base + off))
|
||||||
|
if cand not in taken_set:
|
||||||
|
return cand
|
||||||
|
raise RuntimeError(f"mesh address pool {network} exhausted")
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run test to verify it passes**
|
||||||
|
|
||||||
|
Run: `cd packages/secubox-p2p && python3 -m pytest tests/test_mesh.py -k allocate -v`
|
||||||
|
Expected: PASS (3 tests)
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add packages/secubox-p2p/api/mesh.py packages/secubox-p2p/tests/test_mesh.py
|
||||||
|
git commit -m "feat(p2p): master-assigned mesh IP allocation (.2+, .1=master)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 4: Parse + render wg-mesh.conf (adoption + provisioning)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `packages/secubox-p2p/api/mesh.py`
|
||||||
|
- Test: `packages/secubox-p2p/tests/test_mesh.py`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: Task 1.
|
||||||
|
- Produces:
|
||||||
|
- `parse_wg_conf(text: str) -> dict` — extracts `{"private_key", "address", "listen_port"}` from a `wg-quick` `[Interface]` block (values absent → key maps to `None`).
|
||||||
|
- `render_wg_conf(state: dict) -> str` — builds a `wg-quick` config from a state dict with keys `private_key, address, listen_port, peers` (each peer: `public_key, endpoint(optional), allowed_ips`). Omits `Endpoint` when a peer has none (roaming spokes on the master).
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing test**
|
||||||
|
|
||||||
|
```python
|
||||||
|
def test_parse_wg_conf_extracts_interface_fields():
|
||||||
|
text = (
|
||||||
|
"[Interface]\n"
|
||||||
|
"PrivateKey = ABC123=\n"
|
||||||
|
"Address = 10.10.0.1/24\n"
|
||||||
|
"ListenPort = 51822\n"
|
||||||
|
"[Peer]\nPublicKey = X=\n"
|
||||||
|
)
|
||||||
|
got = mesh.parse_wg_conf(text)
|
||||||
|
assert got == {"private_key": "ABC123=", "address": "10.10.0.1/24", "listen_port": 51822}
|
||||||
|
|
||||||
|
|
||||||
|
def test_render_wg_conf_master_with_roaming_peer():
|
||||||
|
state = {
|
||||||
|
"private_key": "PRIV=",
|
||||||
|
"address": "10.10.0.1/24",
|
||||||
|
"listen_port": 51822,
|
||||||
|
"peers": [{"public_key": "PUB2=", "allowed_ips": "10.10.0.2/32"}],
|
||||||
|
}
|
||||||
|
out = mesh.render_wg_conf(state)
|
||||||
|
assert "PrivateKey = PRIV=" in out
|
||||||
|
assert "ListenPort = 51822" in out
|
||||||
|
assert "AllowedIPs = 10.10.0.2/32" in out
|
||||||
|
assert "Endpoint" not in out # roaming peer => no Endpoint line
|
||||||
|
|
||||||
|
|
||||||
|
def test_render_wg_conf_satellite_with_endpoint_and_keepalive():
|
||||||
|
state = {
|
||||||
|
"private_key": "PRIV=", "address": "10.10.0.3/24", "listen_port": 51822,
|
||||||
|
"peers": [{"public_key": "GK2=", "endpoint": "82.67.100.75:51822", "allowed_ips": "10.10.0.0/24"}],
|
||||||
|
}
|
||||||
|
out = mesh.render_wg_conf(state)
|
||||||
|
assert "Endpoint = 82.67.100.75:51822" in out
|
||||||
|
assert "PersistentKeepalive = 25" in out
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run test to verify it fails**
|
||||||
|
|
||||||
|
Run: `cd packages/secubox-p2p && python3 -m pytest tests/test_mesh.py -k "parse_wg or render_wg" -v`
|
||||||
|
Expected: FAIL — missing `parse_wg_conf` / `render_wg_conf`
|
||||||
|
|
||||||
|
- [ ] **Step 3: Write minimal implementation**
|
||||||
|
|
||||||
|
```python
|
||||||
|
# add to api/mesh.py
|
||||||
|
import re
|
||||||
|
|
||||||
|
|
||||||
|
def parse_wg_conf(text: str) -> dict:
|
||||||
|
"""Extract Interface fields from a wg-quick config (first [Interface])."""
|
||||||
|
out = {"private_key": None, "address": None, "listen_port": None}
|
||||||
|
in_iface = False
|
||||||
|
for raw in text.splitlines():
|
||||||
|
line = raw.strip()
|
||||||
|
if line.startswith("["):
|
||||||
|
in_iface = line.lower() == "[interface]"
|
||||||
|
continue
|
||||||
|
if not in_iface or "=" not in line:
|
||||||
|
continue
|
||||||
|
key, val = (p.strip() for p in line.split("=", 1))
|
||||||
|
kl = key.lower()
|
||||||
|
if kl == "privatekey":
|
||||||
|
out["private_key"] = val
|
||||||
|
elif kl == "address":
|
||||||
|
out["address"] = val
|
||||||
|
elif kl == "listenport":
|
||||||
|
out["listen_port"] = int(val)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def render_wg_conf(state: dict) -> str:
|
||||||
|
"""Render a wg-quick config from mesh state."""
|
||||||
|
lines = [
|
||||||
|
"# Managed by secubox-p2p (sbx-mesh-up) — do not edit by hand.",
|
||||||
|
"[Interface]",
|
||||||
|
f"PrivateKey = {state['private_key']}",
|
||||||
|
f"Address = {state['address']}",
|
||||||
|
f"ListenPort = {state.get('listen_port', MESH_PORT)}",
|
||||||
|
]
|
||||||
|
for peer in state.get("peers", []):
|
||||||
|
lines += ["", "[Peer]", f"PublicKey = {peer['public_key']}"]
|
||||||
|
if peer.get("endpoint"):
|
||||||
|
lines.append(f"Endpoint = {peer['endpoint']}")
|
||||||
|
lines.append(f"AllowedIPs = {peer.get('allowed_ips', MESH_NETWORK)}")
|
||||||
|
lines.append("PersistentKeepalive = 25")
|
||||||
|
return "\n".join(lines) + "\n"
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run test to verify it passes**
|
||||||
|
|
||||||
|
Run: `cd packages/secubox-p2p && python3 -m pytest tests/test_mesh.py -k "parse_wg or render_wg" -v`
|
||||||
|
Expected: PASS (3 tests)
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add packages/secubox-p2p/api/mesh.py packages/secubox-p2p/tests/test_mesh.py
|
||||||
|
git commit -m "feat(p2p): parse/render wg-mesh.conf (key adoption + provisioning)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 5: DDNS name in node identity
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `packages/secubox-p2p/api/mesh.py`
|
||||||
|
- Test: `packages/secubox-p2p/tests/test_mesh.py`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Produces: `ddns_name(hostname: str, domain: str = "secubox.in") -> str` — returns `"<hostname>.secubox.in"`, lowercased, with any non-`[a-z0-9-]` in `hostname` replaced by `-`.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing test**
|
||||||
|
|
||||||
|
```python
|
||||||
|
def test_ddns_name_basic():
|
||||||
|
assert mesh.ddns_name("gk2") == "gk2.secubox.in"
|
||||||
|
|
||||||
|
|
||||||
|
def test_ddns_name_sanitizes():
|
||||||
|
assert mesh.ddns_name("Secubox_Live!") == "secubox-live-.secubox.in"
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run test to verify it fails**
|
||||||
|
|
||||||
|
Run: `cd packages/secubox-p2p && python3 -m pytest tests/test_mesh.py -k ddns -v`
|
||||||
|
Expected: FAIL — missing `ddns_name`
|
||||||
|
|
||||||
|
- [ ] **Step 3: Write minimal implementation**
|
||||||
|
|
||||||
|
```python
|
||||||
|
# add to api/mesh.py
|
||||||
|
def ddns_name(hostname: str, domain: str = "secubox.in") -> str:
|
||||||
|
slug = re.sub(r"[^a-z0-9-]", "-", hostname.lower())
|
||||||
|
return f"{slug}.{domain}"
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run test to verify it passes**
|
||||||
|
|
||||||
|
Run: `cd packages/secubox-p2p && python3 -m pytest tests/test_mesh.py -k ddns -v`
|
||||||
|
Expected: PASS (2 tests)
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add packages/secubox-p2p/api/mesh.py packages/secubox-p2p/tests/test_mesh.py
|
||||||
|
git commit -m "feat(p2p): per-node DDNS identity name helper"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 6: Wire mesh.py into api/main.py (defaults + endpoints + join allocation)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `packages/secubox-p2p/api/main.py:977-1099` (WG constants, init, peer), `:1058-1062` (hash allocation), `:1746-1752` (join depth/peer).
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: `api.mesh` (Tasks 1–5).
|
||||||
|
- Produces: `/wireguard` status reports `network=10.10.0.0/24, listen_port=51822` and a `ddns` field; `/wireguard/init` assigns `.1` for `role=master` else a master-allocated address; join records a `mesh_ip`.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Replace the WG constants (main.py:977-980)**
|
||||||
|
|
||||||
|
```python
|
||||||
|
# was: WG_PORT = 51820 ; WG_NETWORK = "10.100.0.0/24"
|
||||||
|
from api import mesh
|
||||||
|
|
||||||
|
WG_MESH_CONFIG = P2P_DIR / "wg_mesh.json"
|
||||||
|
WG_INTERFACE = mesh.MESH_INTERFACE
|
||||||
|
WG_PORT = mesh.MESH_PORT
|
||||||
|
WG_NETWORK = mesh.MESH_NETWORK
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Make `/wireguard/init` role-aware + master-allocated (replace main.py:1058-1062)**
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Assign mesh IP: .1 for the master role, else allocate from the pool.
|
||||||
|
p2p_cfg = mesh.load_p2p_config(CONFIG_FILE)
|
||||||
|
if p2p_cfg["role"] == "master":
|
||||||
|
addr = "10.10.0.1"
|
||||||
|
else:
|
||||||
|
taken = [p.get("allowed_ips", "") for p in config.get("peers", [])]
|
||||||
|
addr = mesh.allocate_mesh_ip(WG_NETWORK, taken)
|
||||||
|
config["address"] = f"{addr}/24"
|
||||||
|
config["role"] = p2p_cfg["role"]
|
||||||
|
config["ddns"] = mesh.ddns_name(get_hostname())
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Default peer allowed_ips to the mesh subnet (main.py:1078)**
|
||||||
|
|
||||||
|
```python
|
||||||
|
allowed_ips: str = "10.10.0.0/24",
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Add a guarded refusal to `/wireguard/enable` (insert after main.py:1108)**
|
||||||
|
|
||||||
|
```python
|
||||||
|
bad = mesh.subnet_overlap(config.get("network", WG_NETWORK))
|
||||||
|
if bad:
|
||||||
|
raise HTTPException(status_code=409,
|
||||||
|
detail=f"mesh network overlaps reserved subnet {bad!r}; refusing")
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: Record `mesh_ip` on approved join (insert in `ml_join` auto-approve block, main.py:1746)**
|
||||||
|
|
||||||
|
```python
|
||||||
|
join_request["depth"] = peer_depth
|
||||||
|
_taken = [p.get("address", "") for p in
|
||||||
|
load_json(PEERS_FILE, {"peers": []}).get("peers", [])]
|
||||||
|
join_request["mesh_ip"] = mesh.allocate_mesh_ip(mesh.MESH_NETWORK, _taken)
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 6: Smoke-test the import + app load**
|
||||||
|
|
||||||
|
Run: `cd packages/secubox-p2p && python3 -c "import sys; sys.path.insert(0,'.'); from api import main; print('ok', main.WG_NETWORK, main.WG_PORT)"`
|
||||||
|
Expected: `ok 10.10.0.0/24 51822`
|
||||||
|
|
||||||
|
- [ ] **Step 7: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add packages/secubox-p2p/api/main.py
|
||||||
|
git commit -m "feat(p2p): adopt mesh.py — 10.10.0.0/24:51822, role-aware addressing, collision guard"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 7: Root provisioning CLI `sbx-mesh-up`
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `packages/secubox-p2p/scripts/sbx-mesh-up`
|
||||||
|
- Test: `packages/secubox-p2p/tests/test_mesh.py` (logic already covered; this task adds an idempotency test for `adopt_state`)
|
||||||
|
- Modify: `packages/secubox-p2p/api/mesh.py` (add `adopt_state`)
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: Tasks 1–5.
|
||||||
|
- Produces: `adopt_state(state: dict, existing_conf_text: str | None) -> dict` — if `state` has no `private_key` but `existing_conf_text` parses one, import `private_key`/`address`/`listen_port` into `state` (so the public key is preserved); never overwrite an existing `private_key`. Returns the updated state. `sbx-mesh-up` (root) ties it together.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing test**
|
||||||
|
|
||||||
|
```python
|
||||||
|
def test_adopt_state_imports_existing_key_when_absent():
|
||||||
|
state = {"private_key": None, "peers": []}
|
||||||
|
conf = "[Interface]\nPrivateKey = LIVEKEY=\nAddress = 10.10.0.1/24\nListenPort = 51822\n"
|
||||||
|
out = mesh.adopt_state(state, conf)
|
||||||
|
assert out["private_key"] == "LIVEKEY="
|
||||||
|
assert out["address"] == "10.10.0.1/24"
|
||||||
|
|
||||||
|
|
||||||
|
def test_adopt_state_never_overwrites_existing_key():
|
||||||
|
state = {"private_key": "KEEP=", "peers": []}
|
||||||
|
conf = "[Interface]\nPrivateKey = OTHER=\n"
|
||||||
|
out = mesh.adopt_state(state, conf)
|
||||||
|
assert out["private_key"] == "KEEP="
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run test to verify it fails**
|
||||||
|
|
||||||
|
Run: `cd packages/secubox-p2p && python3 -m pytest tests/test_mesh.py -k adopt -v`
|
||||||
|
Expected: FAIL — missing `adopt_state`
|
||||||
|
|
||||||
|
- [ ] **Step 3: Implement `adopt_state` in api/mesh.py**
|
||||||
|
|
||||||
|
```python
|
||||||
|
def adopt_state(state: dict, existing_conf_text: str | None) -> dict:
|
||||||
|
"""Import the live wg-mesh private key so the public key is preserved.
|
||||||
|
Never overwrites a key already present in state."""
|
||||||
|
if state.get("private_key"):
|
||||||
|
return state
|
||||||
|
if not existing_conf_text:
|
||||||
|
return state
|
||||||
|
parsed = parse_wg_conf(existing_conf_text)
|
||||||
|
if parsed["private_key"]:
|
||||||
|
state["private_key"] = parsed["private_key"]
|
||||||
|
if not state.get("address") and parsed["address"]:
|
||||||
|
state["address"] = parsed["address"]
|
||||||
|
if parsed["listen_port"]:
|
||||||
|
state["listen_port"] = parsed["listen_port"]
|
||||||
|
return state
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run test to verify it passes**
|
||||||
|
|
||||||
|
Run: `cd packages/secubox-p2p && python3 -m pytest tests/test_mesh.py -k adopt -v`
|
||||||
|
Expected: PASS (2 tests)
|
||||||
|
|
||||||
|
- [ ] **Step 5: Write the root CLI**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# packages/secubox-p2p/scripts/sbx-mesh-up
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
# SPDX-License-Identifier: LicenseRef-CMSD-1.0
|
||||||
|
# SecuBox-Deb :: secubox-p2p :: sbx-mesh-up
|
||||||
|
# Root provisioner: adopt existing key -> collision guard -> render -> up.
|
||||||
|
# The secubox-p2p service runs as user `secubox` and cannot do this.
|
||||||
|
set -euo pipefail
|
||||||
|
[[ $EUID -eq 0 ]] || { echo "must run as root" >&2; exit 1; }
|
||||||
|
|
||||||
|
STATE=/var/lib/secubox/p2p/wg_mesh.json
|
||||||
|
CONF=/etc/wireguard/wg-mesh.conf
|
||||||
|
PKG=/usr/lib/secubox/p2p
|
||||||
|
|
||||||
|
python3 - "$STATE" "$CONF" <<'PY'
|
||||||
|
import json, sys, subprocess, pathlib
|
||||||
|
sys.path.insert(0, "/usr/lib/secubox/p2p")
|
||||||
|
from api import mesh
|
||||||
|
|
||||||
|
state_path, conf_path = pathlib.Path(sys.argv[1]), pathlib.Path(sys.argv[2])
|
||||||
|
state = json.loads(state_path.read_text()) if state_path.exists() else {"peers": []}
|
||||||
|
|
||||||
|
# Adopt the live key if state has none (preserves the gk2<->c3box handshake).
|
||||||
|
existing = conf_path.read_text() if conf_path.exists() else None
|
||||||
|
state = mesh.adopt_state(state, existing)
|
||||||
|
|
||||||
|
net = state.get("network", mesh.MESH_NETWORK)
|
||||||
|
bad = mesh.subnet_overlap(net)
|
||||||
|
if bad:
|
||||||
|
sys.exit(f"REFUSING: mesh network {net} overlaps reserved subnet {bad!r}")
|
||||||
|
|
||||||
|
if not state.get("private_key"):
|
||||||
|
sys.exit("no private key in state and none to adopt; run /wireguard/init first")
|
||||||
|
|
||||||
|
conf_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
conf_path.write_text(mesh.render_wg_conf(state))
|
||||||
|
conf_path.chmod(0o600)
|
||||||
|
state_path.write_text(json.dumps(state, indent=2))
|
||||||
|
print(f"rendered {conf_path} (addr {state.get('address')}, peers {len(state.get('peers', []))})")
|
||||||
|
PY
|
||||||
|
|
||||||
|
wg-quick down wg-mesh 2>/dev/null || true
|
||||||
|
wg-quick up wg-mesh
|
||||||
|
wg show wg-mesh
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 6: Lint the script**
|
||||||
|
|
||||||
|
Run: `bash -n packages/secubox-p2p/scripts/sbx-mesh-up && echo OK`
|
||||||
|
Expected: `OK`
|
||||||
|
|
||||||
|
- [ ] **Step 7: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
chmod +x packages/secubox-p2p/scripts/sbx-mesh-up
|
||||||
|
git add packages/secubox-p2p/api/mesh.py packages/secubox-p2p/scripts/sbx-mesh-up packages/secubox-p2p/tests/test_mesh.py
|
||||||
|
git commit -m "feat(p2p): root sbx-mesh-up provisioner (adopt key, guard, render, up)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 8: Packaging — ship config + CLI, depend on wireguard-tools, bump
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `packages/secubox-p2p/debian/rules`, `debian/control`, `debian/changelog`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: Tasks 1–7 artifacts.
|
||||||
|
- Produces: installed `/etc/secubox/p2p.toml.example`, `/usr/bin/sbx-mesh-up`, runtime dep `wireguard-tools`, version `1.7.6`.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add install lines to `override_dh_auto_install` (debian/rules, before "Create runtime directory")**
|
||||||
|
|
||||||
|
```makefile
|
||||||
|
# Install p2p.toml example
|
||||||
|
install -d $(CURDIR)/debian/secubox-p2p/etc/secubox
|
||||||
|
install -m 644 $(CURDIR)/conf/p2p.toml.example $(CURDIR)/debian/secubox-p2p/etc/secubox/
|
||||||
|
|
||||||
|
# Install root mesh provisioner CLI
|
||||||
|
install -m 755 $(CURDIR)/scripts/sbx-mesh-up $(CURDIR)/debian/secubox-p2p/usr/bin/
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Add `wireguard-tools` to Depends (debian/control)**
|
||||||
|
|
||||||
|
```
|
||||||
|
Depends: ${misc:Depends}, secubox-core (>= 1.0), python3, python3-fastapi | python3-pip, python3-uvicorn | python3-pip, avahi-daemon, avahi-utils, wireguard-tools
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Add changelog entry (top of debian/changelog)**
|
||||||
|
|
||||||
|
```
|
||||||
|
secubox-p2p (1.7.6-1~bookworm1) bookworm; urgency=medium
|
||||||
|
|
||||||
|
* feat(gondwana P1): adopt secubox-p2p as the single mesh owner.
|
||||||
|
- api/mesh.py: pure mesh logic (subnet collision guard, p2p.toml
|
||||||
|
[wireguard] loader, master-assigned IP allocation, wg.conf
|
||||||
|
parse/render, key adoption, per-node DDNS name).
|
||||||
|
- WireGuard defaults fixed 10.100.0.0/24->10.10.0.0/24 (br-lxc
|
||||||
|
collision), 51820->51822. Role-aware addressing (.1 master).
|
||||||
|
- sbx-mesh-up: root provisioner (adopt live key -> guard -> render ->
|
||||||
|
wg-quick up); the service user cannot run wg-quick.
|
||||||
|
- Depends: wireguard-tools. Ships /etc/secubox/p2p.toml.example.
|
||||||
|
|
||||||
|
-- Gerald KERMA <devel@cybermind.fr> Mon, 29 Jun 2026 14:00:00 +0200
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Build the package (arch:all)**
|
||||||
|
|
||||||
|
Run: `cd packages/secubox-p2p && dpkg-buildpackage -us -uc -b 2>&1 | tail -5`
|
||||||
|
Expected: `dpkg-deb: building package 'secubox-p2p' in '../secubox-p2p_1.7.6-1~bookworm1_all.deb'.`
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add packages/secubox-p2p/debian/
|
||||||
|
git commit -m "build(p2p): ship p2p.toml.example + sbx-mesh-up, dep wireguard-tools, 1.7.6"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 9: Cutover on gk2 — adopt + master, handshake preserved
|
||||||
|
|
||||||
|
**Files:** none (live operation; uses Task 8's `.deb`).
|
||||||
|
|
||||||
|
**Interfaces:** Consumes the built `secubox-p2p_1.7.6` deb.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Snapshot the live wg-mesh public key BEFORE**
|
||||||
|
|
||||||
|
Run: `ssh root@192.168.1.200 'wg show wg-mesh public-key; wg show wg-mesh latest-handshakes'`
|
||||||
|
Record the public key and that c3box's handshake is recent.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Install the new package on gk2 (single unit, no mass restart)**
|
||||||
|
|
||||||
|
Run: `scp ../secubox-p2p_1.7.6-1~bookworm1_all.deb root@192.168.1.200:/tmp/ && ssh root@192.168.1.200 'dpkg -i /tmp/secubox-p2p_1.7.6-1~bookworm1_all.deb && systemctl try-restart secubox-p2p'`
|
||||||
|
Expected: unpacked + configured; only `secubox-p2p` restarts.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Write gk2's p2p.toml as master**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
```bash
|
||||||
|
ssh root@192.168.1.200 'cat > /etc/secubox/p2p.toml <<EOF
|
||||||
|
[wireguard]
|
||||||
|
interface = "wg-mesh"
|
||||||
|
listen_port = 51822
|
||||||
|
network = "10.10.0.0/24"
|
||||||
|
role = "master"
|
||||||
|
EOF
|
||||||
|
chown secubox:secubox /etc/secubox/p2p.toml'
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run the provisioner — it must ADOPT the live key**
|
||||||
|
|
||||||
|
Run: `ssh root@192.168.1.200 'sbx-mesh-up'`
|
||||||
|
Expected: `rendered /etc/wireguard/wg-mesh.conf (addr 10.10.0.1/24, peers 1)` then `wg show wg-mesh` output.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Verify the public key is UNCHANGED and c3box still configured**
|
||||||
|
|
||||||
|
Run: `ssh root@192.168.1.200 'wg show wg-mesh public-key'`
|
||||||
|
Expected: **identical** to Step 1's key. (If different, adoption failed — restore `/etc/wireguard/wg-mesh.conf.pre` and stop.)
|
||||||
|
|
||||||
|
- [ ] **Step 6: Confirm `/wireguard` API truth now matches reality**
|
||||||
|
|
||||||
|
Run: `ssh root@192.168.1.200 'curl -s --unix-socket /run/secubox/p2p.sock http://x/wireguard'`
|
||||||
|
Expected: JSON with `"network":"10.10.0.0/24","listen_port":51822` and `status.running=true`.
|
||||||
|
|
||||||
|
- [ ] **Step 7: Commit a note (no code) — record cutover done**
|
||||||
|
|
||||||
|
No commit; proceed to Task 10. (Source already carries the change from Task 8.)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 10: Freebox forward + enroll amd64 (.3) + verify mesh
|
||||||
|
|
||||||
|
**Files:** none (live operation + operator action).
|
||||||
|
|
||||||
|
- [ ] **Step 1: OPERATOR ACTION — add Freebox UDP 51822 → 192.168.1.200**
|
||||||
|
|
||||||
|
Manual: Freebox OS → Ports → add `UDP 51822 → 192.168.1.200:51822`.
|
||||||
|
Verify from outside is optional now (amd64 is on the LAN); required when a node goes remote.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Install the new package on amd64 (.9)**
|
||||||
|
|
||||||
|
Run: `scp ../secubox-p2p_1.7.6-1~bookworm1_all.deb root@192.168.1.9:/tmp/ && ssh root@192.168.1.9 'dpkg -i /tmp/secubox-p2p_1.7.6-1~bookworm1_all.deb'`
|
||||||
|
Expected: configured.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Write amd64's p2p.toml as satellite + init identity**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
```bash
|
||||||
|
ssh root@192.168.1.9 'cat > /etc/secubox/p2p.toml <<EOF
|
||||||
|
[wireguard]
|
||||||
|
interface = "wg-mesh"
|
||||||
|
listen_port = 51822
|
||||||
|
network = "10.10.0.0/24"
|
||||||
|
role = "satellite"
|
||||||
|
master_endpoint = "82.67.100.75:51822"
|
||||||
|
EOF
|
||||||
|
chown secubox:secubox /etc/secubox/p2p.toml
|
||||||
|
curl -s --unix-socket /run/secubox/p2p.sock -X POST http://x/wireguard/init -H "Authorization: Bearer $(cat /etc/secubox/secrets/*jwt* 2>/dev/null | head -1)"'
|
||||||
|
```
|
||||||
|
Expected: JSON with `public_key` and `address` (allocated; will be `.3` once gk2 assigns — see Step 4 note).
|
||||||
|
|
||||||
|
- [ ] **Step 4: Register amd64 as a peer on gk2 (.3) and on amd64 (gk2)**
|
||||||
|
|
||||||
|
Run (capture amd64 pubkey, then add on gk2; add gk2 on amd64):
|
||||||
|
```bash
|
||||||
|
AMD_PUB=$(ssh root@192.168.1.9 'wg show wg-mesh public-key 2>/dev/null || python3 -c "import json;print(json.load(open(\"/var/lib/secubox/p2p/wg_mesh.json\"))[\"public_key\"])"')
|
||||||
|
GK2_PUB=$(ssh root@192.168.1.200 'wg show wg-mesh public-key')
|
||||||
|
# gk2: add amd64 as roaming spoke .3/32 (edit state, re-provision)
|
||||||
|
ssh root@192.168.1.200 "python3 -c \"import json;p='/var/lib/secubox/p2p/wg_mesh.json';d=json.load(open(p));d.setdefault('peers',[]).append({'public_key':'$AMD_PUB','allowed_ips':'10.10.0.3/32'});json.dump(d,open(p,'w'),indent=2)\" && sbx-mesh-up"
|
||||||
|
# amd64: set address .3 + gk2 peer, provision
|
||||||
|
ssh root@192.168.1.9 "python3 -c \"import json;p='/var/lib/secubox/p2p/wg_mesh.json';d=json.load(open(p));d['address']='10.10.0.3/24';d['peers']=[{'public_key':'$GK2_PUB','endpoint':'82.67.100.75:51822','allowed_ips':'10.10.0.0/24'}];json.dump(d,open(p,'w'),indent=2)\" && sbx-mesh-up"
|
||||||
|
```
|
||||||
|
Expected: both `wg show wg-mesh` list each other.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Verify handshakes + inter-node reachability**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
```bash
|
||||||
|
ssh root@192.168.1.9 'ping -c2 -W2 10.10.0.1' # amd64 -> gk2
|
||||||
|
ssh root@192.168.1.200 'ping -c2 -W2 10.10.0.3' # gk2 -> amd64
|
||||||
|
ssh root@192.168.1.9 'wg show wg-mesh latest-handshakes'
|
||||||
|
```
|
||||||
|
Expected: pings succeed; handshake with gk2 is recent.
|
||||||
|
|
||||||
|
- [ ] **Step 6: Verify threatmesh reachability over the mesh**
|
||||||
|
|
||||||
|
Run: `ssh root@192.168.1.9 'curl -s -m4 -o /dev/null -w "%{http_code}\n" http://10.10.0.1:8780/api/v1/threatmesh/mesh/ingest -X POST -H "Content-Type: application/json" -d "{}"'`
|
||||||
|
Expected: a HTTP code (e.g. `400/422/200`) — **not** a timeout/`000` — proving spoke→hub service reachability over wg-mesh.
|
||||||
|
|
||||||
|
- [ ] **Step 7: Final source sync check**
|
||||||
|
|
||||||
|
Confirm the live `/etc/secubox/p2p.toml` contents and `sbx-mesh-up` behavior match the packaged source (Task 8). If any live tweak was needed, backport it to `conf/p2p.toml.example` or `scripts/sbx-mesh-up` and commit:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add -A packages/secubox-p2p/
|
||||||
|
git commit -m "fix(p2p): backport gondwana P1 cutover tweaks from gk2/amd64"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Self-review
|
||||||
|
|
||||||
|
**Spec coverage:**
|
||||||
|
- §2 addressing (10.10.0.0/24, master-assigned, .1/.2/.3) → Tasks 1,3,6,9,10. ✓
|
||||||
|
- §2 collision guard → Tasks 1,6,7. ✓
|
||||||
|
- §3 identity (persistent keypair, node-id, DDNS name, live-USB persistence) → Tasks 5,6; persistence is `/var/lib/secubox/p2p` on amd64 partition (Task 10 writes there). ✓
|
||||||
|
- §4 topology (master roaming peers, satellite endpoint+keepalive, hub routing) → Tasks 4,6,10. ✓
|
||||||
|
- §5 secubox-p2p changes (config, adoption, provisioning, guard, join wiring) → Tasks 2,4,6,7. ✓
|
||||||
|
- §6 cutover (gk2 adopt+master, Freebox, amd64 .3, verify, backport) → Tasks 9,10. ✓
|
||||||
|
- §7 failure modes (key-regen guarded by adopt_state; collision guard; keepalive) → Tasks 7,1,4. ✓
|
||||||
|
|
||||||
|
**Placeholder scan:** no TBD/TODO; every code step shows full code; verification steps show exact commands + expected output. ✓
|
||||||
|
|
||||||
|
**Type consistency:** `mesh.MESH_NETWORK/MESH_PORT/MESH_INTERFACE`, `subnet_overlap`, `load_p2p_config`, `allocate_mesh_ip`, `parse_wg_conf`, `render_wg_conf`, `ddns_name`, `adopt_state` used consistently across Tasks 1–10. ✓
|
||||||
|
|
||||||
|
**Known limitation (documented, not a gap):** inter-satellite (c3box↔amd64) traffic relies on gk2 hub routing; with c3box offline this is unverifiable now — Step 6 verifies spoke→hub, which is the testable subset. Direct spoke-to-spoke verification waits for c3box online.
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -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`, `<mod>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 (`<service>.<boxname>.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=<module>, version, payload)` → annuaire.
|
||||||
|
Replicas pull → write `/etc/secubox/<m>.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 `<service>.<boxname>.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 <rendezvous> <token>` → 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
|
||||||
|
`<service>.<boxname>.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.
|
||||||
|
|
@ -0,0 +1,852 @@
|
||||||
|
# P2P Service Registry ↔ Annuaire Catalog Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Make the secubox-p2p Service Registry a live, no-drift view of the secubox-annuaire service catalog, with an "Auto register all" action that activates local services and subscribes to remote ones through the existing invite/approval workflow.
|
||||||
|
|
||||||
|
**Architecture:** A new pure module (`api/registry.py`) merges three inputs — the annuaire catalog, my annuaire subscriptions, and a thin local "activation overlay" — plus legacy p2p-local services, into the rows the UI shows. A new I/O module (`api/annuaire_client.py`) talks to the local `annuaire.sock` and subscribes *as the node* using the 0600 node key. Four endpoints in `api/main.py` expose the merged view and the activate/subscribe actions. The UI Service Registry tab renders states and adds the "Auto register all" button. annuaire is unchanged.
|
||||||
|
|
||||||
|
**Tech Stack:** Python 3.11, FastAPI, `urllib` over a unix-socket HTTP connection (no new deps), pytest, vanilla JS UI.
|
||||||
|
|
||||||
|
## Global Constraints
|
||||||
|
|
||||||
|
- SPDX header on every new file: `# SPDX-License-Identifier: LicenseRef-CMSD-1.0` + the CyberMind copyright block (copy from `api/mesh.py`).
|
||||||
|
- secubox-p2p runs as `User=secubox`; both it and secubox-annuaire are `secubox`, so reading `/etc/secubox/secrets/annuaire/node.key` (0600 secubox) is in-policy.
|
||||||
|
- annuaire is read over **its own** socket `/run/secubox/annuaire.sock`, NEVER the aggregator.
|
||||||
|
- No new Python dependencies (stdlib `urllib`/`http.client` only).
|
||||||
|
- No new annuaire model fields, no provider-side macro execution (Milestone 2).
|
||||||
|
- Debian version bump: `1.7.8` → `1.8.0-1~bookworm1`. Changelog entry required.
|
||||||
|
- Tests live in `packages/secubox-p2p/tests/`, imported as `from api import <mod>` (see `tests/conftest.py`).
|
||||||
|
- Never raise into the request path on annuaire-unavailable — degrade gracefully.
|
||||||
|
- Escape all API-derived strings in the UI via the existing `escapeHtml`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: Pure registry merge + activation overlay (`api/registry.py`)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `packages/secubox-p2p/api/registry.py`
|
||||||
|
- Test: `packages/secubox-p2p/tests/test_registry.py`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: nothing (pure functions + file I/O on an injected path).
|
||||||
|
- Produces:
|
||||||
|
- `load_overlay(path) -> dict` / `save_overlay(path, data) -> None`
|
||||||
|
- `set_active(path, service_id, local_port) -> dict`
|
||||||
|
- `port_from_endpoint(endpoint: str) -> int | None`
|
||||||
|
- `merge_services(catalog: list[dict], subscriptions: list[dict], overlay: dict, legacy: list[dict], local_did: str | None) -> list[dict]`
|
||||||
|
- Each merged row: `{service_id, name, type, provider, provider_label, port, approval_mode, subscription_state, active, source, automatable}`
|
||||||
|
- `MACRO_KINDS: set[str]` (forward hint set: `{"tor-exit","wg-relay","dns-resolver","http-mirror"}`)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing test**
|
||||||
|
|
||||||
|
```python
|
||||||
|
# packages/secubox-p2p/tests/test_registry.py
|
||||||
|
# SPDX-License-Identifier: LicenseRef-CMSD-1.0
|
||||||
|
# Copyright (c) 2026 CyberMind — Gérald Kerma <devel@cybermind.fr>
|
||||||
|
import json
|
||||||
|
from api import registry
|
||||||
|
|
||||||
|
|
||||||
|
def test_port_from_endpoint():
|
||||||
|
assert registry.port_from_endpoint("http://10.10.0.1:9050/x") == 9050
|
||||||
|
assert registry.port_from_endpoint("10.10.0.2:3483") == 3483
|
||||||
|
assert registry.port_from_endpoint("/local/path") is None
|
||||||
|
assert registry.port_from_endpoint("") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_merge_local_vs_remote_and_state():
|
||||||
|
local_did = "did:plc:" + "a" * 32
|
||||||
|
remote_did = "did:plc:" + "b" * 32
|
||||||
|
catalog = [
|
||||||
|
{"service_id": "s1", "name": "WAF mirror", "kind": "module",
|
||||||
|
"provider": local_did, "endpoint": "http://10.10.0.1:8085",
|
||||||
|
"approval_mode": "auto"},
|
||||||
|
{"service_id": "s2", "name": "Tor exit", "kind": "tor-exit",
|
||||||
|
"provider": remote_did, "endpoint": "10.10.0.2:9050",
|
||||||
|
"approval_mode": "pending"},
|
||||||
|
]
|
||||||
|
subs = [{"service_id": "s2", "state": "pending"}]
|
||||||
|
overlay = {"s1": {"active": True, "local_port": 8085, "subscription_id": None}}
|
||||||
|
rows = registry.merge_services(catalog, subs, overlay, [], local_did)
|
||||||
|
by_id = {r["service_id"]: r for r in rows}
|
||||||
|
assert by_id["s1"]["provider_label"] == "local"
|
||||||
|
assert by_id["s1"]["active"] is True
|
||||||
|
assert by_id["s1"]["subscription_state"] == "not-subscribed"
|
||||||
|
assert by_id["s2"]["provider_label"] != "local"
|
||||||
|
assert by_id["s2"]["subscription_state"] == "pending"
|
||||||
|
assert by_id["s2"]["automatable"] is True # tor-exit ∈ MACRO_KINDS
|
||||||
|
assert by_id["s1"]["automatable"] is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_merge_includes_legacy_local():
|
||||||
|
legacy = [{"name": "old-svc", "port": 1234, "protocol": "tcp", "active": True}]
|
||||||
|
rows = registry.merge_services([], [], {}, legacy, None)
|
||||||
|
assert len(rows) == 1
|
||||||
|
assert rows[0]["source"] == "p2p-local"
|
||||||
|
assert rows[0]["provider_label"] == "local"
|
||||||
|
assert rows[0]["port"] == 1234
|
||||||
|
|
||||||
|
|
||||||
|
def test_overlay_roundtrip_and_prune(tmp_path):
|
||||||
|
p = tmp_path / "activation.json"
|
||||||
|
registry.set_active(str(p), "s1", 8085)
|
||||||
|
data = registry.load_overlay(str(p))
|
||||||
|
assert data["s1"]["active"] is True and data["s1"]["local_port"] == 8085
|
||||||
|
# prune: merge drops overlay-only entries with no catalog/legacy backing
|
||||||
|
rows = registry.merge_services([], [], data, [], None)
|
||||||
|
assert rows == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_load_overlay_missing_or_corrupt(tmp_path):
|
||||||
|
assert registry.load_overlay(str(tmp_path / "nope.json")) == {}
|
||||||
|
bad = tmp_path / "bad.json"; bad.write_text("{not json")
|
||||||
|
assert registry.load_overlay(str(bad)) == {}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run test to verify it fails**
|
||||||
|
|
||||||
|
Run: `cd packages/secubox-p2p && python3 -m pytest tests/test_registry.py -q`
|
||||||
|
Expected: FAIL — `ModuleNotFoundError: No module named 'api.registry'`.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Write minimal implementation**
|
||||||
|
|
||||||
|
```python
|
||||||
|
# packages/secubox-p2p/api/registry.py
|
||||||
|
# SPDX-License-Identifier: LicenseRef-CMSD-1.0
|
||||||
|
# Copyright (c) 2026 CyberMind — Gérald Kerma <devel@cybermind.fr>
|
||||||
|
# Source-Disclosed License — All rights reserved except as expressly granted.
|
||||||
|
# See LICENCE-CMSD-1.0.md for terms.
|
||||||
|
"""
|
||||||
|
SecuBox-Deb :: secubox-p2p :: registry
|
||||||
|
|
||||||
|
Pure merge logic for the Service Registry: combines the annuaire catalog, my
|
||||||
|
subscriptions, the local activation overlay, and legacy p2p-local services into
|
||||||
|
the rows the UI renders. No network I/O lives here (see annuaire_client.py) so
|
||||||
|
the merge is fully unit-testable.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
|
# Service kinds that (in Milestone 2) carry an executable access macro. In M1
|
||||||
|
# this only drives a cosmetic "automatable" badge.
|
||||||
|
MACRO_KINDS = {"tor-exit", "wg-relay", "dns-resolver", "http-mirror"}
|
||||||
|
|
||||||
|
_PORT_RE = re.compile(r":(\d{1,5})(?:/|$)")
|
||||||
|
|
||||||
|
|
||||||
|
def port_from_endpoint(endpoint: str) -> Optional[int]:
|
||||||
|
"""Best-effort extract a TCP port from a host:port or URL endpoint."""
|
||||||
|
if not endpoint:
|
||||||
|
return None
|
||||||
|
m = _PORT_RE.search(endpoint)
|
||||||
|
if not m:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
p = int(m.group(1))
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
return p if 0 < p < 65536 else None
|
||||||
|
|
||||||
|
|
||||||
|
def load_overlay(path: str) -> Dict[str, Any]:
|
||||||
|
try:
|
||||||
|
with open(path, "r", encoding="utf-8") as fh:
|
||||||
|
data = json.load(fh)
|
||||||
|
return data if isinstance(data, dict) else {}
|
||||||
|
except Exception:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def save_overlay(path: str, data: Dict[str, Any]) -> None:
|
||||||
|
import os
|
||||||
|
os.makedirs(os.path.dirname(path), exist_ok=True)
|
||||||
|
tmp = path + ".tmp"
|
||||||
|
with open(tmp, "w", encoding="utf-8") as fh:
|
||||||
|
json.dump(data, fh)
|
||||||
|
os.replace(tmp, path)
|
||||||
|
|
||||||
|
|
||||||
|
def set_active(path: str, service_id: str, local_port: Optional[int],
|
||||||
|
subscription_id: Optional[str] = None) -> Dict[str, Any]:
|
||||||
|
data = load_overlay(path)
|
||||||
|
entry = data.get(service_id, {})
|
||||||
|
entry["active"] = True
|
||||||
|
entry["local_port"] = local_port
|
||||||
|
if subscription_id is not None:
|
||||||
|
entry["subscription_id"] = subscription_id
|
||||||
|
entry.setdefault("subscription_id", None)
|
||||||
|
entry["activated_at"] = datetime.now(timezone.utc).isoformat()
|
||||||
|
data[service_id] = entry
|
||||||
|
save_overlay(path, data)
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
def set_subscription(path: str, service_id: str, subscription_id: str) -> Dict[str, Any]:
|
||||||
|
data = load_overlay(path)
|
||||||
|
entry = data.get(service_id, {})
|
||||||
|
entry["subscription_id"] = subscription_id
|
||||||
|
entry.setdefault("active", False)
|
||||||
|
entry.setdefault("local_port", None)
|
||||||
|
data[service_id] = entry
|
||||||
|
save_overlay(path, data)
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
def merge_services(catalog: List[Dict], subscriptions: List[Dict],
|
||||||
|
overlay: Dict[str, Any], legacy: List[Dict],
|
||||||
|
local_did: Optional[str]) -> List[Dict]:
|
||||||
|
sub_state = {}
|
||||||
|
for s in subscriptions or []:
|
||||||
|
sid = s.get("service_id")
|
||||||
|
if sid:
|
||||||
|
sub_state[sid] = s.get("state", "pending")
|
||||||
|
|
||||||
|
rows: List[Dict] = []
|
||||||
|
for offer in catalog or []:
|
||||||
|
sid = offer.get("service_id")
|
||||||
|
if not sid:
|
||||||
|
continue
|
||||||
|
provider = offer.get("provider")
|
||||||
|
is_local = bool(local_did) and provider == local_did
|
||||||
|
ov = overlay.get(sid, {})
|
||||||
|
kind = offer.get("kind", "")
|
||||||
|
rows.append({
|
||||||
|
"service_id": sid,
|
||||||
|
"name": offer.get("name", ""),
|
||||||
|
"type": kind,
|
||||||
|
"provider": provider,
|
||||||
|
"provider_label": "local" if is_local else _short_did(provider),
|
||||||
|
"port": ov.get("local_port") or port_from_endpoint(offer.get("endpoint", "")),
|
||||||
|
"approval_mode": offer.get("approval_mode", "auto"),
|
||||||
|
"subscription_state": sub_state.get(sid, "not-subscribed"),
|
||||||
|
"active": bool(ov.get("active", False)),
|
||||||
|
"source": "annuaire",
|
||||||
|
"automatable": kind in MACRO_KINDS,
|
||||||
|
})
|
||||||
|
|
||||||
|
for svc in legacy or []:
|
||||||
|
rows.append({
|
||||||
|
"service_id": None,
|
||||||
|
"name": svc.get("name", ""),
|
||||||
|
"type": svc.get("protocol", svc.get("type", "")),
|
||||||
|
"provider": None,
|
||||||
|
"provider_label": "local",
|
||||||
|
"port": svc.get("port"),
|
||||||
|
"approval_mode": None,
|
||||||
|
"subscription_state": "n/a",
|
||||||
|
"active": bool(svc.get("active", True)),
|
||||||
|
"source": "p2p-local",
|
||||||
|
"automatable": False,
|
||||||
|
})
|
||||||
|
|
||||||
|
rows.sort(key=lambda r: (r["provider_label"] != "local", r["name"].lower()))
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
def _short_did(did: Optional[str]) -> str:
|
||||||
|
if not did:
|
||||||
|
return "unknown"
|
||||||
|
return did[:20] + "…" if len(did) > 21 else did
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run test to verify it passes**
|
||||||
|
|
||||||
|
Run: `cd packages/secubox-p2p && python3 -m pytest tests/test_registry.py -q`
|
||||||
|
Expected: PASS (5 tests).
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add packages/secubox-p2p/api/registry.py packages/secubox-p2p/tests/test_registry.py
|
||||||
|
git commit -m "feat(p2p): pure registry merge + activation overlay (ref #<issue>)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2: Annuaire client over unix socket (`api/annuaire_client.py`)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `packages/secubox-p2p/api/annuaire_client.py`
|
||||||
|
- Test: `packages/secubox-p2p/tests/test_annuaire_client.py`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: nothing from earlier tasks.
|
||||||
|
- Produces:
|
||||||
|
- `node_identity(key_path=NODE_KEY_PATH) -> tuple[str|None, str|None]` → `(did, priv_hex)` or `(None, None)` if no key.
|
||||||
|
- `get_catalog(sock=ANNUAIRE_SOCK) -> tuple[list[dict], str|None]` → `(offers, error)`.
|
||||||
|
- `get_subscriptions(mine_did, sock=...) -> tuple[list[dict], str|None]`.
|
||||||
|
- `subscribe(service_id, did, priv_hex, sock=...) -> tuple[dict|None, str|None]`.
|
||||||
|
- Constants `ANNUAIRE_SOCK = "/run/secubox/annuaire.sock"`, `NODE_KEY_PATH = "/etc/secubox/secrets/annuaire/node.key"`.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing test**
|
||||||
|
|
||||||
|
```python
|
||||||
|
# packages/secubox-p2p/tests/test_annuaire_client.py
|
||||||
|
# SPDX-License-Identifier: LicenseRef-CMSD-1.0
|
||||||
|
# Copyright (c) 2026 CyberMind — Gérald Kerma <devel@cybermind.fr>
|
||||||
|
import http.server, json, socket, threading, os
|
||||||
|
from api import annuaire_client as ac
|
||||||
|
|
||||||
|
|
||||||
|
def _serve_unix(sock_path, routes):
|
||||||
|
"""Tiny unix-socket HTTP server. routes: {path: (status, json_obj)}."""
|
||||||
|
class H(http.server.BaseHTTPRequestHandler):
|
||||||
|
def _send(self):
|
||||||
|
body = b""
|
||||||
|
for p, (st, obj) in routes.items():
|
||||||
|
if self.path == p:
|
||||||
|
body = json.dumps(obj).encode(); st_code = st; break
|
||||||
|
else:
|
||||||
|
st_code = 404; body = b'{"detail":"nf"}'
|
||||||
|
self.send_response(st_code)
|
||||||
|
self.send_header("Content-Type", "application/json"); self.end_headers()
|
||||||
|
self.wfile.write(body)
|
||||||
|
do_GET = _send
|
||||||
|
do_POST = _send
|
||||||
|
def log_message(self, *a): pass
|
||||||
|
srv = http.server.HTTPServer.__new__(http.server.HTTPServer)
|
||||||
|
# bind a unix socket manually
|
||||||
|
s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||||
|
if os.path.exists(sock_path): os.unlink(sock_path)
|
||||||
|
s.bind(sock_path); s.listen(8)
|
||||||
|
http.server.HTTPServer.__init__(srv, ("localhost", 0), H, bind_and_activate=False)
|
||||||
|
srv.socket = s
|
||||||
|
t = threading.Thread(target=srv.serve_forever, daemon=True); t.start()
|
||||||
|
return srv
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_catalog_reads_services(tmp_path):
|
||||||
|
sp = str(tmp_path / "ann.sock")
|
||||||
|
srv = _serve_unix(sp, {"/api/v1/annuaire/services": (200, {"services": [{"service_id": "s1", "name": "WAF"}]})})
|
||||||
|
try:
|
||||||
|
offers, err = ac.get_catalog(sock=sp)
|
||||||
|
assert err is None
|
||||||
|
assert offers[0]["service_id"] == "s1"
|
||||||
|
finally:
|
||||||
|
srv.shutdown()
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_catalog_socket_missing_returns_error(tmp_path):
|
||||||
|
offers, err = ac.get_catalog(sock=str(tmp_path / "nope.sock"))
|
||||||
|
assert offers == [] and err is not None
|
||||||
|
|
||||||
|
|
||||||
|
def test_node_identity_reads_key(tmp_path):
|
||||||
|
# a 32-byte key hex → deterministic did
|
||||||
|
key = tmp_path / "node.key"; key.write_text("11" * 32 + "\n")
|
||||||
|
did, priv = ac.node_identity(key_path=str(key))
|
||||||
|
assert priv == "11" * 32
|
||||||
|
assert did and did.startswith("did:plc:")
|
||||||
|
|
||||||
|
|
||||||
|
def test_node_identity_missing(tmp_path):
|
||||||
|
did, priv = ac.node_identity(key_path=str(tmp_path / "nope"))
|
||||||
|
assert did is None and priv is None
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run test to verify it fails**
|
||||||
|
|
||||||
|
Run: `cd packages/secubox-p2p && python3 -m pytest tests/test_annuaire_client.py -q`
|
||||||
|
Expected: FAIL — `No module named 'api.annuaire_client'`.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Write minimal implementation**
|
||||||
|
|
||||||
|
```python
|
||||||
|
# packages/secubox-p2p/api/annuaire_client.py
|
||||||
|
# SPDX-License-Identifier: LicenseRef-CMSD-1.0
|
||||||
|
# Copyright (c) 2026 CyberMind — Gérald Kerma <devel@cybermind.fr>
|
||||||
|
# Source-Disclosed License — All rights reserved except as expressly granted.
|
||||||
|
# See LICENCE-CMSD-1.0.md for terms.
|
||||||
|
"""
|
||||||
|
SecuBox-Deb :: secubox-p2p :: annuaire_client
|
||||||
|
|
||||||
|
Thin client to the LOCAL secubox-annuaire over its own unix socket
|
||||||
|
(/run/secubox/annuaire.sock — never the aggregator). Subscribes AS THE NODE
|
||||||
|
using the 0600 node key shared by the secubox user. Never raises into the
|
||||||
|
request path: every call returns (data, error).
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import http.client
|
||||||
|
import json
|
||||||
|
import socket as _socket
|
||||||
|
from typing import Any, Dict, List, Optional, Tuple
|
||||||
|
|
||||||
|
ANNUAIRE_SOCK = "/run/secubox/annuaire.sock"
|
||||||
|
NODE_KEY_PATH = "/etc/secubox/secrets/annuaire/node.key"
|
||||||
|
_TIMEOUT = 3.0
|
||||||
|
|
||||||
|
|
||||||
|
class _UnixHTTPConnection(http.client.HTTPConnection):
|
||||||
|
def __init__(self, sock_path: str, timeout: float = _TIMEOUT):
|
||||||
|
super().__init__("localhost", timeout=timeout)
|
||||||
|
self._sock_path = sock_path
|
||||||
|
|
||||||
|
def connect(self):
|
||||||
|
s = _socket.socket(_socket.AF_UNIX, _socket.SOCK_STREAM)
|
||||||
|
s.settimeout(self.timeout)
|
||||||
|
s.connect(self._sock_path)
|
||||||
|
self.sock = s
|
||||||
|
|
||||||
|
|
||||||
|
def _request(method: str, path: str, sock: str,
|
||||||
|
body: Optional[dict] = None) -> Tuple[Optional[Any], Optional[str]]:
|
||||||
|
try:
|
||||||
|
conn = _UnixHTTPConnection(sock)
|
||||||
|
headers = {"Accept": "application/json"}
|
||||||
|
data = None
|
||||||
|
if body is not None:
|
||||||
|
data = json.dumps(body).encode()
|
||||||
|
headers["Content-Type"] = "application/json"
|
||||||
|
conn.request(method, path, body=data, headers=headers)
|
||||||
|
resp = conn.getresponse()
|
||||||
|
raw = resp.read()
|
||||||
|
conn.close()
|
||||||
|
if resp.status >= 400:
|
||||||
|
return None, f"annuaire {method} {path} -> {resp.status}"
|
||||||
|
return (json.loads(raw) if raw else {}), None
|
||||||
|
except Exception as e: # noqa: BLE001
|
||||||
|
return None, f"{type(e).__name__}: {e}"
|
||||||
|
|
||||||
|
|
||||||
|
def did_from_pubkey_hex(pub_hex: str) -> str:
|
||||||
|
return "did:plc:" + hashlib.sha256(bytes.fromhex(pub_hex)).hexdigest()[:32]
|
||||||
|
|
||||||
|
|
||||||
|
def node_identity(key_path: str = NODE_KEY_PATH) -> Tuple[Optional[str], Optional[str]]:
|
||||||
|
"""Return (did, priv_hex) from the node key, or (None, None) if absent."""
|
||||||
|
try:
|
||||||
|
with open(key_path, "r", encoding="ascii") as fh:
|
||||||
|
priv_hex = fh.read().strip()
|
||||||
|
priv = bytes.fromhex(priv_hex)
|
||||||
|
if len(priv) != 32:
|
||||||
|
return None, None
|
||||||
|
from cryptography.hazmat.primitives.asymmetric import ed25519
|
||||||
|
from cryptography.hazmat.primitives import serialization
|
||||||
|
pub = ed25519.Ed25519PrivateKey.from_private_bytes(priv).public_key().public_bytes(
|
||||||
|
encoding=serialization.Encoding.Raw, format=serialization.PublicFormat.Raw)
|
||||||
|
return did_from_pubkey_hex(pub.hex()), priv_hex
|
||||||
|
except Exception:
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
|
||||||
|
def get_catalog(sock: str = ANNUAIRE_SOCK) -> Tuple[List[Dict], Optional[str]]:
|
||||||
|
data, err = _request("GET", "/api/v1/annuaire/services", sock)
|
||||||
|
if err:
|
||||||
|
return [], err
|
||||||
|
return (data or {}).get("services", []), None
|
||||||
|
|
||||||
|
|
||||||
|
def get_subscriptions(mine_did: Optional[str] = None,
|
||||||
|
sock: str = ANNUAIRE_SOCK) -> Tuple[List[Dict], Optional[str]]:
|
||||||
|
path = "/api/v1/annuaire/subscriptions"
|
||||||
|
if mine_did:
|
||||||
|
path += f"?mine={mine_did}"
|
||||||
|
data, err = _request("GET", path, sock)
|
||||||
|
if err:
|
||||||
|
return [], err
|
||||||
|
return (data or {}).get("subscriptions", []), None
|
||||||
|
|
||||||
|
|
||||||
|
def subscribe(service_id: str, did: str, priv_hex: str,
|
||||||
|
sock: str = ANNUAIRE_SOCK) -> Tuple[Optional[Dict], Optional[str]]:
|
||||||
|
return _request("POST", f"/api/v1/annuaire/service/{service_id}/subscribe", sock,
|
||||||
|
body={"subscriber_did": did, "subscriber_priv_hex": priv_hex})
|
||||||
|
```
|
||||||
|
|
||||||
|
Note: `did_from_pubkey_hex` mirrors `annuaire/crypto.did_from_pubkey` exactly (sha256(pubkey)[:32]); the test pins it.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run test to verify it passes**
|
||||||
|
|
||||||
|
Run: `cd packages/secubox-p2p && python3 -m pytest tests/test_annuaire_client.py -q`
|
||||||
|
Expected: PASS (4 tests).
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add packages/secubox-p2p/api/annuaire_client.py packages/secubox-p2p/tests/test_annuaire_client.py
|
||||||
|
git commit -m "feat(p2p): annuaire unix-socket client + node-key identity (ref #<issue>)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3: Wire the endpoints in `api/main.py`
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `packages/secubox-p2p/api/main.py` (add `ACTIVATION_FILE`, replace `GET /services`, add 3 endpoints; import the two new modules)
|
||||||
|
- Test: `packages/secubox-p2p/tests/test_services_endpoints.py`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: `registry.merge_services/load_overlay/set_active/set_subscription/port_from_endpoint`, `annuaire_client.get_catalog/get_subscriptions/subscribe/node_identity`.
|
||||||
|
- Produces (HTTP): `GET /services`, `POST /services/auto-register`, `POST /services/{service_id}/request`, `POST /services/{service_id}/activate`.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing test**
|
||||||
|
|
||||||
|
```python
|
||||||
|
# packages/secubox-p2p/tests/test_services_endpoints.py
|
||||||
|
# SPDX-License-Identifier: LicenseRef-CMSD-1.0
|
||||||
|
# Copyright (c) 2026 CyberMind — Gérald Kerma <devel@cybermind.fr>
|
||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
from api import main, annuaire_client, registry
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def client(tmp_path, monkeypatch):
|
||||||
|
monkeypatch.setattr(main, "ACTIVATION_FILE", tmp_path / "activation.json")
|
||||||
|
monkeypatch.setattr(main, "SERVICES_FILE", tmp_path / "services.json")
|
||||||
|
local = "did:plc:" + "a" * 32
|
||||||
|
remote = "did:plc:" + "b" * 32
|
||||||
|
monkeypatch.setattr(annuaire_client, "node_identity", lambda *a, **k: (local, "11" * 32))
|
||||||
|
monkeypatch.setattr(annuaire_client, "get_catalog", lambda *a, **k: ([
|
||||||
|
{"service_id": "s1", "name": "WAF", "kind": "module", "provider": local,
|
||||||
|
"endpoint": "http://10.10.0.1:8085", "approval_mode": "auto"},
|
||||||
|
{"service_id": "s2", "name": "Tor", "kind": "tor-exit", "provider": remote,
|
||||||
|
"endpoint": "10.10.0.2:9050", "approval_mode": "auto"},
|
||||||
|
], None))
|
||||||
|
monkeypatch.setattr(annuaire_client, "get_subscriptions", lambda *a, **k: ([], None))
|
||||||
|
calls = []
|
||||||
|
def fake_sub(sid, did, priv, **k):
|
||||||
|
calls.append(sid); return ({"subscription_id": "sub-" + sid, "state": "approved"}, None)
|
||||||
|
monkeypatch.setattr(annuaire_client, "subscribe", fake_sub)
|
||||||
|
main._test_sub_calls = calls
|
||||||
|
return TestClient(main.app)
|
||||||
|
|
||||||
|
|
||||||
|
def test_services_merges_catalog(client):
|
||||||
|
r = client.get("/services")
|
||||||
|
assert r.status_code == 200
|
||||||
|
rows = r.json()["services"]
|
||||||
|
ids = {row["service_id"] for row in rows}
|
||||||
|
assert "s1" in ids and "s2" in ids
|
||||||
|
|
||||||
|
|
||||||
|
def test_auto_register_activates_local_and_subscribes_remote(client):
|
||||||
|
r = client.post("/services/auto-register")
|
||||||
|
assert r.status_code == 200
|
||||||
|
body = r.json()
|
||||||
|
assert body["activated"] >= 1 # s1 local
|
||||||
|
assert body["requested"] >= 1 # s2 remote subscribed
|
||||||
|
assert "s2" in main._test_sub_calls
|
||||||
|
# s1 now active in the overlay-backed view
|
||||||
|
rows = {x["service_id"]: x for x in client.get("/services").json()["services"]}
|
||||||
|
assert rows["s1"]["active"] is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_catalog_unavailable_degrades(client, monkeypatch):
|
||||||
|
monkeypatch.setattr(annuaire_client, "get_catalog", lambda *a, **k: ([], "socket down"))
|
||||||
|
r = client.get("/services")
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert r.json().get("catalog_unavailable") is True
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run test to verify it fails**
|
||||||
|
|
||||||
|
Run: `cd packages/secubox-p2p && python3 -m pytest tests/test_services_endpoints.py -q`
|
||||||
|
Expected: FAIL — `AttributeError: module 'api.main' has no attribute 'ACTIVATION_FILE'` (or the new endpoints 404 / return the old shape).
|
||||||
|
|
||||||
|
- [ ] **Step 3: Write minimal implementation**
|
||||||
|
|
||||||
|
In `api/main.py`, near the other `_FILE` constants (after line ~46) add:
|
||||||
|
|
||||||
|
```python
|
||||||
|
ACTIVATION_FILE = P2P_DIR / "activation.json"
|
||||||
|
```
|
||||||
|
|
||||||
|
Near the top imports (after `from api import mesh` if present, else add):
|
||||||
|
|
||||||
|
```python
|
||||||
|
from api import registry, annuaire_client
|
||||||
|
```
|
||||||
|
|
||||||
|
Replace the existing `GET /services` handler (the `list_services` function) with:
|
||||||
|
|
||||||
|
```python
|
||||||
|
@app.get("/services")
|
||||||
|
async def list_services():
|
||||||
|
"""Live view: annuaire catalog ⨝ my subscriptions ⨝ activation overlay ⨝ legacy."""
|
||||||
|
init_dirs()
|
||||||
|
local_did, _ = annuaire_client.node_identity()
|
||||||
|
catalog, cat_err = annuaire_client.get_catalog()
|
||||||
|
subs, _ = annuaire_client.get_subscriptions(local_did)
|
||||||
|
overlay = registry.load_overlay(str(ACTIVATION_FILE))
|
||||||
|
legacy = load_json(SERVICES_FILE, [])
|
||||||
|
legacy = legacy if isinstance(legacy, list) else []
|
||||||
|
rows = registry.merge_services(catalog, subs, overlay, legacy, local_did)
|
||||||
|
out = {"services": rows}
|
||||||
|
if cat_err:
|
||||||
|
out["catalog_unavailable"] = True
|
||||||
|
out["catalog_error"] = cat_err
|
||||||
|
return out
|
||||||
|
```
|
||||||
|
|
||||||
|
After the `unregister_service` handler, add:
|
||||||
|
|
||||||
|
```python
|
||||||
|
@app.post("/services/auto-register")
|
||||||
|
async def auto_register(user: dict = Depends(require_jwt)):
|
||||||
|
"""Activate local catalog services + subscribe to remote ones (per approval mode)."""
|
||||||
|
init_dirs()
|
||||||
|
local_did, priv = annuaire_client.node_identity()
|
||||||
|
catalog, cat_err = annuaire_client.get_catalog()
|
||||||
|
if cat_err:
|
||||||
|
return {"activated": 0, "requested": 0, "pending": 0, "already": 0,
|
||||||
|
"errors": [cat_err]}
|
||||||
|
subs, _ = annuaire_client.get_subscriptions(local_did)
|
||||||
|
subscribed = {s.get("service_id") for s in subs}
|
||||||
|
overlay = registry.load_overlay(str(ACTIVATION_FILE))
|
||||||
|
activated = requested = pending = already = 0
|
||||||
|
errors = []
|
||||||
|
for offer in catalog:
|
||||||
|
sid = offer.get("service_id")
|
||||||
|
if not sid:
|
||||||
|
continue
|
||||||
|
if offer.get("provider") == local_did:
|
||||||
|
registry.set_active(str(ACTIVATION_FILE), sid,
|
||||||
|
registry.port_from_endpoint(offer.get("endpoint", "")))
|
||||||
|
activated += 1
|
||||||
|
continue
|
||||||
|
if sid in subscribed or (overlay.get(sid, {}).get("subscription_id")):
|
||||||
|
already += 1
|
||||||
|
continue
|
||||||
|
if not priv or not local_did:
|
||||||
|
errors.append(f"{sid}: node has no annuaire identity (run annuairectl init)")
|
||||||
|
continue
|
||||||
|
res, err = annuaire_client.subscribe(sid, local_did, priv)
|
||||||
|
if err:
|
||||||
|
errors.append(f"{sid}: {err}")
|
||||||
|
continue
|
||||||
|
registry.set_subscription(str(ACTIVATION_FILE), sid, res.get("subscription_id", ""))
|
||||||
|
requested += 1
|
||||||
|
if res.get("state") == "pending":
|
||||||
|
pending += 1
|
||||||
|
return {"activated": activated, "requested": requested, "pending": pending,
|
||||||
|
"already": already, "errors": errors}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/services/{service_id}/request")
|
||||||
|
async def request_access(service_id: str, user: dict = Depends(require_jwt)):
|
||||||
|
"""Subscribe to a single remote service offer."""
|
||||||
|
init_dirs()
|
||||||
|
local_did, priv = annuaire_client.node_identity()
|
||||||
|
if not priv or not local_did:
|
||||||
|
return {"status": "error", "error": "node has no annuaire identity (run annuairectl init)"}
|
||||||
|
res, err = annuaire_client.subscribe(service_id, local_did, priv)
|
||||||
|
if err:
|
||||||
|
return {"status": "error", "error": err}
|
||||||
|
registry.set_subscription(str(ACTIVATION_FILE), service_id, res.get("subscription_id", ""))
|
||||||
|
return {"status": "ok", "subscription": res}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/services/{service_id}/activate")
|
||||||
|
async def activate_service(service_id: str, user: dict = Depends(require_jwt)):
|
||||||
|
"""Mark a catalog service locally active (binds the derived local port)."""
|
||||||
|
init_dirs()
|
||||||
|
catalog, _ = annuaire_client.get_catalog()
|
||||||
|
offer = next((o for o in catalog if o.get("service_id") == service_id), None)
|
||||||
|
if offer is None:
|
||||||
|
return {"status": "error", "error": "service not in catalog"}
|
||||||
|
local_did, _ = annuaire_client.node_identity()
|
||||||
|
if offer.get("provider") != local_did:
|
||||||
|
subs, _ = annuaire_client.get_subscriptions(local_did)
|
||||||
|
st = next((s.get("state") for s in subs if s.get("service_id") == service_id), None)
|
||||||
|
if st != "approved":
|
||||||
|
return {"status": "error", "error": f"remote service not approved (state={st})"}
|
||||||
|
registry.set_active(str(ACTIVATION_FILE), service_id,
|
||||||
|
registry.port_from_endpoint(offer.get("endpoint", "")))
|
||||||
|
return {"status": "ok"}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run test to verify it passes**
|
||||||
|
|
||||||
|
Run: `cd packages/secubox-p2p && python3 -m pytest tests/test_services_endpoints.py -q`
|
||||||
|
Expected: PASS (3 tests). Then run the whole suite: `python3 -m pytest tests/ -q` — all green.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add packages/secubox-p2p/api/main.py packages/secubox-p2p/tests/test_services_endpoints.py
|
||||||
|
git commit -m "feat(p2p): /services live merge + auto-register/request/activate endpoints (ref #<issue>)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 4: UI — Service Registry tab live view + Auto register all
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `packages/secubox-p2p/www/p2p/index.html` (the Services tab header + `loadServices()` render + new action functions)
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes (HTTP): `GET /services` (new row shape), `POST /services/auto-register`, `POST /services/{id}/request`, `POST /services/{id}/activate`.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add the "Auto register all" button to the Services tab header**
|
||||||
|
|
||||||
|
Find the Services tab `section-header` (around line 554) and change it to:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<div class="section-header">
|
||||||
|
<h2>Service Registry</h2>
|
||||||
|
<div>
|
||||||
|
<button class="btn" onclick="autoRegisterAll()">Auto register all</button>
|
||||||
|
<button class="btn" onclick="showRegisterServiceModal()">+ Register Service</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Replace `loadServices()` render to the new row shape**
|
||||||
|
|
||||||
|
Replace the `loadServices()` body (around line 784) with:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
async function loadServices() {
|
||||||
|
const data = await apiGet('/services');
|
||||||
|
const services = Array.isArray(data) ? data : (data && Array.isArray(data.services) ? data.services : []);
|
||||||
|
const tbody = document.getElementById('services-table');
|
||||||
|
if (data && data.catalog_unavailable) {
|
||||||
|
showNotice && showNotice('Annuaire catalog unavailable — showing local services only');
|
||||||
|
}
|
||||||
|
if (services.length > 0) {
|
||||||
|
tbody.innerHTML = services.map(svc => {
|
||||||
|
const state = svc.subscription_state || 'n/a';
|
||||||
|
const statusLabel = svc.active ? 'active' : (state === 'n/a' ? (svc.active ? 'active' : 'inactive') : state);
|
||||||
|
const badge = svc.automatable ? ' <span class="status-badge">automatable</span>' : '';
|
||||||
|
let actions = '';
|
||||||
|
if (svc.source === 'p2p-local') {
|
||||||
|
actions = `<button class="btn btn-small btn-danger" onclick="unregisterService('${encodeURIComponent(svc.name)}')">Unregister</button>`;
|
||||||
|
} else if (svc.provider_label !== 'local' && state === 'not-subscribed') {
|
||||||
|
actions = `<button class="btn btn-small" onclick="requestAccess('${svc.service_id}')">Request access</button>`;
|
||||||
|
} else if (state === 'pending') {
|
||||||
|
actions = `<span class="text-muted">awaiting approval</span>`;
|
||||||
|
} else if (!svc.active) {
|
||||||
|
actions = `<button class="btn btn-small" onclick="activateService('${svc.service_id}')">Activate</button>`;
|
||||||
|
} else {
|
||||||
|
actions = `<span class="status-badge active">active</span>`;
|
||||||
|
}
|
||||||
|
return `
|
||||||
|
<tr>
|
||||||
|
<td>${escapeHtml(svc.name)}${badge}</td>
|
||||||
|
<td>${escapeHtml(svc.type || '')}</td>
|
||||||
|
<td>${escapeHtml(svc.provider_label || 'local')}</td>
|
||||||
|
<td>${svc.port != null ? svc.port : '—'}</td>
|
||||||
|
<td><span class="status-badge ${svc.active ? 'active' : ''}">${escapeHtml(statusLabel)}</span></td>
|
||||||
|
<td>${actions}</td>
|
||||||
|
</tr>`;
|
||||||
|
}).join('');
|
||||||
|
} else {
|
||||||
|
tbody.innerHTML = '<tr><td colspan="6">No services in catalog</td></tr>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Add the action functions** (next to `loadServices`)
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
async function autoRegisterAll() {
|
||||||
|
const res = await apiPost('/services/auto-register', {});
|
||||||
|
const msg = `Activated ${res.activated||0}, requested ${res.requested||0}` +
|
||||||
|
(res.pending ? `, ${res.pending} pending approval` : '') +
|
||||||
|
(res.errors && res.errors.length ? `, ${res.errors.length} error(s)` : '');
|
||||||
|
(showNotice ? showNotice(msg) : alert(msg));
|
||||||
|
loadServices();
|
||||||
|
}
|
||||||
|
async function requestAccess(sid) {
|
||||||
|
const res = await apiPost('/services/' + encodeURIComponent(sid) + '/request', {});
|
||||||
|
(showNotice ? showNotice(res.status === 'ok' ? 'Subscription requested' : (res.error||'failed')) : 0);
|
||||||
|
loadServices();
|
||||||
|
}
|
||||||
|
async function activateService(sid) {
|
||||||
|
const res = await apiPost('/services/' + encodeURIComponent(sid) + '/activate', {});
|
||||||
|
(showNotice ? showNotice(res.status === 'ok' ? 'Activated' : (res.error||'failed')) : 0);
|
||||||
|
loadServices();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
(If `showNotice` does not exist in this file, replace the `showNotice ? ... : 0` calls with `console.log(...)` — check the file first and match the existing notification pattern.)
|
||||||
|
|
||||||
|
- [ ] **Step 4: Syntax-check the JS**
|
||||||
|
|
||||||
|
Run: `cd packages/secubox-p2p && node --check www/p2p/index.html 2>/dev/null || python3 -c "import re,sys;h=open('www/p2p/index.html').read();print('script blocks:',h.count('<script'))"`
|
||||||
|
Expected: no JS parse error (or, if `node --check` rejects HTML, extract the `<script>` block and `node --check` that). Manually confirm braces balance.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add packages/secubox-p2p/www/p2p/index.html
|
||||||
|
git commit -m "feat(p2p): Service Registry live catalog view + Auto register all UI (ref #<issue>)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 5: Packaging, build, deploy, live verify
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `packages/secubox-p2p/debian/changelog`
|
||||||
|
- Verify: `packages/secubox-p2p/debian/rules` installs `api/*.py` (confirm `api/registry.py` + `api/annuaire_client.py` are copied — they will be if rules does `cp -r api/* …/api/`).
|
||||||
|
|
||||||
|
- [ ] **Step 1: Confirm rules ships the new modules**
|
||||||
|
|
||||||
|
Run: `grep -nE "cp -r .*api|api/\*|install.*api" packages/secubox-p2p/debian/rules`
|
||||||
|
Expected: a recursive copy of `api/` (so new `.py` files ship automatically). If rules lists individual files, add `api/registry.py` and `api/annuaire_client.py`.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Add the changelog entry**
|
||||||
|
|
||||||
|
Prepend to `packages/secubox-p2p/debian/changelog`:
|
||||||
|
|
||||||
|
```
|
||||||
|
secubox-p2p (1.8.0-1~bookworm1) bookworm; urgency=medium
|
||||||
|
|
||||||
|
* feat: Service Registry is now a live view of the secubox-annuaire catalog
|
||||||
|
(#<issue>). New api/registry.py (pure merge of catalog + subscriptions +
|
||||||
|
activation overlay + legacy local services) and api/annuaire_client.py
|
||||||
|
(reads /run/secubox/annuaire.sock — never the aggregator — and subscribes
|
||||||
|
as the node using the 0600 node key). New endpoints: GET /services (live
|
||||||
|
merge), POST /services/auto-register (activate locals + subscribe remotes
|
||||||
|
per approval mode), /services/{id}/request, /services/{id}/activate. UI:
|
||||||
|
"Auto register all" button + per-service Request access / Activate + state
|
||||||
|
badges. annuaire unchanged; provider-side macro execution deferred (M2).
|
||||||
|
|
||||||
|
-- Gerald Kerma <devel@cybermind.fr> Tue, 30 Jun 2026 18:00:00 +0200
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Build the package**
|
||||||
|
|
||||||
|
Run: `cd packages/secubox-p2p && dpkg-buildpackage -us -uc -b 2>&1 | grep -iE "dpkg-deb: building|error"`
|
||||||
|
Expected: `secubox-p2p_1.8.0-1~bookworm1_all.deb` built (or `_arm64` if arch-specific — match the existing control Architecture).
|
||||||
|
|
||||||
|
- [ ] **Step 4: Deploy to gk2 + c3box and verify**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
DEB=packages/secubox-p2p_1.8.0-1~bookworm1_all.deb
|
||||||
|
for H in 192.168.1.200 192.168.1.94; do
|
||||||
|
scp "$DEB" root@$H:/tmp/
|
||||||
|
ssh root@$H 'dpkg -i /tmp/secubox-p2p_1.8.0-1~bookworm1_all.deb && systemctl restart secubox-p2p && sleep 1 && \
|
||||||
|
curl -s --unix-socket /run/secubox/p2p.sock http://x/services | python3 -c "import sys,json;d=json.load(sys.stdin);print(\"rows:\",len(d[\"services\"]),\"unavailable:\",d.get(\"catalog_unavailable\",False))"'
|
||||||
|
done
|
||||||
|
```
|
||||||
|
Expected: gk2 shows ≥1 row (the `WAF mirror` it offers + the federated `Suricata IDS feed`); c3box shows its offers + federated ones. `catalog_unavailable: False`.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Live auto-register check + commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh root@192.168.1.200 'curl -s -X POST --unix-socket /run/secubox/p2p.sock http://x/services/auto-register | python3 -m json.tool'
|
||||||
|
```
|
||||||
|
Expected: `{"activated": N, "requested": M, ...}` with no fatal errors (local WAF mirror activated; remote offers subscribed).
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add packages/secubox-p2p/debian/changelog
|
||||||
|
git commit -m "release(p2p): 1.8.0 — annuaire-backed Service Registry (ref #<issue>)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Notes for the executor
|
||||||
|
- Replace `#<issue>` with the GitHub issue number created for this work.
|
||||||
|
- Run the **full** p2p test suite (`pytest tests/ -q`) after Task 3 and again after Task 4 — `test_mesh.py` must stay green.
|
||||||
|
- The UI step has no automated test (repo convention); verify by loading `/p2p/` and clicking Auto register all after deploy.
|
||||||
|
- Do NOT touch annuaire, the macro subsystem, or the gk2→c3box reverse-federation issue — all out of scope for this plan.
|
||||||
916
docs/superpowers/plans/2026-07-01-macro-subsystem-tor-exit.md
Normal file
916
docs/superpowers/plans/2026-07-01-macro-subsystem-tor-exit.md
Normal file
|
|
@ -0,0 +1,916 @@
|
||||||
|
# Macro Subsystem (tor-exit) Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Let a service offer propose a vetted, parameterized, AppArmor-confined automation ("macro") so an approved peer can actually consume it — shipping the framework plus one real kind, `tor-exit` (SOCKS-over-mesh).
|
||||||
|
|
||||||
|
**Architecture:** Three packages. (A) **secubox-annuaire** gains an optional signed `ServiceOffer.macro = {kind, params}` descriptor that federates. (B) new **secubox-macro** ships a root dispatcher `secubox-macroctl` + vetted `macros.d/tor-exit` plugin + per-kind AppArmor + a tight sudoers allowlist. (C) **secubox-p2p** exposes a mesh-only grant endpoint (authorized by the consumer's self-signed Subscription against an `auto` offer), and the consumer's Activate pulls the credential and runs `macroctl activate`.
|
||||||
|
|
||||||
|
**Tech Stack:** Python 3.11, FastAPI, Pydantic v2, nftables (named sets), Tor (SocksPort), AppArmor, Debian packaging, pytest.
|
||||||
|
|
||||||
|
## Global Constraints
|
||||||
|
|
||||||
|
- SPDX header on every new source file: `# SPDX-License-Identifier: LicenseRef-CMSD-1.0` + the 3-line CyberMind copyright block (copy from any existing `packages/secubox-p2p/api/mesh.py`).
|
||||||
|
- **No offer-supplied code executes.** Offers carry only an allowlisted `kind` (`^[a-z][a-z0-9-]{1,31}$`) + typed `params`; `macroctl` never runs a shell on offer strings and never execs an unlisted kind.
|
||||||
|
- **annuaire executes nothing** — it only stores/serves/federates the `macro` descriptor.
|
||||||
|
- Only `secubox-macroctl` is sudo-allowed for user `secubox` (nothing else).
|
||||||
|
- `--src-ip` must be a literal IPv4 in `10.10.0.0/24`; reject otherwise.
|
||||||
|
- nft allows are per-source-IP into one port, in the named set `secubox_macro_torexit`, individually removable; never `0.0.0.0`, never the whole subnet.
|
||||||
|
- Every grant/revoke appends one line to `/var/log/secubox/audit.log` (append-only).
|
||||||
|
- Increment 1 = **`auto`-mode macro offers only** (pending deferred — spec §7).
|
||||||
|
- Version bumps: secubox-annuaire → `0.3.0-1~bookworm1`; secubox-macro → `0.1.0-1~bookworm1`; secubox-p2p → `1.9.0-1~bookworm1`.
|
||||||
|
- Tests: annuaire under `packages/secubox-annuaire/tests/` (`from annuaire...`); macro under `packages/secubox-macro/tests/`; p2p under `packages/secubox-p2p/tests/` (`from api import ...`, see conftest).
|
||||||
|
- Both secubox-annuaire and secubox-p2p run as `User=secubox`; node key `/etc/secubox/secrets/annuaire/node.key` (0600 secubox) is the node identity for both.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Package A — secubox-annuaire 0.3.0 (macro descriptor)
|
||||||
|
|
||||||
|
### Task 1: `ServiceOffer.macro` descriptor (model + sign/ingest/enrich flow)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `packages/secubox-annuaire/annuaire/model.py` (add `MacroDescriptor`, add `macro` field to `ServiceOffer`)
|
||||||
|
- Test: `packages/secubox-annuaire/tests/test_macro_offer.py`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Produces: `ServiceOffer.macro: Optional[MacroDescriptor]` where `MacroDescriptor` has `kind: str` (pattern `^[a-z][a-z0-9-]{1,31}$`) and `params: Dict[str, Union[str,int,bool]] = {}`. Default `macro=None`.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing test**
|
||||||
|
|
||||||
|
```python
|
||||||
|
# packages/secubox-annuaire/tests/test_macro_offer.py
|
||||||
|
# SPDX-License-Identifier: LicenseRef-CMSD-1.0
|
||||||
|
# Copyright (c) 2026 CyberMind — Gérald Kerma <devel@cybermind.fr>
|
||||||
|
import tempfile
|
||||||
|
import pytest
|
||||||
|
from annuaire.model import ServiceOffer, MacroDescriptor
|
||||||
|
from annuaire.log import Journal
|
||||||
|
from annuaire.crypto import generate_keypair
|
||||||
|
from annuaire.verbs import genesis, offer_service, _get_offers, ingest_offer
|
||||||
|
|
||||||
|
DID = "did:plc:" + "a" * 32
|
||||||
|
|
||||||
|
|
||||||
|
def test_macro_descriptor_validates_kind():
|
||||||
|
MacroDescriptor(kind="tor-exit", params={"socks_port": 9050})
|
||||||
|
with pytest.raises(Exception):
|
||||||
|
MacroDescriptor(kind="../evil", params={})
|
||||||
|
with pytest.raises(Exception):
|
||||||
|
MacroDescriptor(kind="Tor Exit", params={})
|
||||||
|
|
||||||
|
|
||||||
|
def test_offer_without_macro_still_valid():
|
||||||
|
o = ServiceOffer(service_id="s" * 64, provider=DID, name="n", kind="api",
|
||||||
|
endpoint="/x", sig="0" * 128, signer_did=DID)
|
||||||
|
assert o.macro is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_macro_round_trips_through_offer_and_federation():
|
||||||
|
ja = Journal(tempfile.mktemp(suffix=".db"))
|
||||||
|
pa, pub = generate_keypair()
|
||||||
|
ida = genesis(ja, pa)
|
||||||
|
offer_service(ja, pa, ida.did, name="Tor exit", kind="tor-exit",
|
||||||
|
endpoint="http://10.10.0.1/tor", approval_mode="auto",
|
||||||
|
macro={"kind": "tor-exit", "params": {"socks_port": 9050}})
|
||||||
|
wire = _get_offers(ja)[0]
|
||||||
|
assert wire["macro"]["kind"] == "tor-exit"
|
||||||
|
assert wire["macro"]["params"]["socks_port"] == 9050
|
||||||
|
# federate into a second journal (self-cert), macro preserved
|
||||||
|
jb = Journal(tempfile.mktemp(suffix=".db"))
|
||||||
|
genesis(jb, generate_keypair()[0])
|
||||||
|
allowed = set(ServiceOffer.model_fields)
|
||||||
|
offer = ServiceOffer(**{k: v for k, v in wire.items() if k in allowed})
|
||||||
|
ingest_offer(jb, offer, wire["provider_pubkey"])
|
||||||
|
assert _get_offers(jb)[0]["macro"]["kind"] == "tor-exit"
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run test to verify it fails**
|
||||||
|
|
||||||
|
Run: `cd packages/secubox-annuaire && python3 -m pytest tests/test_macro_offer.py -q`
|
||||||
|
Expected: FAIL — `ImportError: cannot import name 'MacroDescriptor'` (and `offer_service` has no `macro` kwarg yet — Task adds it in step 3).
|
||||||
|
|
||||||
|
- [ ] **Step 3: Implement the model + thread `macro` through `offer_service`**
|
||||||
|
|
||||||
|
In `packages/secubox-annuaire/annuaire/model.py`, add near the other models (all models use `model_config = ConfigDict(extra="forbid")`):
|
||||||
|
|
||||||
|
```python
|
||||||
|
class MacroDescriptor(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
kind: str = Field(..., pattern=r"^[a-z][a-z0-9-]{1,31}$")
|
||||||
|
params: Dict[str, Union[str, int, bool]] = Field(default_factory=dict)
|
||||||
|
```
|
||||||
|
|
||||||
|
Add `Union` to the existing `from typing import ...` import if absent. Add to `ServiceOffer` (after `description`, before `created_at`):
|
||||||
|
|
||||||
|
```python
|
||||||
|
macro: Optional[MacroDescriptor] = None
|
||||||
|
```
|
||||||
|
|
||||||
|
In `packages/secubox-annuaire/annuaire/verbs.py`, give `offer_service` a `macro: Optional[Dict] = None` keyword and include it in the constructed `ServiceOffer(... macro=macro ...)` (the signed payload already `model_dump()`s the whole offer, so `macro` is covered by the signature automatically; `_enrich_offer` copies `entry.payload` so it carries `macro` too — no change needed there). Confirm `ingest_offer` reconstructs it (ServiceOffer accepts the `macro` key).
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run test to verify it passes**
|
||||||
|
|
||||||
|
Run: `cd packages/secubox-annuaire && python3 -m pytest tests/test_macro_offer.py -q`
|
||||||
|
Expected: PASS (3 tests). Then `python3 -m pytest tests/ -q` — all prior tests still green.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add packages/secubox-annuaire/annuaire/model.py packages/secubox-annuaire/annuaire/verbs.py packages/secubox-annuaire/tests/test_macro_offer.py
|
||||||
|
git commit -m "feat(annuaire): optional signed ServiceOffer.macro descriptor (ref #<issueA>)"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Task 2: `annuairectl offer --macro-kind/--macro-param` + 0.3.0 changelog
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `packages/secubox-annuaire/sbin/annuairectl` (offer subcommand + cmd_offer)
|
||||||
|
- Modify: `packages/secubox-annuaire/debian/changelog`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: `offer_service(..., macro=...)` from Task 1.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add the CLI flags and pass-through**
|
||||||
|
|
||||||
|
In `annuairectl`, in the `offer` subparser (near line 289) add:
|
||||||
|
|
||||||
|
```python
|
||||||
|
po.add_argument("--macro-kind", help="macro kind, e.g. tor-exit")
|
||||||
|
po.add_argument("--macro-param", action="append", metavar="k=v",
|
||||||
|
help="macro param key=value (repeatable; ints/bools auto-typed)")
|
||||||
|
```
|
||||||
|
|
||||||
|
In `cmd_offer`, before calling `offer_service`, build the macro dict:
|
||||||
|
|
||||||
|
```python
|
||||||
|
macro = None
|
||||||
|
if args.macro_kind:
|
||||||
|
params = {}
|
||||||
|
for kv in (args.macro_param or []):
|
||||||
|
if "=" not in kv:
|
||||||
|
_die(f"--macro-param expects k=v, got {kv!r}")
|
||||||
|
k, v = kv.split("=", 1)
|
||||||
|
if v.isdigit():
|
||||||
|
v = int(v)
|
||||||
|
elif v.lower() in ("true", "false"):
|
||||||
|
v = v.lower() == "true"
|
||||||
|
params[k] = v
|
||||||
|
macro = {"kind": args.macro_kind, "params": params}
|
||||||
|
```
|
||||||
|
|
||||||
|
Pass `macro=macro` into the `offer_service(...)` call.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Smoke-test the CLI locally**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
```bash
|
||||||
|
cd packages/secubox-annuaire && TMP=$(mktemp -d)
|
||||||
|
ANNUAIRE_LIB="$PWD" ANNUAIRE_DB_PATH=$TMP/a.db ANNUAIRE_KEY_PATH=$TMP/a.key python3 sbin/annuairectl init --isolation-domain gondwana >/dev/null
|
||||||
|
ANNUAIRE_LIB="$PWD" ANNUAIRE_DB_PATH=$TMP/a.db ANNUAIRE_KEY_PATH=$TMP/a.key python3 sbin/annuairectl offer --name "Tor exit" --kind tor-exit --endpoint "http://10.10.0.1/tor" --mode auto --macro-kind tor-exit --macro-param socks_port=9050
|
||||||
|
ANNUAIRE_LIB="$PWD" ANNUAIRE_DB_PATH=$TMP/a.db ANNUAIRE_KEY_PATH=$TMP/a.key python3 sbin/annuairectl services --raw | python3 -c "import sys,json;print(json.load(sys.stdin)['services'][0]['macro'])"
|
||||||
|
rm -rf $TMP
|
||||||
|
```
|
||||||
|
Expected: prints `{'kind': 'tor-exit', 'params': {'socks_port': 9050}}`.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Bump changelog to 0.3.0**
|
||||||
|
|
||||||
|
Prepend to `packages/secubox-annuaire/debian/changelog`:
|
||||||
|
```
|
||||||
|
secubox-annuaire (0.3.0-1~bookworm1) bookworm; urgency=medium
|
||||||
|
|
||||||
|
* feat: optional signed ServiceOffer.macro descriptor {kind, params} — lets an
|
||||||
|
offer propose a vetted access macro (e.g. tor-exit). Federates in the signed
|
||||||
|
payload; annuaire executes nothing. annuairectl offer gains --macro-kind /
|
||||||
|
--macro-param. Consumed by secubox-macro + secubox-p2p 1.9.0.
|
||||||
|
|
||||||
|
-- Gerald Kerma <devel@cybermind.fr> Wed, 01 Jul 2026 10:00:00 +0200
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add packages/secubox-annuaire/sbin/annuairectl packages/secubox-annuaire/debian/changelog
|
||||||
|
git commit -m "feat(annuaire): annuairectl offer --macro-kind/--macro-param + 0.3.0 (ref #<issueA>)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Package B — secubox-macro (NEW): dispatcher + tor-exit plugin
|
||||||
|
|
||||||
|
### Task 3: scaffold package + `secubox-macroctl` dispatcher
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `packages/secubox-macro/` (debian/control, rules, changelog, compat, postinst, prerm)
|
||||||
|
- Create: `packages/secubox-macro/sbin/secubox-macroctl`
|
||||||
|
- Create: `packages/secubox-macro/tests/test_macroctl.py`, `tests/conftest.py`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Produces (CLI): `secubox-macroctl <kind> <grant|activate|revoke> [--sub DID] [--src-ip IP] [--params JSON] [--cred JSON]`. `grant` prints one JSON object to stdout. Exit 0 ok, non-zero + JSON `{"error":...}` on failure.
|
||||||
|
- Env override for tests: `MACRO_PLUGIN_DIR` (default `/usr/lib/secubox/macro/macros.d`), `MACRO_AUDIT_LOG` (default `/var/log/secubox/audit.log`), `MACRO_MESH_CIDR` (default `10.10.0.0/24`).
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing test**
|
||||||
|
|
||||||
|
```python
|
||||||
|
# packages/secubox-macro/tests/conftest.py
|
||||||
|
# SPDX-License-Identifier: LicenseRef-CMSD-1.0
|
||||||
|
import sys, pathlib
|
||||||
|
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[1] / "sbin"))
|
||||||
|
```
|
||||||
|
```python
|
||||||
|
# packages/secubox-macro/tests/test_macroctl.py
|
||||||
|
# SPDX-License-Identifier: LicenseRef-CMSD-1.0
|
||||||
|
# Copyright (c) 2026 CyberMind — Gérald Kerma <devel@cybermind.fr>
|
||||||
|
import json, os, stat, subprocess, sys, pathlib
|
||||||
|
CTL = str(pathlib.Path(__file__).resolve().parents[1] / "sbin" / "secubox-macroctl")
|
||||||
|
|
||||||
|
|
||||||
|
def _run(args, env):
|
||||||
|
return subprocess.run([sys.executable, CTL] + args, capture_output=True, text=True, env=env)
|
||||||
|
|
||||||
|
|
||||||
|
def _env(tmp_path):
|
||||||
|
d = tmp_path / "macros.d"; d.mkdir()
|
||||||
|
plug = d / "echo"
|
||||||
|
plug.write_text("#!/usr/bin/env python3\n"
|
||||||
|
"import json,sys\n"
|
||||||
|
"print(json.dumps({'ok': True, 'argv': sys.argv[1:]}))\n")
|
||||||
|
plug.chmod(0o755)
|
||||||
|
e = dict(os.environ, MACRO_PLUGIN_DIR=str(d),
|
||||||
|
MACRO_AUDIT_LOG=str(tmp_path / "audit.log"),
|
||||||
|
MACRO_MESH_CIDR="10.10.0.0/24")
|
||||||
|
return e
|
||||||
|
|
||||||
|
|
||||||
|
def test_rejects_unknown_kind(tmp_path):
|
||||||
|
r = _run(["nope", "grant"], _env(tmp_path))
|
||||||
|
assert r.returncode != 0
|
||||||
|
assert "unknown" in (r.stdout + r.stderr).lower() or "error" in (r.stdout + r.stderr).lower()
|
||||||
|
|
||||||
|
|
||||||
|
def test_rejects_path_traversal_kind(tmp_path):
|
||||||
|
r = _run(["../secrets", "grant"], _env(tmp_path))
|
||||||
|
assert r.returncode != 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_rejects_src_ip_outside_mesh(tmp_path):
|
||||||
|
r = _run(["echo", "grant", "--src-ip", "192.168.1.5"], _env(tmp_path))
|
||||||
|
assert r.returncode != 0
|
||||||
|
assert "mesh" in (r.stdout + r.stderr).lower() or "10.10.0" in (r.stdout + r.stderr)
|
||||||
|
|
||||||
|
|
||||||
|
def test_dispatches_to_plugin_and_audits(tmp_path):
|
||||||
|
env = _env(tmp_path)
|
||||||
|
r = _run(["echo", "grant", "--sub", "did:plc:" + "a"*32, "--src-ip", "10.10.0.2"], env)
|
||||||
|
assert r.returncode == 0, r.stderr
|
||||||
|
out = json.loads(r.stdout)
|
||||||
|
assert out["ok"] is True
|
||||||
|
audit = pathlib.Path(env["MACRO_AUDIT_LOG"]).read_text()
|
||||||
|
assert "echo" in audit and "grant" in audit
|
||||||
|
|
||||||
|
|
||||||
|
def test_refuses_non_root_owned_or_world_writable_plugin(tmp_path):
|
||||||
|
env = _env(tmp_path)
|
||||||
|
plug = pathlib.Path(env["MACRO_PLUGIN_DIR"]) / "echo"
|
||||||
|
plug.chmod(0o777) # world-writable → tamper risk
|
||||||
|
r = _run(["echo", "grant", "--src-ip", "10.10.0.2"], env)
|
||||||
|
assert r.returncode != 0
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run to verify it fails**
|
||||||
|
|
||||||
|
Run: `cd packages/secubox-macro && python3 -m pytest tests/ -q`
|
||||||
|
Expected: FAIL — `secubox-macroctl` does not exist.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Implement `secubox-macroctl`**
|
||||||
|
|
||||||
|
```python
|
||||||
|
# packages/secubox-macro/sbin/secubox-macroctl
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
# SPDX-License-Identifier: LicenseRef-CMSD-1.0
|
||||||
|
# Copyright (c) 2026 CyberMind — Gérald Kerma <devel@cybermind.fr>
|
||||||
|
# Source-Disclosed License — All rights reserved except as expressly granted.
|
||||||
|
# See LICENCE-CMSD-1.0.md for terms.
|
||||||
|
"""SecuBox-Deb :: secubox-macroctl — root dispatcher for vetted access macros.
|
||||||
|
|
||||||
|
Validates the kind against the installed macros.d allowlist, checks the plugin
|
||||||
|
is root-owned and not world-writable, validates --src-ip is inside the mesh
|
||||||
|
CIDR, execs the plugin verb (never via a shell, never eval'ing params), and
|
||||||
|
appends an audit line. Offers never supply code — only an allowlisted kind and
|
||||||
|
typed params.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
import argparse, ipaddress, json, os, stat, subprocess, sys, time
|
||||||
|
|
||||||
|
PLUGIN_DIR = os.environ.get("MACRO_PLUGIN_DIR", "/usr/lib/secubox/macro/macros.d")
|
||||||
|
AUDIT_LOG = os.environ.get("MACRO_AUDIT_LOG", "/var/log/secubox/audit.log")
|
||||||
|
MESH_CIDR = os.environ.get("MACRO_MESH_CIDR", "10.10.0.0/24")
|
||||||
|
KIND_RE = __import__("re").compile(r"^[a-z][a-z0-9-]{1,31}$")
|
||||||
|
VERBS = {"grant", "activate", "revoke"}
|
||||||
|
|
||||||
|
|
||||||
|
def _die(msg, code=1):
|
||||||
|
sys.stderr.write(json.dumps({"error": msg}) + "\n")
|
||||||
|
raise SystemExit(code)
|
||||||
|
|
||||||
|
|
||||||
|
def _audit(rec: dict):
|
||||||
|
try:
|
||||||
|
os.makedirs(os.path.dirname(AUDIT_LOG), exist_ok=True)
|
||||||
|
with open(AUDIT_LOG, "a", encoding="utf-8") as fh:
|
||||||
|
fh.write(json.dumps(rec) + "\n")
|
||||||
|
except Exception:
|
||||||
|
pass # audit best-effort; never block the operation on log failure
|
||||||
|
|
||||||
|
|
||||||
|
def _plugin_path(kind: str) -> str:
|
||||||
|
if not KIND_RE.match(kind):
|
||||||
|
_die(f"invalid kind {kind!r}")
|
||||||
|
p = os.path.join(PLUGIN_DIR, kind)
|
||||||
|
if os.path.dirname(os.path.realpath(p)) != os.path.realpath(PLUGIN_DIR):
|
||||||
|
_die("kind escapes plugin dir")
|
||||||
|
if not os.path.isfile(p):
|
||||||
|
_die(f"unknown kind {kind!r}")
|
||||||
|
st = os.stat(p)
|
||||||
|
if st.st_mode & stat.S_IWOTH:
|
||||||
|
_die("plugin is world-writable — refusing (tamper guard)")
|
||||||
|
if not (st.st_mode & stat.S_IXUSR):
|
||||||
|
_die("plugin not executable")
|
||||||
|
return p
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv=None) -> int:
|
||||||
|
p = argparse.ArgumentParser(prog="secubox-macroctl")
|
||||||
|
p.add_argument("kind")
|
||||||
|
p.add_argument("verb")
|
||||||
|
p.add_argument("--sub", default="")
|
||||||
|
p.add_argument("--src-ip", default="")
|
||||||
|
p.add_argument("--params", default="{}")
|
||||||
|
p.add_argument("--cred", default="{}")
|
||||||
|
a = p.parse_args(argv)
|
||||||
|
if a.verb not in VERBS:
|
||||||
|
_die(f"invalid verb {a.verb!r}")
|
||||||
|
if a.src_ip:
|
||||||
|
try:
|
||||||
|
ip = ipaddress.ip_address(a.src_ip)
|
||||||
|
if ip not in ipaddress.ip_network(MESH_CIDR):
|
||||||
|
_die(f"--src-ip {a.src_ip} not in mesh {MESH_CIDR}")
|
||||||
|
except ValueError:
|
||||||
|
_die(f"--src-ip {a.src_ip!r} is not a valid IPv4")
|
||||||
|
try:
|
||||||
|
json.loads(a.params); json.loads(a.cred)
|
||||||
|
except ValueError:
|
||||||
|
_die("--params/--cred must be JSON")
|
||||||
|
plugin = _plugin_path(a.kind)
|
||||||
|
cmd = [plugin, a.verb, "--sub", a.sub, "--src-ip", a.src_ip,
|
||||||
|
"--params", a.params, "--cred", a.cred]
|
||||||
|
proc = subprocess.run(cmd, capture_output=True, text=True) # no shell
|
||||||
|
_audit({"ts": int(time.time()), "kind": a.kind, "verb": a.verb,
|
||||||
|
"sub": a.sub, "src_ip": a.src_ip, "rc": proc.returncode})
|
||||||
|
if proc.stdout:
|
||||||
|
sys.stdout.write(proc.stdout)
|
||||||
|
if proc.returncode != 0 and proc.stderr:
|
||||||
|
sys.stderr.write(proc.stderr)
|
||||||
|
return proc.returncode
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
|
```
|
||||||
|
|
||||||
|
Note: `time.time()` is allowed here (real CLI, not a workflow script).
|
||||||
|
|
||||||
|
- [ ] **Step 4: Create the Debian package skeleton**
|
||||||
|
|
||||||
|
Create `packages/secubox-macro/debian/control`:
|
||||||
|
```
|
||||||
|
Source: secubox-macro
|
||||||
|
Section: admin
|
||||||
|
Priority: optional
|
||||||
|
Maintainer: Gerald Kerma <devel@cybermind.fr>
|
||||||
|
Build-Depends: debhelper-compat (= 13)
|
||||||
|
Standards-Version: 4.6.2
|
||||||
|
|
||||||
|
Package: secubox-macro
|
||||||
|
Architecture: all
|
||||||
|
Depends: ${misc:Depends}, python3, nftables, secubox-core
|
||||||
|
Recommends: tor
|
||||||
|
Description: SecuBox vetted access-macro framework + tor-exit kind
|
||||||
|
Root dispatcher secubox-macroctl and the vetted tor-exit macro that offers a
|
||||||
|
node's Tor SOCKS port to approved mesh peers.
|
||||||
|
```
|
||||||
|
Create `debian/compat` = `13`. Create `debian/changelog` with a `secubox-macro (0.1.0-1~bookworm1) bookworm; urgency=medium` entry (mirror the format of an existing package's changelog; one bullet describing the framework + tor-exit). Create `debian/rules`:
|
||||||
|
```make
|
||||||
|
#!/usr/bin/make -f
|
||||||
|
%:
|
||||||
|
dh $@
|
||||||
|
|
||||||
|
override_dh_auto_install:
|
||||||
|
install -d $(CURDIR)/debian/secubox-macro/usr/sbin
|
||||||
|
install -m 755 sbin/secubox-macroctl $(CURDIR)/debian/secubox-macro/usr/sbin/
|
||||||
|
install -d $(CURDIR)/debian/secubox-macro/usr/lib/secubox/macro/macros.d
|
||||||
|
install -m 755 macros.d/tor-exit $(CURDIR)/debian/secubox-macro/usr/lib/secubox/macro/macros.d/
|
||||||
|
install -d $(CURDIR)/debian/secubox-macro/etc/sudoers.d
|
||||||
|
install -m 440 sudoers.d/secubox-macro $(CURDIR)/debian/secubox-macro/etc/sudoers.d/
|
||||||
|
install -d $(CURDIR)/debian/secubox-macro/etc/apparmor.d
|
||||||
|
install -m 644 apparmor/secubox-macroctl $(CURDIR)/debian/secubox-macro/etc/apparmor.d/
|
||||||
|
install -d $(CURDIR)/debian/secubox-macro/etc/tor/torrc.d
|
||||||
|
install -m 644 conf/secubox-macro-tor-exit.conf.example $(CURDIR)/debian/secubox-macro/usr/share/secubox/macro/
|
||||||
|
```
|
||||||
|
(The `tor-exit` plugin, sudoers, apparmor, and conf files are created in Tasks 4–5; `dpkg-buildpackage` is only run in Task 8 after they exist.)
|
||||||
|
|
||||||
|
- [ ] **Step 5: Run tests + commit**
|
||||||
|
|
||||||
|
Run: `cd packages/secubox-macro && python3 -m pytest tests/ -q` → 5 pass.
|
||||||
|
```bash
|
||||||
|
git add packages/secubox-macro/sbin packages/secubox-macro/tests packages/secubox-macro/debian
|
||||||
|
git commit -m "feat(macro): scaffold secubox-macro + secubox-macroctl dispatcher (ref #<issueB>)"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Task 4: `macros.d/tor-exit` plugin
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `packages/secubox-macro/macros.d/tor-exit`
|
||||||
|
- Test: `packages/secubox-macro/tests/test_tor_exit.py`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: invoked by macroctl as `tor-exit <verb> --sub DID --src-ip IP --params JSON --cred JSON`.
|
||||||
|
- Produces: `grant` prints `{"kind":"tor-exit","endpoint":"<mesh-ip>:<port>"}`; applies nft set add. `revoke` removes it. `activate` writes consumer state.
|
||||||
|
- Env override for tests: `TOREXIT_NFT` (default `nft`), `TOREXIT_MESH_IP` (default: first wg-mesh IPv4), `TOREXIT_STATE_DIR` (default `/var/lib/secubox/macro/active`), `TOREXIT_SET` (default `secubox_macro_torexit`), `TOREXIT_TABLE` (default `inet secubox_filter`).
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing test**
|
||||||
|
|
||||||
|
```python
|
||||||
|
# packages/secubox-macro/tests/test_tor_exit.py
|
||||||
|
# SPDX-License-Identifier: LicenseRef-CMSD-1.0
|
||||||
|
# Copyright (c) 2026 CyberMind — Gérald Kerma <devel@cybermind.fr>
|
||||||
|
import json, os, subprocess, sys, pathlib
|
||||||
|
PLUG = str(pathlib.Path(__file__).resolve().parents[1] / "macros.d" / "tor-exit")
|
||||||
|
|
||||||
|
|
||||||
|
def _fake_nft(tmp_path):
|
||||||
|
# a fake `nft` that records its argv to a file and exits 0
|
||||||
|
d = tmp_path / "bin"; d.mkdir()
|
||||||
|
rec = tmp_path / "nft.calls"
|
||||||
|
fake = d / "nft"
|
||||||
|
fake.write_text("#!/usr/bin/env bash\necho \"$@\" >> " + str(rec) + "\n")
|
||||||
|
fake.chmod(0o755)
|
||||||
|
return str(fake), rec
|
||||||
|
|
||||||
|
|
||||||
|
def _env(tmp_path):
|
||||||
|
fake, rec = _fake_nft(tmp_path)
|
||||||
|
return dict(os.environ, TOREXIT_NFT=fake, TOREXIT_MESH_IP="10.10.0.1",
|
||||||
|
TOREXIT_STATE_DIR=str(tmp_path / "active"),
|
||||||
|
TOREXIT_SET="secubox_macro_torexit", TOREXIT_TABLE="inet secubox_filter"), rec
|
||||||
|
|
||||||
|
|
||||||
|
def _run(args, env):
|
||||||
|
return subprocess.run([PLUG] + args, capture_output=True, text=True, env=env)
|
||||||
|
|
||||||
|
|
||||||
|
def test_grant_emits_endpoint_and_adds_set(tmp_path):
|
||||||
|
env, rec = _env(tmp_path)
|
||||||
|
r = _run(["grant", "--sub", "did:plc:"+"a"*32, "--src-ip", "10.10.0.2",
|
||||||
|
"--params", json.dumps({"socks_port": 9050})], env)
|
||||||
|
assert r.returncode == 0, r.stderr
|
||||||
|
out = json.loads(r.stdout)
|
||||||
|
assert out["endpoint"] == "10.10.0.1:9050"
|
||||||
|
calls = rec.read_text()
|
||||||
|
assert "10.10.0.2" in calls and "secubox_macro_torexit" in calls and "add" in calls
|
||||||
|
|
||||||
|
|
||||||
|
def test_revoke_removes_set(tmp_path):
|
||||||
|
env, rec = _env(tmp_path)
|
||||||
|
r = _run(["revoke", "--sub", "did:plc:"+"a"*32, "--src-ip", "10.10.0.2",
|
||||||
|
"--params", "{}"], env)
|
||||||
|
assert r.returncode == 0
|
||||||
|
assert "delete" in rec.read_text() and "10.10.0.2" in rec.read_text()
|
||||||
|
|
||||||
|
|
||||||
|
def test_activate_writes_state(tmp_path):
|
||||||
|
env, _ = _env(tmp_path)
|
||||||
|
r = _run(["activate", "--cred", json.dumps({"endpoint": "10.10.0.1:9050",
|
||||||
|
"service_id": "svc1"})], env)
|
||||||
|
assert r.returncode == 0
|
||||||
|
st = pathlib.Path(env["TOREXIT_STATE_DIR"]) / "svc1.json"
|
||||||
|
assert st.exists() and "10.10.0.1:9050" in st.read_text()
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run to verify it fails**
|
||||||
|
|
||||||
|
Run: `cd packages/secubox-macro && python3 -m pytest tests/test_tor_exit.py -q`
|
||||||
|
Expected: FAIL — plugin missing.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Implement `macros.d/tor-exit`**
|
||||||
|
|
||||||
|
```python
|
||||||
|
# packages/secubox-macro/macros.d/tor-exit
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
# SPDX-License-Identifier: LicenseRef-CMSD-1.0
|
||||||
|
# Copyright (c) 2026 CyberMind — Gérald Kerma <devel@cybermind.fr>
|
||||||
|
# Source-Disclosed License — All rights reserved except as expressly granted.
|
||||||
|
# See LICENCE-CMSD-1.0.md for terms.
|
||||||
|
"""SecuBox-Deb :: macros.d/tor-exit — offer a node's Tor SOCKS to mesh peers.
|
||||||
|
|
||||||
|
grant : nft-allow the subscriber's mesh IP into the Tor SocksPort; emit endpoint.
|
||||||
|
revoke : remove that allow.
|
||||||
|
activate: (consumer side) record the SOCKS endpoint for the operator.
|
||||||
|
Run only via secubox-macroctl (root, AppArmor-confined). No shell on inputs.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
import argparse, json, os, subprocess, sys
|
||||||
|
|
||||||
|
NFT = os.environ.get("TOREXIT_NFT", "nft")
|
||||||
|
MESH_IP = os.environ.get("TOREXIT_MESH_IP") or ""
|
||||||
|
STATE_DIR = os.environ.get("TOREXIT_STATE_DIR", "/var/lib/secubox/macro/active")
|
||||||
|
SET = os.environ.get("TOREXIT_SET", "secubox_macro_torexit")
|
||||||
|
TABLE = os.environ.get("TOREXIT_TABLE", "inet secubox_filter")
|
||||||
|
|
||||||
|
|
||||||
|
def _mesh_ip() -> str:
|
||||||
|
if MESH_IP:
|
||||||
|
return MESH_IP
|
||||||
|
out = subprocess.run(["ip", "-4", "-o", "addr", "show", "wg-mesh"],
|
||||||
|
capture_output=True, text=True).stdout
|
||||||
|
for tok in out.split():
|
||||||
|
if "/" in tok and tok.split("/")[0].startswith("10.10.0."):
|
||||||
|
return tok.split("/")[0]
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def _nft(*args) -> int:
|
||||||
|
return subprocess.run([NFT, *args]).returncode
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
p = argparse.ArgumentParser()
|
||||||
|
p.add_argument("verb")
|
||||||
|
p.add_argument("--sub", default=""); p.add_argument("--src-ip", default="")
|
||||||
|
p.add_argument("--params", default="{}"); p.add_argument("--cred", default="{}")
|
||||||
|
a = p.parse_args()
|
||||||
|
params = json.loads(a.params)
|
||||||
|
port = int(params.get("socks_port", 9050))
|
||||||
|
|
||||||
|
if a.verb == "grant":
|
||||||
|
ip = _mesh_ip()
|
||||||
|
if not ip:
|
||||||
|
print(json.dumps({"error": "no wg-mesh ip / tor-exit not provisioned"})); return 2
|
||||||
|
# named set element add (idempotent); the base set + rule are created by postinst
|
||||||
|
rc = _nft("add", "element", *TABLE.split(), SET, "{", a.src_ip, "}")
|
||||||
|
if rc != 0:
|
||||||
|
print(json.dumps({"error": "nft add failed"})); return 3
|
||||||
|
print(json.dumps({"kind": "tor-exit", "endpoint": f"{ip}:{port}"}))
|
||||||
|
return 0
|
||||||
|
if a.verb == "revoke":
|
||||||
|
_nft("delete", "element", *TABLE.split(), SET, "{", a.src_ip, "}")
|
||||||
|
print(json.dumps({"ok": True})); return 0
|
||||||
|
if a.verb == "activate":
|
||||||
|
cred = json.loads(a.cred)
|
||||||
|
os.makedirs(STATE_DIR, exist_ok=True)
|
||||||
|
sid = cred.get("service_id", "unknown")
|
||||||
|
with open(os.path.join(STATE_DIR, f"{sid}.json"), "w") as fh:
|
||||||
|
json.dump(cred, fh)
|
||||||
|
print(json.dumps({"ok": True, "socks": cred.get("endpoint")})); return 0
|
||||||
|
print(json.dumps({"error": f"unknown verb {a.verb}"})); return 1
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run tests + commit**
|
||||||
|
|
||||||
|
Run: `cd packages/secubox-macro && python3 -m pytest tests/ -q` → 8 pass (5 macroctl + 3 tor-exit).
|
||||||
|
```bash
|
||||||
|
git add packages/secubox-macro/macros.d/tor-exit packages/secubox-macro/tests/test_tor_exit.py
|
||||||
|
git commit -m "feat(macro): tor-exit plugin (nft SOCKS-over-mesh grant/revoke/activate) (ref #<issueB>)"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Task 5: AppArmor, sudoers, postinst (Tor SocksPort + nft base set)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `packages/secubox-macro/sudoers.d/secubox-macro`
|
||||||
|
- Create: `packages/secubox-macro/apparmor/secubox-macroctl`
|
||||||
|
- Create: `packages/secubox-macro/conf/secubox-macro-tor-exit.conf.example`
|
||||||
|
- Create: `packages/secubox-macro/debian/postinst`, `debian/prerm`
|
||||||
|
|
||||||
|
- [ ] **Step 1: sudoers (validate offline)**
|
||||||
|
|
||||||
|
`packages/secubox-macro/sudoers.d/secubox-macro`:
|
||||||
|
```
|
||||||
|
# Allow the secubox service user to invoke ONLY the macro dispatcher as root.
|
||||||
|
secubox ALL=(root) NOPASSWD: /usr/sbin/secubox-macroctl
|
||||||
|
```
|
||||||
|
Validate: `visudo -cf packages/secubox-macro/sudoers.d/secubox-macro` → "parsed OK".
|
||||||
|
|
||||||
|
- [ ] **Step 2: AppArmor profile**
|
||||||
|
|
||||||
|
`packages/secubox-macro/apparmor/secubox-macroctl` (enforce; mirror the structure of `packages/secubox-eye-square/debian/secubox-eye-square/etc/apparmor.d/secubox-eye-square-helper`):
|
||||||
|
```
|
||||||
|
#include <tunables/global>
|
||||||
|
/usr/sbin/secubox-macroctl {
|
||||||
|
#include <abstractions/base>
|
||||||
|
#include <abstractions/python>
|
||||||
|
/usr/sbin/secubox-macroctl r,
|
||||||
|
/usr/bin/python3* rix,
|
||||||
|
/usr/lib/secubox/macro/macros.d/* rix,
|
||||||
|
/usr/sbin/nft rix,
|
||||||
|
/sbin/nft rix,
|
||||||
|
/usr/bin/ip rix,
|
||||||
|
/etc/tor/torrc.d/ r,
|
||||||
|
/var/lib/secubox/macro/** rw,
|
||||||
|
/var/log/secubox/audit.log w,
|
||||||
|
network inet stream,
|
||||||
|
network netlink raw,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Tor SocksPort example + postinst**
|
||||||
|
|
||||||
|
`packages/secubox-macro/conf/secubox-macro-tor-exit.conf.example`:
|
||||||
|
```
|
||||||
|
# Rendered by postinst into /etc/tor/torrc.d/secubox-macro-tor-exit.conf with the
|
||||||
|
# node wg-mesh IP substituted. A DEDICATED SocksPort — does not touch existing Tor config.
|
||||||
|
SocksPort __MESH_IP__:9050
|
||||||
|
```
|
||||||
|
`packages/secubox-macro/debian/postinst` (`#!/bin/sh` + `set -e`, `configure` case):
|
||||||
|
```sh
|
||||||
|
install -d -m 0750 /var/lib/secubox/macro/active /var/lib/secubox/macro/grants
|
||||||
|
chown -R secubox:secubox /var/lib/secubox/macro 2>/dev/null || true
|
||||||
|
# nft base set + rule (only if the secubox_filter table exists)
|
||||||
|
if nft list table inet secubox_filter >/dev/null 2>&1; then
|
||||||
|
nft list set inet secubox_filter secubox_macro_torexit >/dev/null 2>&1 || \
|
||||||
|
nft add set inet secubox_filter secubox_macro_torexit '{ type ipv4_addr; }' 2>/dev/null || true
|
||||||
|
nft add rule inet secubox_filter input iifname "wg-mesh" ip saddr @secubox_macro_torexit tcp dport 9050 accept 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
# Tor SocksPort on the mesh IP (dedicated file; reload tor if present)
|
||||||
|
MESH_IP=$(ip -4 -o addr show wg-mesh 2>/dev/null | awk '{print $4}' | cut -d/ -f1 | head -n1)
|
||||||
|
if [ -n "$MESH_IP" ] && [ -f /usr/share/secubox/macro/secubox-macro-tor-exit.conf.example ]; then
|
||||||
|
sed "s/__MESH_IP__/$MESH_IP/g" /usr/share/secubox/macro/secubox-macro-tor-exit.conf.example > /etc/tor/torrc.d/secubox-macro-tor-exit.conf
|
||||||
|
systemctl reload tor 2>/dev/null || systemctl restart tor 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
apparmor_parser -r -W /etc/apparmor.d/secubox-macroctl 2>/dev/null || true
|
||||||
|
```
|
||||||
|
Add a matching `debian/prerm` (`remove` case: `rm -f /etc/tor/torrc.d/secubox-macro-tor-exit.conf`, drop the nft rule best-effort). Also fix the Task-3 rules `override_dh_auto_install` to install the conf to `/usr/share/secubox/macro/` (create the dir) — verify the path matches postinst.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add packages/secubox-macro/sudoers.d packages/secubox-macro/apparmor packages/secubox-macro/conf packages/secubox-macro/debian/postinst packages/secubox-macro/debian/prerm packages/secubox-macro/debian/rules
|
||||||
|
git commit -m "feat(macro): sudoers + AppArmor + postinst (Tor SocksPort, nft base set) (ref #<issueB>)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Package C — secubox-p2p 1.9.0 (grant endpoint + activate + mesh listener + UI)
|
||||||
|
|
||||||
|
### Task 6: provider grant endpoint (self-signed Subscription auth)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `packages/secubox-p2p/api/macro_grant.py` (auth + subprocess wrapper)
|
||||||
|
- Modify: `packages/secubox-p2p/api/main.py` (mount `POST /api/v1/p2p-macro/grant/{service_id}`)
|
||||||
|
- Test: `packages/secubox-p2p/tests/test_macro_grant.py`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: annuaire self-cert verify (reuse `annuaire_client.did_from_pubkey_hex` from M1) + `registry`/`annuaire_client.get_catalog` (M1) to find the local offer and its `approval_mode`/`macro`.
|
||||||
|
- Produces (HTTP): `POST /api/v1/p2p-macro/grant/{service_id}` body `{subscriber, service_id, sig, signer_did, subscriber_pubkey}` → 200 `{endpoint,...}` | 403.
|
||||||
|
- Produces (fn): `authorize_grant(offer, sub_body, verify_fn) -> (ok: bool, reason: str)` (pure, testable).
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing test** (pure authorize_grant logic — sig verify injected)
|
||||||
|
|
||||||
|
```python
|
||||||
|
# packages/secubox-p2p/tests/test_macro_grant.py
|
||||||
|
# SPDX-License-Identifier: LicenseRef-CMSD-1.0
|
||||||
|
# Copyright (c) 2026 CyberMind — Gérald Kerma <devel@cybermind.fr>
|
||||||
|
from api import macro_grant
|
||||||
|
|
||||||
|
DID = "did:plc:" + "b" * 32
|
||||||
|
|
||||||
|
|
||||||
|
def _offer(mode="auto", macro=True):
|
||||||
|
o = {"service_id": "svc1", "provider": "did:plc:"+"c"*32, "approval_mode": mode}
|
||||||
|
if macro:
|
||||||
|
o["macro"] = {"kind": "tor-exit", "params": {"socks_port": 9050}}
|
||||||
|
return o
|
||||||
|
|
||||||
|
|
||||||
|
def _sub(service_id="svc1", subscriber=DID):
|
||||||
|
return {"subscriber": subscriber, "service_id": service_id, "sig": "ok",
|
||||||
|
"signer_did": subscriber, "subscriber_pubkey": "deadbeef"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_authorize_ok_when_sig_valid_service_matches_and_auto():
|
||||||
|
ok, why = macro_grant.authorize_grant(_offer(), _sub(), verify_fn=lambda s: True)
|
||||||
|
assert ok, why
|
||||||
|
|
||||||
|
|
||||||
|
def test_reject_when_sig_invalid():
|
||||||
|
ok, why = macro_grant.authorize_grant(_offer(), _sub(), verify_fn=lambda s: False)
|
||||||
|
assert not ok and "sig" in why.lower()
|
||||||
|
|
||||||
|
|
||||||
|
def test_reject_when_service_id_mismatch():
|
||||||
|
ok, why = macro_grant.authorize_grant(_offer(), _sub(service_id="other"),
|
||||||
|
verify_fn=lambda s: True)
|
||||||
|
assert not ok
|
||||||
|
|
||||||
|
|
||||||
|
def test_reject_when_offer_pending():
|
||||||
|
ok, why = macro_grant.authorize_grant(_offer(mode="pending"), _sub(),
|
||||||
|
verify_fn=lambda s: True)
|
||||||
|
assert not ok and "auto" in why.lower()
|
||||||
|
|
||||||
|
|
||||||
|
def test_reject_when_no_macro():
|
||||||
|
ok, why = macro_grant.authorize_grant(_offer(macro=False), _sub(),
|
||||||
|
verify_fn=lambda s: True)
|
||||||
|
assert not ok
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run to verify it fails**
|
||||||
|
|
||||||
|
Run: `cd packages/secubox-p2p && python3 -m pytest tests/test_macro_grant.py -q` → FAIL (module missing).
|
||||||
|
|
||||||
|
- [ ] **Step 3: Implement `api/macro_grant.py`**
|
||||||
|
|
||||||
|
```python
|
||||||
|
# packages/secubox-p2p/api/macro_grant.py
|
||||||
|
# SPDX-License-Identifier: LicenseRef-CMSD-1.0
|
||||||
|
# Copyright (c) 2026 CyberMind — Gérald Kerma <devel@cybermind.fr>
|
||||||
|
# Source-Disclosed License — All rights reserved except as expressly granted.
|
||||||
|
# See LICENCE-CMSD-1.0.md for terms.
|
||||||
|
"""SecuBox-Deb :: secubox-p2p :: macro_grant
|
||||||
|
|
||||||
|
Provider-side authorization + invocation for macro grants. A consumer presents
|
||||||
|
its self-signed Subscription; we authorize self-certifyingly against an auto
|
||||||
|
offer, then run `sudo secubox-macroctl <kind> grant`. No federated state needed.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
import json, subprocess
|
||||||
|
from typing import Callable, Dict, Optional, Tuple
|
||||||
|
|
||||||
|
|
||||||
|
def authorize_grant(offer: Dict, sub: Dict, verify_fn: Callable[[Dict], bool]) -> Tuple[bool, str]:
|
||||||
|
"""Return (ok, reason). offer = local ServiceOffer dict; sub = presented
|
||||||
|
Subscription dict (+subscriber_pubkey). verify_fn(sub) checks the signature
|
||||||
|
self-certifyingly (pubkey hashes to subscriber AND sig verifies)."""
|
||||||
|
if not offer or not offer.get("macro"):
|
||||||
|
return False, "offer has no macro"
|
||||||
|
if offer.get("approval_mode") != "auto":
|
||||||
|
return False, "only auto-mode macro offers are grantable (pending unsupported)"
|
||||||
|
if sub.get("service_id") != offer.get("service_id"):
|
||||||
|
return False, "subscription service_id mismatch"
|
||||||
|
if not verify_fn(sub):
|
||||||
|
return False, "subscription signature invalid (self-cert failed)"
|
||||||
|
return True, "ok"
|
||||||
|
|
||||||
|
|
||||||
|
def run_grant(kind: str, sub_did: str, src_ip: str, params: Dict) -> Tuple[Optional[Dict], Optional[str]]:
|
||||||
|
"""sudo secubox-macroctl <kind> grant ... → (credential_dict, error)."""
|
||||||
|
cmd = ["sudo", "-n", "/usr/sbin/secubox-macroctl", kind, "grant",
|
||||||
|
"--sub", sub_did, "--src-ip", src_ip, "--params", json.dumps(params)]
|
||||||
|
try:
|
||||||
|
p = subprocess.run(cmd, capture_output=True, text=True, timeout=15)
|
||||||
|
except Exception as e: # noqa: BLE001
|
||||||
|
return None, f"{type(e).__name__}: {e}"
|
||||||
|
if p.returncode != 0:
|
||||||
|
return None, (p.stderr or p.stdout or "macroctl grant failed").strip()
|
||||||
|
try:
|
||||||
|
return json.loads(p.stdout), None
|
||||||
|
except ValueError:
|
||||||
|
return None, "grant produced non-JSON"
|
||||||
|
```
|
||||||
|
|
||||||
|
In `api/main.py` add the endpoint (self-cert verify reuses `annuaire_client.did_from_pubkey_hex`; the ed25519 sig check mirrors annuaire — verify `subscriber_pubkey` hashes to `subscriber` and the sig covers the canonical Subscription payload):
|
||||||
|
|
||||||
|
```python
|
||||||
|
@app.post("/api/v1/p2p-macro/grant/{service_id}")
|
||||||
|
async def macro_grant_endpoint(service_id: str, req: Request):
|
||||||
|
from api import macro_grant, annuaire_client, registry # noqa: PLC0415
|
||||||
|
body = await req.json()
|
||||||
|
catalog, _ = annuaire_client.get_catalog()
|
||||||
|
offer = next((o for o in catalog if o.get("service_id") == service_id), None)
|
||||||
|
if offer is None:
|
||||||
|
return JSONResponse({"error": "unknown service"}, status_code=404)
|
||||||
|
|
||||||
|
def _verify(sub):
|
||||||
|
pub = sub.get("subscriber_pubkey", "")
|
||||||
|
if not pub or annuaire_client.did_from_pubkey_hex(pub) != sub.get("subscriber"):
|
||||||
|
return False
|
||||||
|
return _verify_subscription_sig(sub, pub) # ed25519 over canonical payload
|
||||||
|
|
||||||
|
ok, why = macro_grant.authorize_grant(offer, body, _verify)
|
||||||
|
if not ok:
|
||||||
|
return JSONResponse({"error": why}, status_code=403)
|
||||||
|
src_ip = (req.client.host if req.client else "") or req.headers.get("x-real-ip", "")
|
||||||
|
cred, err = macro_grant.run_grant(offer["macro"]["kind"], body["subscriber"],
|
||||||
|
src_ip, offer["macro"].get("params", {}))
|
||||||
|
if err:
|
||||||
|
return JSONResponse({"error": err}, status_code=502)
|
||||||
|
cred["service_id"] = service_id
|
||||||
|
return cred
|
||||||
|
```
|
||||||
|
|
||||||
|
Add `_verify_subscription_sig(sub, pub_hex)` next to it: reconstruct the signed payload (`{k:v for k,v in sub if k not in ("sig","signer_did","subscriber_pubkey")}`), canonical-encode it the same way annuaire does (JSON `sort_keys=True, separators=(",",":")`), and ed25519-verify `sub["sig"]` with `cryptography`. (Import `Request`/`JSONResponse` from fastapi if not already.)
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run tests + commit**
|
||||||
|
|
||||||
|
Run: `cd packages/secubox-p2p && python3 -m pytest tests/test_macro_grant.py -q` → 5 pass; then `python3 -m pytest tests/ -q` all green.
|
||||||
|
```bash
|
||||||
|
git add packages/secubox-p2p/api/macro_grant.py packages/secubox-p2p/api/main.py packages/secubox-p2p/tests/test_macro_grant.py
|
||||||
|
git commit -m "feat(p2p): macro grant endpoint — self-signed Subscription auth over auto offer (ref #<issueC>)"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Task 7: consumer activate pulls credential + revoke-access + mesh listener
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `packages/secubox-p2p/api/main.py` (extend `/services/{id}/activate`; add `/services/{id}/revoke-access`)
|
||||||
|
- Create: `packages/secubox-p2p/nginx/p2p-macro-mesh.conf.tpl` (mesh listener for the grant endpoint)
|
||||||
|
- Modify: `packages/secubox-p2p/debian/{rules,postinst,postrm}` (render/remove mesh listener)
|
||||||
|
- Test: extend `packages/secubox-p2p/tests/test_services_endpoints.py`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: `macro_grant` (Task 6), `annuaire_client` (M1, node identity + catalog), `registry` (M1 overlay).
|
||||||
|
- Produces (HTTP): `POST /services/{id}/activate` (now pulls credential for automatable remote offers, runs `sudo macroctl activate`, records overlay + endpoint); `POST /services/{id}/revoke-access` (`sudo macroctl revoke`).
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing test** (activate pulls + calls macroctl activate; monkeypatch the pull + subprocess)
|
||||||
|
|
||||||
|
```python
|
||||||
|
def test_activate_remote_macro_pulls_and_runs_activate(client, monkeypatch):
|
||||||
|
import api.main as m
|
||||||
|
called = {}
|
||||||
|
monkeypatch.setattr(m, "_pull_grant", lambda offer: ({"endpoint": "10.10.0.1:9050",
|
||||||
|
"kind": "tor-exit", "service_id": "s2"}, None))
|
||||||
|
monkeypatch.setattr(m, "_macroctl_activate", lambda kind, cred: (True, None) or called.setdefault("cred", cred))
|
||||||
|
r = client.post("/services/s2/activate")
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert r.json().get("status") == "ok"
|
||||||
|
```
|
||||||
|
(Adapt to the fixture in test_services_endpoints.py; `s2` is the remote tor-exit offer — extend the fixture's catalog to include a `macro` on `s2` and mark it approved.)
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run to verify it fails**, then **Step 3: implement**:
|
||||||
|
- `_pull_grant(offer)`: build the consumer's self-signed Subscription (`annuaire_client` node identity + sign a `{subscriber, service_id, requested_at}` payload; include `subscriber_pubkey`), POST it to `http://<provider-mesh-ip>:8798/api/v1/p2p-macro/grant/<sid>` (derive provider mesh IP from the offer endpoint / a mesh lookup), return `(cred, err)`. Never raises.
|
||||||
|
- `_macroctl_activate(kind, cred)`: `sudo -n /usr/sbin/secubox-macroctl <kind> activate --cred <json>` → `(ok, err)`.
|
||||||
|
- Extend `activate_service`: if `offer.macro` and remote → `_pull_grant` → `_macroctl_activate` → on success set overlay active + store the endpoint; return `{status:ok, endpoint}`. Non-macro path unchanged (M1).
|
||||||
|
- Add `revoke_access` endpoint: `sudo -n macroctl <kind> revoke --sub <did> --src-ip <our-mesh-ip>` + clear overlay active.
|
||||||
|
- Mesh listener `nginx/p2p-macro-mesh.conf.tpl`: mirror #766's annuaire mesh listener but bind `__MESH_IP__:8798`, `allow 10.10.0.0/24; deny all;`, `location = /api/v1/p2p-macro/grant` → `proxy_pass unix:/run/secubox/p2p.sock` (note: this is a prefix; use a `location ~ ^/api/v1/p2p-macro/grant/` regex). postinst renders it with the wg-mesh IP + validates with `nginx -t` (revert on failure, per #766 pattern); postrm removes it; also add the nft allow for tcp dport 8798 on wg-mesh from 10.10.0.0/24 (same drop-in pattern).
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run full p2p suite + commit**
|
||||||
|
|
||||||
|
Run: `cd packages/secubox-p2p && python3 -m pytest tests/ -q` → all green.
|
||||||
|
```bash
|
||||||
|
git add packages/secubox-p2p/api/main.py packages/secubox-p2p/nginx packages/secubox-p2p/debian packages/secubox-p2p/tests/test_services_endpoints.py
|
||||||
|
git commit -m "feat(p2p): consumer activate pulls macro credential + mesh listener + revoke-access (ref #<issueC>)"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Task 8: UI + p2p 1.9.0 packaging + build all three + live gk2↔c3box demo
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `packages/secubox-p2p/www/p2p/index.html` (Activate shows SOCKS endpoint; Revoke-access button on active macro rows)
|
||||||
|
- Modify: `packages/secubox-p2p/debian/changelog` (1.9.0)
|
||||||
|
|
||||||
|
- [ ] **Step 1: UI** — in `loadServices()`, for rows where `svc.automatable` and `svc.active`, show the stored SOCKS endpoint (from `GET /services` — extend the merge to surface `activation.overlay[sid].endpoint`) and a "Revoke access" button calling `POST /services/{id}/revoke-access`. `node --check` the extracted script block.
|
||||||
|
|
||||||
|
- [ ] **Step 2: changelog 1.9.0** — prepend an entry describing the macro grant endpoint, consumer activate credential-pull, mesh listener (8798), and the UI. `-- Gerald Kerma ... Wed, 01 Jul 2026 ...`.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Build all three packages**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
for P in secubox-annuaire secubox-macro secubox-p2p; do
|
||||||
|
(cd packages/$P && dpkg-buildpackage -us -uc -b 2>&1 | grep -iE "dpkg-deb: building|error")
|
||||||
|
done
|
||||||
|
ls packages/secubox-annuaire_0.3.0-*_all.deb packages/secubox-macro_0.1.0-*_all.deb packages/secubox-p2p_1.9.0-*_all.deb
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Deploy + live demo (gk2 provider, c3box consumer)**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# deploy all three to gk2 + c3box, restart annuaire + p2p, reload nginx
|
||||||
|
# gk2: offer a tor-exit service
|
||||||
|
ssh root@192.168.1.200 'runuser -u secubox -- annuairectl offer --name "Tor exit" --kind tor-exit \
|
||||||
|
--endpoint "http://10.10.0.1/tor" --mode auto --macro-kind tor-exit --macro-param socks_port=9050'
|
||||||
|
# c3box: pull catalog, subscribe (auto→approved), activate → pulls grant from gk2
|
||||||
|
ssh root@192.168.1.94 'runuser -u secubox -- annuairectl pull http://10.10.0.1:8799; \
|
||||||
|
TOKEN=$(python3 -c "import secubox_core.auth as a;print(a.create_token(\"admin\"))"); \
|
||||||
|
SID=$(curl -s --unix-socket /run/secubox/p2p.sock http://x/services | python3 -c "import sys,json;print([s[\"service_id\"] for s in json.load(sys.stdin)[\"services\"] if s[\"name\"]==\"Tor exit\"][0]"); \
|
||||||
|
curl -s -X POST -H "Authorization: Bearer $TOKEN" --unix-socket /run/secubox/p2p.sock http://x/services/$SID/activate'
|
||||||
|
# verify on gk2: c3box mesh IP now in the nft set; c3box can reach the SOCKS port
|
||||||
|
ssh root@192.168.1.200 'nft list set inet secubox_filter secubox_macro_torexit'
|
||||||
|
ssh root@192.168.1.94 'curl -s --socks5 10.10.0.1:9050 -o /dev/null -w "%{http_code}\n" --max-time 10 https://check.torproject.org/ || echo "socks reachable test"'
|
||||||
|
```
|
||||||
|
Expected: gk2's `secubox_macro_torexit` set contains c3box's mesh IP (10.10.0.2); c3box reaches the Tor SOCKS via the mesh. Revoke removes it.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add packages/secubox-p2p/www/p2p/index.html packages/secubox-p2p/debian/changelog
|
||||||
|
git commit -m "feat(p2p): macro UI (SOCKS endpoint + revoke) + 1.9.0 (ref #<issueC>)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Notes for the executor
|
||||||
|
- Replace `#<issueA/B/C>` with the GitHub issue number(s) created for this work (one umbrella issue is fine; reference it in every commit).
|
||||||
|
- Run each package's FULL test suite after its tasks; never regress a sibling.
|
||||||
|
- Tasks 1–2 (annuaire), 3–5 (secubox-macro), 6–8 (p2p) are ordered: p2p Task 6 needs the annuaire `macro` field (Task 1) and the macro package (Tasks 3–5) present for the live demo, but the p2p unit tests mock macroctl so they don't need it installed.
|
||||||
|
- The live demo (Task 8) is the real proof; if the mesh reverse-path (gk2→c3box) matters, note the parked nft issue — but here gk2 is the PROVIDER and c3box PULLS, so it uses the working consumer→provider direction.
|
||||||
|
- Do NOT implement pending-mode macros, additional kinds, or transparent consumer routing — all out of scope (spec §1/§9).
|
||||||
|
|
@ -0,0 +1,204 @@
|
||||||
|
# Gondwana Phase 1 — Mesh Transport + Node Identity (Substrate)
|
||||||
|
|
||||||
|
**Date:** 2026-06-29
|
||||||
|
**Status:** Design approved — pending spec review → implementation plan
|
||||||
|
**Scope:** Phase 1 of the gondwana program (substrate only). Phases 2–4 are
|
||||||
|
out of scope here and get their own spec → plan → build cycles.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Context & problem
|
||||||
|
|
||||||
|
SecuBox now runs on three nodes that should form one mesh ("gondwana"):
|
||||||
|
|
||||||
|
| Node | Role | Address | Notes |
|
||||||
|
|-------|-----------------|----------------------|-------|
|
||||||
|
| gk2 | master / public hub | 192.168.1.200 (WAN via Freebox, public 82.67.100.75) | only node with a stable public ingress |
|
||||||
|
| c3box | reference (mochabin) | 192.168.1.94 (currently offline) | satellite |
|
||||||
|
| amd64 | live-USB | 192.168.1.9 | satellite, ephemeral medium |
|
||||||
|
|
||||||
|
Goal of the wider program: **service mirroring/redundancy, redundant access,
|
||||||
|
and (above all) shared protections** across nodes, with a **zero-trust
|
||||||
|
GK-HAM ZKP** trust model (#762) as the target.
|
||||||
|
|
||||||
|
That program layers on a transport+identity substrate that does not cleanly
|
||||||
|
exist today. Two concrete defects block everything else:
|
||||||
|
|
||||||
|
1. **Two half-systems.** The live mesh is a hand-rolled `wg-quick` interface
|
||||||
|
(`wg-mesh`, `10.10.0.0/24`, UDP `51822`, gk2=`.1` ↔ c3box=`.2`) created
|
||||||
|
*outside* `secubox-p2p`. Meanwhile `secubox-p2p` has its own WireGuard
|
||||||
|
provisioning code that is dormant and reports `enabled=false, 0 peers`.
|
||||||
|
2. **Subnet collision.** `secubox-p2p`'s WireGuard default network is
|
||||||
|
`10.100.0.0/24` — **identical to the `br-lxc` LXC bridge**. If the p2p
|
||||||
|
layer ever brought up its interface on the default, it would collide with
|
||||||
|
every LXC (Lyrion, mail, mqtt, grafana, …). This is a primary reason the
|
||||||
|
MirrorNet layer never took over the mesh.
|
||||||
|
|
||||||
|
Phase 1 makes "the live mesh" and "the MirrorNet layer" the **same thing**,
|
||||||
|
on a collision-free subnet, reachable multi-site, with a persistent
|
||||||
|
per-node identity that Phase 2 (ZKP/did:plc) will wrap.
|
||||||
|
|
||||||
|
### Decisions locked during brainstorming
|
||||||
|
- **Topology:** multi-site distributed (nodes on different sites/links).
|
||||||
|
- **Trust target:** zero-trust GK-HAM (#762) — but implemented in Phase 2;
|
||||||
|
Phase 1 keeps the existing plain-auth join behind the same interface.
|
||||||
|
- **Rendezvous:** gk2 exposed via a **dedicated Freebox UDP `51822 → .200`**
|
||||||
|
forward (separate from the toolbox VPN on 51820).
|
||||||
|
- **Rendezvous is a ROLE, not a hardwired hub (revised 2026-06-29).** Any
|
||||||
|
node may hold the rendezvous role; the *active* rendezvous is whichever
|
||||||
|
node is currently publicly reachable. Today only gk2 has a public ingress,
|
||||||
|
so gk2 is the active rendezvous — but config/code must not hardwire "gk2
|
||||||
|
is the master." Each node also carries a **DDNS name as part of its
|
||||||
|
identity** (`<boxname>.secubox.in`), so reachability is name-based and the
|
||||||
|
rendezvous can float later without reconfiguring peers. Phase 1 builds
|
||||||
|
only this forward-compatibility; availability-based failover between
|
||||||
|
multiple rendezvous nodes is Phase 4 (hub HA), and the shared state moving
|
||||||
|
to a distributed ledger is Phase 2/3 (see §8).
|
||||||
|
- **Approach:** make `secubox-p2p` the mesh owner (vs. keep-wg-quick, vs. new
|
||||||
|
daemon). "Owner" = the component that provisions WireGuard and holds the
|
||||||
|
peer registry; the registry is **local-first/replicable**, not a
|
||||||
|
gk2-exclusive source of truth, so it can migrate to the Phase-2/3 ledger.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Addressing model
|
||||||
|
|
||||||
|
- **Mesh subnet: `10.10.0.0/24`** (keep the interim subnet; already live and
|
||||||
|
collision-free).
|
||||||
|
- **Hard collision guard:** the mesh subnet MUST NOT overlap `br-lxc`
|
||||||
|
(10.100.0.0/24), `eye-br0` (10.55.0.0/24), `lxcbr0` (10.0.3.0/24), or
|
||||||
|
`wg-toolbox` (10.99.0.0/24). The provisioner refuses to enable on overlap.
|
||||||
|
- **Allocation: master-assigned, deterministic.** gk2 = `10.10.0.1` (fixed
|
||||||
|
master). Satellites are assigned the next free `.2–.254` *by gk2 at join*
|
||||||
|
and recorded in gk2's peer registry. (Replaces the current
|
||||||
|
hash-from-node-id scheme, which can silently collide.) c3box stays `.2`,
|
||||||
|
amd64 becomes `.3`.
|
||||||
|
|
||||||
|
## 3. Identity model
|
||||||
|
|
||||||
|
- Each node owns a persistent **WireGuard keypair + stable `node-id`** under
|
||||||
|
`/var/lib/secubox/p2p/`:
|
||||||
|
- `wg_mesh.json` — holds the private key, `0600 secubox:secubox`.
|
||||||
|
- `node.id` — stable node identifier.
|
||||||
|
- `(pubkey, node-id)` **is** the Phase-1 identity; Phase 2 GK-HAM ZKP /
|
||||||
|
did:plc wraps it rather than replacing it.
|
||||||
|
- **Live-USB caveat (amd64):** identity is persisted on the persistence
|
||||||
|
partition so it survives reboot. If absent, the node re-enrolls fresh and
|
||||||
|
gk2 dedupes the stale peer entry by hostname.
|
||||||
|
|
||||||
|
## 4. Topology & routing — hub-and-spoke via gk2
|
||||||
|
|
||||||
|
- **gk2 (hub):** listens `:51822`; public `Endpoint = <gk2-public>:51822`.
|
||||||
|
One `[Peer]` per satellite with `AllowedIPs = 10.10.0.<n>/32` and **no**
|
||||||
|
Endpoint (learned from each satellite's handshake → roaming; nomadic amd64
|
||||||
|
works with no reconfig).
|
||||||
|
- **Satellites (spokes):** a single `[Peer]` = gk2, `AllowedIPs =
|
||||||
|
10.10.0.0/24`, `PersistentKeepalive = 25` (holds the NAT hole open).
|
||||||
|
- **Inter-satellite traffic** (e.g. threatmesh gossip c3box↔amd64) routes
|
||||||
|
**through gk2**: spoke → `10.10.0.0/24` → gk2 → forward → other spoke.
|
||||||
|
gk2 already has `ip_forward=1` and nftables `forward policy accept`, so the
|
||||||
|
hairpin needs no new rule.
|
||||||
|
- Same-LAN nodes may later get direct peer entries as an optimization; the
|
||||||
|
uniform baseline is hub-routed (correct behind any NAT).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. secubox-p2p changes (the single reconciling change)
|
||||||
|
|
||||||
|
- **Config** — new `/etc/secubox/p2p.toml [wireguard]`:
|
||||||
|
`interface="wg-mesh"`, `listen_port=51822`, `network="10.10.0.0/24"`,
|
||||||
|
`role="master"|"satellite"`, `master_endpoint="<gk2-public>:51822"`
|
||||||
|
(satellites only). Code defaults change `51820→51822` and
|
||||||
|
`10.100.0.0/24→10.10.0.0/24`.
|
||||||
|
- **`master_endpoint` is a free-form host:port** — it accepts either a
|
||||||
|
DDNS hostname (future-proofing against a changing WAN IP) or a literal
|
||||||
|
IP. WireGuard re-resolves a hostname on each handshake, so a DDNS name
|
||||||
|
survives IP changes with no reconfig. **Current deployment pins the
|
||||||
|
literal public IP: `82.67.100.75:51822`**; switching to a DDNS name is a
|
||||||
|
one-line config change later.
|
||||||
|
- **Adoption (critical for zero cutover):** on enable, if
|
||||||
|
`/etc/wireguard/wg-mesh.conf` already exists with the same subnet/port,
|
||||||
|
**import its existing private key** into `wg_mesh.json` so the public key
|
||||||
|
is unchanged → the gk2↔c3box handshake survives. Never regenerate a key
|
||||||
|
when a valid one exists.
|
||||||
|
- **Provisioning:** `/wireguard/enable` (re)writes a standard `wg-quick`
|
||||||
|
`wg-mesh.conf` from config + peer registry and `wg-quick up`s it
|
||||||
|
idempotently. `/wireguard/peer` adds/removes a `[Peer]`.
|
||||||
|
- **Collision guard:** refuse to enable if `network` overlaps the bridges in
|
||||||
|
§2.
|
||||||
|
- **Join wiring:** `master-link/join` assigns the next free `10.10.0.x`,
|
||||||
|
returns it plus gk2's pubkey/endpoint, and adds the peer on both ends.
|
||||||
|
Plain-auth for now; Phase 2 swaps in ZKP behind this same interface.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Cutover plan — zero disruption, in order
|
||||||
|
|
||||||
|
1. **gk2:** import the live `wg-mesh` private key into p2p state; set
|
||||||
|
`role=master`, `10.10.0.0/24:51822`; switch to p2p-managed. Generated conf
|
||||||
|
≡ current conf → **c3box handshake preserved**.
|
||||||
|
2. **Freebox:** add UDP `51822 → 192.168.1.200` (operator action; until then
|
||||||
|
satellites join only from the LAN).
|
||||||
|
3. **amd64 (.9):** generate identity → gk2 issues join (`.3`) → peer added
|
||||||
|
both sides → satellite brings up `wg-mesh` with `Endpoint=<gk2-public>:51822`.
|
||||||
|
4. **Verify:** handshakes on all three; `10.10.0.1 ↔ .2 ↔ .3` ping through
|
||||||
|
the hub; threatmesh `:8780` reachable spoke-to-spoke.
|
||||||
|
5. **Backport:** every step lands in source (p2p.toml defaults, provisioning,
|
||||||
|
guard) — no live-only drift.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Failure modes & mitigations
|
||||||
|
|
||||||
|
| Failure | Mitigation |
|
||||||
|
|---------|------------|
|
||||||
|
| Key regenerated on adopt → breaks c3box | Import-or-keep existing privkey; never regen if a valid key exists |
|
||||||
|
| Subnet regression (overlap br-lxc etc.) | Collision guard refuses to start |
|
||||||
|
| gk2 (hub) down | Already-handshaked spokes keep roaming on last endpoint; *new* joins blocked (accepted for Phase 1; Phase 4 adds HA) |
|
||||||
|
| amd64 live-USB wiped | Re-enroll fresh; gk2 dedupes stale peer by hostname |
|
||||||
|
| NAT hole closes | `PersistentKeepalive=25` on spokes |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Out of scope (later phases)
|
||||||
|
|
||||||
|
- **Cross-cutting — Distributed directory (DNS-structured ledger, requested
|
||||||
|
2026-06-29).** Shared mesh state (peers, services, threat-intel, name
|
||||||
|
records) migrates from per-node JSON registries to a replicated,
|
||||||
|
append-only, hierarchically-named directory every node holds — a
|
||||||
|
blockchain/DID-style ledger "like DNS." This is the concrete form of the
|
||||||
|
CLAUDE.md `did:plc` + "Chain of Hamiltonians → HamCoin" intent. It is the
|
||||||
|
data-plane substrate for Phases 2–4 (identity records in P2, threat
|
||||||
|
records in P3, name records in P4). Phase 1 keeps the registry
|
||||||
|
**local-first/replicable** specifically so it can be backed by this ledger
|
||||||
|
later without reworking the transport.
|
||||||
|
- **Phase 2** — GK-HAM ZKP enrollment (#762): hamiltonian ZKP join, did:plc
|
||||||
|
identity, auto-discover / magic-invite over wg. Each node's
|
||||||
|
`(pubkey, node-id, boxname)` from Phase 1 becomes its ledger identity
|
||||||
|
record.
|
||||||
|
- **Phase 3** — Zero-trust protection sharing: signed threatmesh gossip,
|
||||||
|
N-source consensus, peer-identity-gated ingestion, WAF-rule sharing.
|
||||||
|
- **Phase 4** — Service mirroring + access redundancy: service replication,
|
||||||
|
multi-endpoint failover (DNS / HAProxy), hub HA.
|
||||||
|
- **Auto-registration + per-node naming (requested 2026-06-29):** each
|
||||||
|
node registers itself with the central `secubox.in` and automatically
|
||||||
|
gets vhosts published as `<service>.<boxname>.secubox.in`. Architecture
|
||||||
|
that falls out of Phase 1: DNS for `*.<boxname>.secubox.in` resolves to
|
||||||
|
**gk2's public IP** (the only public ingress; satellites are behind
|
||||||
|
NAT); gk2's HAProxy/mitmproxy routes by `Host:` **over the wg-mesh** to
|
||||||
|
the owning node's service. Consumes the Phase-1 node identity
|
||||||
|
(`boxname`/`node-id`) + mesh transport. **Open question for Phase 4
|
||||||
|
design:** how `*.secubox.in` DNS records are authored — gk2 as an
|
||||||
|
authoritative zone vs. a registrar/provider API. Must keep the
|
||||||
|
no-waf_bypass rule (every published vhost routes through
|
||||||
|
mitmproxy_inspector).
|
||||||
|
|
||||||
|
## 9. Success criteria (Phase 1)
|
||||||
|
|
||||||
|
1. `secubox-p2p` reports the mesh as enabled with the real peers (no more
|
||||||
|
`enabled=false, 0 peers`); `/wireguard` truth matches `wg show wg-mesh`.
|
||||||
|
2. No subnet overlaps any bridge; collision guard proven to refuse a bad
|
||||||
|
subnet.
|
||||||
|
3. gk2↔c3box handshake uninterrupted across cutover (same keys).
|
||||||
|
4. amd64 (`.3`) joins via the master flow and reaches `.1` and `.2`.
|
||||||
|
5. All changes present in source; a fresh install reproduces the topology.
|
||||||
|
|
@ -0,0 +1,165 @@
|
||||||
|
# SecuBox App Store / Module Manager — Architecture Sketch
|
||||||
|
|
||||||
|
**Date:** 2026-06-29 · **Status:** brainstorm sketch (pre-spec) · **Author intent:** "appstore categorized + live prefs/components care; lyrion/peertube/nextcloud/webmail/kbin are modules of this toolbox system; implement shortly."
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Core idea
|
||||||
|
|
||||||
|
A single web UI to **discover → install → enable/disable → configure → monitor** every SecuBox module, like an app store but fused with a live "components care" panel (prefs + health + restart/repair). It is a *front-end + lifecycle layer over manifests that already exist* — not a new packaging system.
|
||||||
|
|
||||||
|
**Foundation already present (don't reinvent):**
|
||||||
|
- **128 modules** each ship `debian/secubox.yaml` = `{name, category, tier, description, depends, api:{socket,health}, ui:{path}}` — this IS the catalog entry.
|
||||||
|
- Categories in use: `media, email, ai, iot, communication, publishing, network, security, system, vpn, dashboard, misc`. Tiers: `lite | standard | pro | all`.
|
||||||
|
- Module anatomy: `api/main.py` (FastAPI control-plane), `nginx/*.conf`, `sbin/<mod>ctl` (29), `lib/<mod>/install-lxc.sh` (9 LXC-backed: lyrion, peertube, photoprism, jellyfin-ish, grafana, mqtt, yacy, rustdesk, zigbee), `conf/<mod>.toml.example`, `menu.d/NNN-<mod>.json` (129), `www/<mod>/` (150).
|
||||||
|
- The **aggregator** mounts each module API at `/api/v1/<name>/`; the **hub** is the central dashboard; **menu.d** drives the menu.
|
||||||
|
- apt repo `apt.secubox.in` is the package source; `dpkg` is installed-state truth.
|
||||||
|
|
||||||
|
## 2. State model (per module)
|
||||||
|
|
||||||
|
`available` (in apt, not installed) → `installed` → `enabled` (service on + menu/nginx wired) → `running` (healthy). Plus `update-available`, `error`, and `tier-locked` (board tier < module tier). Health = {service active?, LXC state (for LXC apps), socket up?, last /health, mem/disk}.
|
||||||
|
|
||||||
|
## 3. Backend — `secubox-appstore` module (FastAPI, on the aggregator, runs as `secubox`)
|
||||||
|
|
||||||
|
Read/aggregate endpoints (safe, no privilege):
|
||||||
|
- `GET /catalog?category=&tier=&q=` — merged list: apt-available ∪ dpkg-installed, each with manifest + state + version + update flag.
|
||||||
|
- `GET /module/{name}` — manifest + live state + health + prefs schema + screenshots.
|
||||||
|
- `GET /module/{name}/health` — proxy module `/health` + LXC/service/socket/resources.
|
||||||
|
- `GET /jobs/{id}` — async job status (install/uninstall progress).
|
||||||
|
|
||||||
|
Mutating endpoints (delegate to a root helper — see §4):
|
||||||
|
- `POST /module/{name}/install` · `/uninstall`
|
||||||
|
- `POST /module/{name}/enable` · `/disable`
|
||||||
|
- `POST /module/{name}/restart`
|
||||||
|
- `GET/PUT /module/{name}/prefs` — read/write the module TOML (schema-validated; sensitive configs go through the CSPN double-buffer/4R: shadow → validate → atomic swap → rollback).
|
||||||
|
- `POST /module/{name}/expose` — toggle public exposure (HAProxy ACL + mitmproxy route, **never waf_bypass**) — later phase.
|
||||||
|
|
||||||
|
## 4. Privilege & async model (the hard part)
|
||||||
|
|
||||||
|
The FastAPI runs as `secubox` and **cannot** apt-install, run `install-lxc.sh`, `lxc-*`, `systemctl`, or edit `/etc/nginx`. Mirror the proven `sbx-mesh-up`/`<mod>ctl` pattern:
|
||||||
|
- A **root helper `secubox-appstorectl`** does the privileged verbs (`install <name>` = apt-get install + run the module's `install-lxc.sh` if LXC-backed; `enable/disable` = systemctl + menu.d toggle + nginx reload; `uninstall` = apt purge [+ optional LXC destroy]).
|
||||||
|
- FastAPI invokes it via a **narrow sudoers rule** (only `secubox-appstorectl <verb> <name>`), or hands jobs to a **privileged oneshot/queue** (a root `secubox-appstore-worker` consuming a job dir).
|
||||||
|
- Installs take **minutes** (apt + debootstrap LXC) → **async job model**: enqueue → return job id → UI polls `/jobs/{id}` with a progress stream (stage: download → install → provision-lxc → enable → health-check).
|
||||||
|
- Every action → append-only **audit log** (`/var/log/secubox/audit.log`), CSPN requirement.
|
||||||
|
|
||||||
|
## 5. Manifest schema extension (`secubox.yaml` v2 — additive, back-compatible)
|
||||||
|
|
||||||
|
Add optional appstore fields, defaulted so the 128 existing manifests still parse:
|
||||||
|
```yaml
|
||||||
|
appstore:
|
||||||
|
icon: "lyrion.svg" # or emoji/glyph
|
||||||
|
summary: "Squeezebox / LMS music server"
|
||||||
|
long_description: | # markdown
|
||||||
|
screenshots: ["1.png","2.png"]
|
||||||
|
lxc: true # LXC-backed (drives install-lxc.sh step)
|
||||||
|
exposure: optional # none | optional | required (HAProxy/mitmproxy)
|
||||||
|
default_ports: [9000, 3483]
|
||||||
|
resources: { ram_mb: 512, disk_mb: 2048 }
|
||||||
|
conflicts: [] # mutually-exclusive modules
|
||||||