v3/docs/adr/ADR-107-federation-tls.md
Federation v1 (alpha.9) uses ws:// (plain WebSocket) over TCP. Encryption + identity is delegated to the network layer:
This is sufficient TODAY because the dogfood configuration is mac↔ruvultra over tailscale. It does not work for cross-tailnet federation over the open internet.
v1 (shipped): plugin defaults to ws:// and assumes the integrator has set up a tailnet (or equivalent — VPN, private network) for transport-layer protection. Document this assumption in the operator runbook.
v2 (this ADR's payload): add wss:// (TLS-secured WebSocket) with cert pinning for federation peers crossing trust domains. Three sub-decisions:
The plugin will accept TLS materials in config; it will NOT generate or rotate them. Reasons:
Config shape:
{
// ...
endpoint: 'wss://federation.example.com:9100',
tls: {
// server side — bind certs for the listener
certPath: '/etc/letsencrypt/live/example.com/fullchain.pem',
keyPath: '/etc/letsencrypt/live/example.com/privkey.pem',
// client side — pin which certs are acceptable for outbound
pinnedFingerprints: [
'sha256/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=', // peer-A
'sha256/BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=', // peer-B
],
// optional CA bundle for non-pinned mode (e.g. private CA)
caPath: '/etc/ssl/internal-ca.pem',
},
}
When pinnedFingerprints is set, only those exact certs are accepted — no CA path validation. If the peer's cert rotates and the fingerprint changes, the connection fails closed (operator must update config + restart). This prevents:
federation.example.comWhen caPath is set without pinnedFingerprints, falls back to standard CA-validated TLS. Document the trade-off explicitly.
Even with wss+pinning, the plugin documents tailnet-WG as the preferred path. Reasons:
So the implementation order is:
wss:// support to the loader (small change in agentic-flow's WebSocketFallbackTransport)In agentic-flow/src/transport/quic-loader.ts (companion PR upstream):
// In WebSocketFallbackTransport.getOrCreateConnection:
const isWss = url.startsWith('wss://');
const tlsOpts = isWss && this.config.pinnedFingerprints?.length
? {
checkServerIdentity: (host, cert) => {
const fp = `sha256/${createHash('sha256').update(cert.raw).digest('base64')}`;
if (!this.config.pinnedFingerprints!.includes(fp)) {
return new Error(`Cert fingerprint ${fp} not in pinned set`);
}
return undefined; // accept
},
}
: {};
const ws = new WebSocket(url, tlsOpts);
// In plugin.ts initialize():
const tlsConfig = config['tls'] as {
certPath?: string;
keyPath?: string;
pinnedFingerprints?: string[];
caPath?: string;
} | undefined;
const transport = await loadQuicTransport({
serverName: nodeId,
// ...
tls: tlsConfig, // pass through
});
In WebSocketFallbackTransport.listen():
async listen(port: number, host: string, opts?: { cert: Buffer; key: Buffer }) {
const httpServer = opts
? https.createServer({ cert: opts.cert, key: opts.key })
: http.createServer();
const wss = new WebSocketServer({ server: httpServer });
await new Promise<void>((r) => httpServer.listen(port, host, r));
// ...
}
$ npx ruflo doctor --component federation
✓ Federation Breaker: ADR-097 breaker loadable
✓ Federation Transport: selectedBackend=websocket
ℹ Federation TLS: wss + 2 pinned fingerprints
caPath, wss:// connections fall back to system CA validation (NOT skip-validation)ws:// connections in production environments emit a warn-level log entry (operator awareness)| Step | Status |
|---|---|
| Tailnet-as-TLS documented (ADR-104) | Implemented |
wss:// support in loader | Implemented — [email protected] |
WebSocketFallbackTransport accepts tls.{certPath,keyPath} and binds via https.createServer | Implemented |
Client-side tls.pinnedFingerprints with fail-closed checkServerIdentity | Implemented — sha256/<base64> per cert.raw, rejectUnauthorized=false (pinning IS the trust) |
Client-side tls.caPath for non-pinned CA validation | Implemented — rejectUnauthorized=true |
Plugin passes tls config through to transport | Implemented — loadQuicTransport({ tls }) accepted in plugin.ts initialize() |
| 4 new tests pin TLS config + ws-fallback compat | Implemented |
| Doctor surface enhancement | Deferred — surface to add when first TLS-pinned deployment lands |
Re-open when: