Files
Patrick Ecord 3d4af095b1 wasm: browser↔native clipboard sync works over iroh
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>
2026-06-23 20:08:45 -05:00
..

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
  • room is already sha256(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:

  1. Run it on a CT/VM (systemd unit below).

  2. 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.)

  3. 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.