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.
node_id), a public origin
(url) and an API version (federation_api,
currently 1).127.0.0.1:8909; nginx exposes six
exact-match paths. There is no HTTP admin surface —
invites and approvals are CLI-only on the box./var/lib/webx11/federation (mode 700), files mode 600.
Writes are atomic (tmp + fsync +
os.replace) under an flock, because the health
cron and the HTTP handler can both read-modify-write
peers.json.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.
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.
canonical(payload) = json.dumps(payload, sort_keys=True, separators=(',', ':'))
sig = HMAC-SHA256(secret, f"{ts}\n{nonce}\n{canonical(payload)}").hexdigest()
payload is always
{"node_id": <signer>}.ts is epoch seconds; accepted only within ±300 s.nonce is a random hex string, single-use per
(source, nonce) for 600 s.secret is 64 hex chars, stored mode 600, never
transmitted after bootstrap and never exposed in
peers.json.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.
/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).
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:
federated=1 (or
X-Federation-Hop: 1) means “answer from your own catalog
only.” Without it two nodes recurse A→B→A→B until both
deadlines expire and the caller silently gets local-only results. This
was observed in the two-node test (118 requests, zero peer results); the
hop flag is mandatory for peer calls.(query, type, federated);
max 200 entries.origin equals our own node_id
are dropped (a peer echoing our catalog back is not “external
content”).url field is discarded.
Result links are rebuilt as <peer origin>/skills.html
or <peer origin>/marketplace.html, because
HTML-escaping does not neutralise a javascript: href.| 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.
federation_api is an integer. A peer announcing a
different value gets 409 and is not registered. Bump it on
any breaking change to the payloads above.kind values)
do not require a bump: both sides ignore unknown fields.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.