Two root causes kept the wasm web client from syncing with native peers,
both fixed here:
1. Gossip island — a node that registered with the rendezvous first joined
its gossip topic alone and never pulled later-joining peers into its
active view (its single subscribe_and_join was done, so a browser that
appeared afterwards had a live magicsock path but no gossip neighbor).
The re-register loop now seeds peer relay addrs into the MemoryLookup and
calls GossipSender::join_peers each round, so neighbors form both ways.
2. Engine::send panicked on wasm — directly_covered() calls Instant::now(),
which traps ("time not implemented") on wasm32-unknown-unknown, so send
threw before ever reaching the gossip broadcast (0 Broadcast commands).
web-time backs Instant with Performance.now() on wasm; native keeps
std::time and is unaffected (web-time isn't linked there).
Supporting connectivity: the rendezvous now stores + returns each peer's
iroh relay URL (PeerInfo{id,relay}) so a browser — relay-only, can't resolve
id→addr via discovery — can dial natives via a seeded MemoryLookup; CORS
added so the browser's fetch to the rendezvous is allowed.
Verified live, cross-relay: text typed in a browser tab lands on the Mac
clipboard and vice-versa, with the wasm engine running as a relay-only iroh
peer. Browser-console tracing capped at INFO; demo send wrapped in try/catch.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
tether-rendezvous
The one piece of server tether still needs once it's on iroh: a bootstrap rendezvous. To join the gossip topic a device needs to reach ≥1 peer already in it; this hands it one. After that, everything is peer-to-peer.
It is deliberately tiny and content-blind:
- maps
room → {EndpointId: last_seen}in memory, TTL 90s roomis alreadysha256(account)[..4], so the account is never exposed- it only ever sees iroh public keys + timestamps — never clipboard content (that's E2E between peers)
This is "supernode #1, the boring half." The other half is a self-hosted
iroh-relay (for holepunch-assist / relay fallback). Neither replaces the old
Go server's role in content — there is none anymore.
API
POST /rendezvous/register {"room":"<hex>","id":"<endpoint-id>"}
→ {"peers":["<other-id>", ...]} # current members minus the caller
GET /rendezvous/peers?room=<hex> → {"peers":[...]}
GET /healthz → "ok"
Clients register on a short interval (well under the 90s TTL) so the member list reflects who's actually online.
Run
cargo run --release # binds 0.0.0.0:8765
PORT=9000 cargo run --release
Deploy (homelab)
It needs to be publicly reachable for cross-network (cellular/CGNAT) devices to bootstrap — same requirement as the iroh-relay. On the homelab:
-
Run it on a CT/VM (systemd unit below).
-
Front it with Caddy for TLS, e.g.
rendezvous.pecord.io:rendezvous.pecord.io { reverse_proxy 127.0.0.1:8765 }(Plain HTTP reverse-proxy is fine — unlike the iroh-relay, there's no UDP/QUIC path here, just small JSON over HTTPS.)
-
Point the tether clients' server field at
https://rendezvous.pecord.io.
systemd unit
[Unit]
Description=tether rendezvous
After=network-online.target
[Service]
ExecStart=/opt/tether/tether-rendezvous
Environment=PORT=8765
Restart=always
RestartSec=2
[Install]
WantedBy=multi-user.target
Auth
Set TETHER_RENDEZVOUS_TOKEN to require Authorization: Bearer <token> on
register/peers (healthz stays open). Clients send it via the same env var
(TETHER_RENDEZVOUS_TOKEN), so the agent/CLI pick it up automatically; the iOS
app needs a field for it (small Swift follow-up). Empty token = open — fine
for a LAN/test run, but set one before exposing it publicly. Without a token,
anyone who knows a room (a hash) can register/enumerate — metadata only, content
stays E2E, but still worth gating.
Remaining hardening (follow-up): per-IP rate-limiting (the MAX_PER_ROOM cap is
only a flood blunt); behind Caddy use X-Forwarded-For for the real client IP.