v3/docs/adr/ADR-106-peer-discovery.md
Federation peers need to know each other's endpoints to talk. Today (alpha.9) the only way to discover a peer is to put their endpoint in config.staticPeers at plugin init time. This works for the common case ("I have 3 known machines on a tailnet, I want them to peer") but is friction for ad-hoc + dynamic deployments.
v1 (shipped): explicit staticPeers config — no auto-discovery on the wire. Peers are added via discovery.addStaticPeer(endpoint) either at init time or at runtime. Each newly added peer's manifest is fetched + Ed25519-verified before they join the registry.
await plugin.initialize({
config: {
nodeId: 'laptop',
endpoint: 'ws://laptop.local:9100',
staticPeers: [
'ws://server.local:9100',
'ws://homelab.tail-net.ts.net:9100',
],
// ...
},
});
v2 (planned, this ADR's payload): opt-in mDNS/Bonjour discovery via bonjour-service (npm) or multicast-dns. New config knob discoveryModes: ('static' | 'mdns')[] (default: ['static'] for backward compat; users opt into ['static', 'mdns']).
bonjour-service over multicast-dns| Library | Pros | Cons |
|---|---|---|
bonjour-service (~5k weekly DLs, TS-typed) | High-level service browse/publish API; auto-handles instance naming + TXT records | Slightly heavier than raw mDNS |
multicast-dns (~2M weekly DLs) | Minimal, low-level | Caller writes record-type handling + service-instance matching from scratch |
Choose bonjour-service — federation needs the service-instance pattern (one node may publish multiple service types), not raw record manipulation.
_ruflo-federation._tcp.local.
TXT records:
nodeId = <peer node id>
publicKey = <hex-encoded Ed25519 public key>
capabilities = <comma-separated list>
version = <plugin semver>
Receiving side queries _ruflo-federation._tcp.local. periodically (default 30s), enumerates instances, fetches each one's signed manifest at ws://<host>:<port>/.well-known/federation-manifest, verifies the Ed25519 sig, and adds to discovery if new.
_ruflo-federation._tcp.local. record. We treat mDNS as a HINT, not a trust signal.UNTRUSTED (no send/receive capability per the trust gates).tailscale set --advertise-tagsstaticPeers for cross-host (which is the current default)mdns discovery only finds peers on the local broadcast domain. Don't promise tailnet-wide auto-discovery.// New file: domain/services/mdns-discovery-service.ts
import bonjour from 'bonjour-service';
export class MdnsDiscoveryService {
constructor(
private readonly nodeId: string,
private readonly publicKeyHex: string,
private readonly endpoint: string,
private readonly capabilities: readonly string[],
private readonly onPeer: (host: string, port: number, txt: Record<string,string>) => void,
) {}
async start(): Promise<void> { /* publish + browse */ }
async stop(): Promise<void> { /* unpublish + stop browser */ }
}
// In plugin.ts initialize():
const discoveryModes = (config['discoveryModes'] as string[]) ?? ['static'];
if (discoveryModes.includes('mdns')) {
const mdns = new MdnsDiscoveryService(nodeId, publicKeyHex, endpoint, capabilities, async (host, port, txt) => {
const url = `ws://${host}:${port}`;
try {
await discovery.addStaticPeer(url); // re-uses existing manifest verify path
context.logger.info(`mDNS: added peer ${txt.nodeId} at ${url}`);
} catch (err) {
context.logger.warn(`mDNS: rejected peer ${url}: ${err.message}`);
}
});
await mdns.start();
}
127.0.0.1 with different ports + discoveryModes: ['mdns'], assert each discovers the other within 5sbonjour-service (~50KB minified)staticPeers until ADR-107 ships TLS pinning for public peers| Step | Status |
|---|---|
staticPeers config | Implemented (alpha.9) |
MdnsDiscoveryService class | Deferred — this ADR's payload |
Plugin discoveryModes config | Deferred |
| Tests | Deferred |
Re-open when: