WebX11 Federation Protocol (v1)

Independent WebX11 instances can register with each other, prove identity, and search each other’s skill and product catalogs. This document is the contract; the implementation is api/federation_lib.py, api/federation-api.py and api/federation-cli.py.

Non-goals (not implemented, not planned): content replication between nodes, shared user accounts or SSO, shared payment/billing ledgers, auto-installing remote skills or products. Federation grants read-only discovery. Nothing a peer sends is ever executed.


1. Model

2. Identity — GET /federation/node.json

{
  "node_id": "98e6e07f-c602-4cee-bb5a-4e48f89b47c2",
  "name": "WebX11 Main",
  "url": "https://webx11.duckdns.org",
  "description": "Multi-tenant hosting + AI spaces",
  "software": "webx11",
  "federation_api": 1,
  "capabilities": ["skills.search", "products.search"]
}

url is an origin only (no path, no credentials). Anything else is rejected by safe_base_url(), and it must be https — an instance behind NAT or on a phone has to put a TLS tunnel in front of nginx (WX11_TLS_MODE=tunnel, see PLATFORMS.md), otherwise peers cannot register it.

3. Registration sequence

A: federation-cli.py add-peer https://B
   → POST /federation/announce  {node: A, token: <one-time>}
B: stores status=pending (never auto-accepted)
   ← 202 {node_id: B, name, approve_with}
B: federation-cli.py approve <A_node_id>      # human step
   → derives secret from a fresh invite code
   → POST /federation/accept  {node: B, token, invite}
A: checks token matches its pending-out entry
   → derives the same secret, status=active
   ← 200 {node_id: A, ts, nonce, sig}
B: verifies sig → peer active + verified

Any later call (/federation/handshake) is signed with that secret and updates last_ok / fails. Three consecutive failures mark the peer degraded; a signed response restores active. Alerts fire only on transitions.

4. Signing

canonical(payload) = json.dumps(payload, sort_keys=True, separators=(',', ':'))
sig = HMAC-SHA256(secret, f"{ts}\n{nonce}\n{canonical(payload)}").hexdigest()

5. Secret derivation

secret = HMAC-SHA256(invite_code, f"{min(node_a,node_b)}|{max(node_a,node_b)}").hexdigest()

Both sides derive the same value from B’s one-time invite code (18 URL-safe chars). The code travels once, inside the /federation/accept body over TLS, and is not stored.

6. Endpoints

/federation/node.json GET none — — node identity (cached 60 s)
/federation/peers.json GET none — — active peers, no secrets
/federation/search GET none 30 r/m, burst 20 — aggregated items
/federation/announce POST none (schema-validated) 12 r/m, burst 5 16 KB 202 pending / 409 / 429
/federation/accept POST accept token 12 r/m, burst 5 16 KB signed ack / 403 / 404
/federation/handshake POST HMAC 12 r/m, burst 10 16 KB signed ack
/federation/status GET loopback only — — health + counters (404 publicly)

Rate-limit zones are fed_search and fed_peer in /etc/nginx/conf.d/wx11-zones.conf (http context — a limit_req_zone inside a server block is invalid). They are separate so a search-heavy client cannot starve its own handshakes.

Registry caps: 32 peers, pending requests expire after 14 days (pruned by the health pass).

7. Search contract

Request: GET /federation/search?q=<2-120 chars>&type=all|skills|products[&federated=1]

Response:

{
  "query": "nginx",
  "type": "all",
  "count": 6,
  "items": [
    {"kind": "skill", "id": "nginx-proxy-config-gotchas", "title": "…",
     "description": "…", "category": "devops", "version": "1.0.0",
     "origin": "<node_id>", "origin_name": "WebX11 Main",
     "external": false, "url": "https://webx11.duckdns.org/skills.html"}
  ]
}

Rules that matter:

8. Threat model

Threat Control
SSRF via peer URL https only, no credentials, no path/query, every DNS answer must be public
DNS rebinding between validation and fetch the validated IP is pinned; TLS SNI and Host still use the hostname
Replay ±300 s window, single-use nonce, signature verified before the nonce is consumed
Pending-peer hijack (node_id is public) a second announce with a different URL for a pending node is rejected 409
Registry flooding 12 r/m, 32-peer cap, human approval, 14-day pending TTL
Click-XSS from peer content peer url ignored, links rebuilt from origin, every field length-capped and HTML-escaped, external badge
Amplification through us fan-out ≤ 8 peers, 2.5 s deadline, response cache
Admin takeover no admin endpoints at all; invites/approvals need shell access on the node

Federation peers are trusted to serve their own catalog and nothing else. A peer is not trusted to serve your catalog: the hop flag stops recursion, and origin filtering stops echoes.

9. Compatibility

10. Operating it

python3 api/federation-cli.py init --name "My Node" --url https://me.example.org
python3 api/federation-cli.py add-peer https://peer.example.org
python3 api/federation-cli.py pending
python3 api/federation-cli.py approve <node_id>
python3 api/federation-cli.py list
python3 api/federation-cli.py health      # silent when everything is fine
python3 api/federation-cli.py remove <node_id>

deploy/federation-health.sh wraps health for cron: it prints only on a transition and pipes non-empty output to notify.py when one is installed. deploy/test-federation.sh runs a full two-node handshake on one host using scratch state directories.