core+rendezvous: zero-config bootstrap + iroh on iOS

Two pieces that unblock the on-device cross-CGNAT test:

1. Rendezvous (new `rendezvous/` crate, tether-rendezvous, axum) — the only
   server tether still needs on iroh: maps room → {EndpointId}, in-memory, TTL,
   content-blind (room is a hash; never sees clipboard data). IrohTransport now
   registers under cfg.server and bootstraps gossip from the returned peers, and
   re-registers every 30s so late joiners can find it. The TETHER_IROH_BOOTSTRAP
   env var is demoted to a test override. Verified: two engines self-discover via
   a local rendezvous and sync text + a 200KB photo with zero env wiring.

2. iroh on Apple targets — the iroh-transport feature cross-compiles for
   ios-arm64 / ios-sim / macOS once IPHONEOS_DEPLOYMENT_TARGET=26.0 is set (ring/
   blake3 prebuilt objects target the newer SDK). build-apple.sh now exports the
   deployment targets and takes a TETHER_FEATURES switch;
   `TETHER_FEATURES=iroh-transport ./build-apple.sh` assembles + vendors the iroh
   xcframework (4891 iroh symbols in the iOS .a). Swift bindings are unchanged —
   the app builds on iroh with no Swift changes.

Remaining for the live test: deploy the rendezvous (+ optional self-hosted
iroh-relay) publicly on the homelab, then build TetherApp in Xcode and point its
server field at the rendezvous URL. Default ship build untouched throughout.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-22 01:26:19 -05:00
parent 3cb916ee5e
commit 2c1419d18c
9 changed files with 830 additions and 34 deletions

View File

@@ -435,11 +435,23 @@ multi-modal payloads (text inline, audio/photo/data via `iroh-blobs`). Receiving
peers fetch P2P by hash and emit `MeshEvent::File`. Replaces the
M/C/E frame protocol. Verified: a 200 KB photo syncs A→B
E2E-decrypted alongside text (`iroh_engine` example).
- [ ] Remaining: rendezvous for zero-config bootstrap (today via
`TETHER_IROH_BOOTSTRAP` env stopgap); cross-compile the feature
for iOS + build the xcframework with it; make webrtc/mdns/reqwest
optional so the iroh/wasm build is lean; A/B vs RTC+LAN; flip the
feature on by default.
- [x] **Rendezvous for zero-config bootstrap**`rendezvous/`
(tether-rendezvous, axum): `room → {EndpointId}`, metadata-only,
content-blind. IrohTransport registers under `cfg.server` and
bootstraps gossip from the returned peers (env var now just a
test override). Verified: two engines self-discover via a local
rendezvous, sync text + photo, zero env wiring.
- [x] **iOS/Apple cross-compile + xcframework**`iroh-transport`
builds for ios-arm64 / ios-sim / macOS (needs
`IPHONEOS_DEPLOYMENT_TARGET=26.0`, now in `build-apple.sh` along
with a `TETHER_FEATURES` switch). `TETHER_FEATURES=iroh-transport
./build-apple.sh` assembles + vendors the iroh xcframework
(4,891 iroh symbols in the iOS staticlib); Swift bindings
unchanged, so the app builds with no Swift changes.
- [ ] Remaining: deploy the rendezvous + a self-hosted iroh-relay
publicly (homelab) for the on-device cross-CGNAT test; make
webrtc/mdns/reqwest optional for a lean iroh/wasm build; A/B vs
RTC+LAN; flip the feature on by default.
- [ ] Spike **Automerge** as the Sync tier; re-express the clipboard as a tiny
app over replicated state to validate the App API.
- [ ] Durable device keypairs + authenticated handshake (Noise/`snow`, or

View File

@@ -13,11 +13,25 @@ IOS=aarch64-apple-ios
SIM=aarch64-apple-ios-sim
MAC=aarch64-apple-darwin
# Deployment targets — must match the apps (iOS 26). Without this the linker
# defaults to iOS 10 and the cdylib fails to link against ring/blake3's prebuilt
# assembly objects (built for the newer SDK).
export IPHONEOS_DEPLOYMENT_TARGET=26.0
export MACOSX_DEPLOYMENT_TARGET=26.0
# Optional Cargo features, e.g. TETHER_FEATURES=iroh-transport to build the
# iroh-backed core. Empty → the default (legacy SSE/RTC/LAN) build. Plain string
# (not an array) so macOS bash 3.2 + `set -u` doesn't choke when it's empty.
FEATURES="${TETHER_FEATURES:-}"
FEAT_ARGS=""
[ -n "$FEATURES" ] && FEAT_ARGS="--features $FEATURES" && echo "▸ features: $FEATURES"
echo "▸ building static libs ($IOS, $SIM, $MAC)…"
rustup target add "$IOS" "$SIM" "$MAC" >/dev/null 2>&1 || true
cargo build --release --target "$IOS" --lib
cargo build --release --target "$SIM" --lib
cargo build --release --target "$MAC" --lib
# shellcheck disable=SC2086 # intentional word-split of FEAT_ARGS
cargo build --release --target "$IOS" --lib $FEAT_ARGS
cargo build --release --target "$SIM" --lib $FEAT_ARGS
cargo build --release --target "$MAC" --lib $FEAT_ARGS
echo "▸ generating Swift bindings…"
cargo run --release --bin uniffi-bindgen -- \

View File

@@ -11,7 +11,7 @@
//! it to B.
use std::sync::mpsc;
use std::time::{Duration, Instant};
use std::time::Duration;
use tethercore::{Engine, Message, MessageHandler};
@@ -39,40 +39,26 @@ impl MessageHandler for Collector {
fn main() {
let account = "pecord@gmail.com"; // same account → same room → same topic + key
let room = "iroh-engine-test";
let server = "unused"; // iroh ignores cfg.server; it uses n0 infra
// Zero-config bootstrap via the rendezvous (run tether-rendezvous first, or
// point at a deployed one). Both engines register under `room` and discover
// each other — no env var, no manual id wiring.
let server = std::env::var("TETHER_RENDEZVOUS")
.unwrap_or_else(|_| "http://localhost:8765".into());
eprintln!("rendezvous: {server}");
// Opener (no bootstrap yet).
let a = Engine::new(
server.into(), room.into(), "iroh-a".into(), "macos".into(), "A".into(), account.into(),
server.clone(), room.into(), "iroh-a".into(), "macos".into(), "A".into(), account.into(),
);
a.start(Box::new(Noop));
// Wait for A's iroh endpoint to come online, then bootstrap B off it.
let a_id = {
let deadline = Instant::now() + Duration::from_secs(20);
loop {
if let Some(id) = a.iroh_id() {
break id;
}
if Instant::now() > deadline {
eprintln!("❌ A never came online");
std::process::exit(1);
}
std::thread::sleep(Duration::from_millis(200));
}
};
eprintln!("A online, id={a_id}");
std::env::set_var("TETHER_IROH_BOOTSTRAP", &a_id);
// Joiner (bootstraps to A via the env var its IrohTransport reads at start).
let (tx, rx) = mpsc::channel();
let (ftx, frx) = mpsc::channel();
let b = Engine::new(
server.into(), room.into(), "iroh-b".into(), "ios".into(), "B".into(), account.into(),
server.clone(), room.into(), "iroh-b".into(), "ios".into(), "B".into(), account.into(),
);
b.start(Box::new(Collector { text: tx, file: ftx }));
eprintln!("waiting for gossip topic to form…");
eprintln!("waiting for gossip topic to form (via rendezvous)");
std::thread::sleep(Duration::from_secs(8));
// 1) Text clipboard over gossip.

View File

@@ -77,7 +77,8 @@ fn topic_for(room: &str) -> TopicId {
TopicId::from_bytes(id)
}
fn bootstrap_ids() -> Vec<EndpointId> {
/// Env-var bootstrap (test/override path): comma-separated EndpointIds.
fn env_bootstrap_ids() -> Vec<EndpointId> {
std::env::var("TETHER_IROH_BOOTSTRAP")
.ok()
.into_iter()
@@ -89,6 +90,32 @@ fn bootstrap_ids() -> Vec<EndpointId> {
.collect()
}
/// Register this node with the rendezvous (cfg.server) under our room and get
/// back the current peers to bootstrap the gossip topic. Zero-config: the room
/// is a hash, content never touches the rendezvous. No-op if server isn't a URL.
async fn rendezvous_register(server: &str, room: &str, my_id: &str) -> Vec<EndpointId> {
if !(server.starts_with("http://") || server.starts_with("https://")) {
return Vec::new();
}
let url = format!("{}/rendezvous/register", server.trim_end_matches('/'));
let body = serde_json::json!({ "room": room, "id": my_id });
let resp = match reqwest::Client::new().post(&url).json(&body).send().await {
Ok(r) => r,
Err(_) => return Vec::new(),
};
let Ok(v) = resp.json::<serde_json::Value>().await else {
return Vec::new();
};
v["peers"]
.as_array()
.map(|a| {
a.iter()
.filter_map(|p| p.as_str()?.parse::<EndpointId>().ok())
.collect()
})
.unwrap_or_default()
}
impl IrohTransport {
/// Fetch a blob announced over gossip from its provider and surface it.
async fn fetch_blob(blobs: Blobs, events: EventTx, ann: BlobAnnounce) {
@@ -154,8 +181,28 @@ impl Transport for IrohTransport {
my_id: my_id.clone(),
});
// Bootstrap: env override (tests) + the rendezvous (zero-config).
let mut boot = env_bootstrap_ids();
boot.extend(rendezvous_register(&this.cfg.server, &this.cfg.room, &my_id).await);
eprintln!("tethercore[iroh]: bootstrapping with {} peer(s)", boot.len());
// Keep re-registering so late-joining devices can bootstrap off us
// (rendezvous TTL is 90s; refresh well under it).
{
let server = this.cfg.server.clone();
let room = this.cfg.room.clone();
let id = my_id.clone();
let running = running.clone();
tokio::spawn(async move {
while running.load(Ordering::SeqCst) {
tokio::time::sleep(std::time::Duration::from_secs(30)).await;
let _ = rendezvous_register(&server, &room, &id).await;
}
});
}
let topic = topic_for(&this.cfg.room);
let sub = match gossip.subscribe_and_join(topic, bootstrap_ids()).await {
let sub = match gossip.subscribe_and_join(topic, boot).await {
Ok(s) => s,
Err(e) => {
eprintln!("tethercore[iroh]: topic join failed: {e}");

1
rendezvous/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
/target

540
rendezvous/Cargo.lock generated Normal file
View File

@@ -0,0 +1,540 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "async-trait"
version = "0.1.89"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "atomic-waker"
version = "1.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
[[package]]
name = "axum"
version = "0.7.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "edca88bc138befd0323b20752846e6587272d3b03b0343c8ea28a6f819e6e71f"
dependencies = [
"async-trait",
"axum-core",
"bytes",
"futures-util",
"http",
"http-body",
"http-body-util",
"hyper",
"hyper-util",
"itoa",
"matchit",
"memchr",
"mime",
"percent-encoding",
"pin-project-lite",
"rustversion",
"serde",
"serde_json",
"serde_path_to_error",
"serde_urlencoded",
"sync_wrapper",
"tokio",
"tower",
"tower-layer",
"tower-service",
"tracing",
]
[[package]]
name = "axum-core"
version = "0.4.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09f2bd6146b97ae3359fa0cc6d6b376d9539582c7b4220f041a33ec24c226199"
dependencies = [
"async-trait",
"bytes",
"futures-util",
"http",
"http-body",
"http-body-util",
"mime",
"pin-project-lite",
"rustversion",
"sync_wrapper",
"tower-layer",
"tower-service",
"tracing",
]
[[package]]
name = "bytes"
version = "1.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ae3f5d315924270530207e2a68396c3cc547f6dca3fbdca317cfb1a51edb593"
[[package]]
name = "errno"
version = "0.3.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys",
]
[[package]]
name = "form_urlencoded"
version = "1.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf"
dependencies = [
"percent-encoding",
]
[[package]]
name = "futures-channel"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d"
dependencies = [
"futures-core",
]
[[package]]
name = "futures-core"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d"
[[package]]
name = "futures-task"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393"
[[package]]
name = "futures-util"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6"
dependencies = [
"futures-core",
"futures-task",
"pin-project-lite",
"slab",
]
[[package]]
name = "http"
version = "1.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425"
dependencies = [
"bytes",
"itoa",
]
[[package]]
name = "http-body"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184"
dependencies = [
"bytes",
"http",
]
[[package]]
name = "http-body-util"
version = "0.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a"
dependencies = [
"bytes",
"futures-core",
"http",
"http-body",
"pin-project-lite",
]
[[package]]
name = "httparse"
version = "1.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
[[package]]
name = "httpdate"
version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
[[package]]
name = "hyper"
version = "1.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498"
dependencies = [
"atomic-waker",
"bytes",
"futures-channel",
"futures-core",
"http",
"http-body",
"httparse",
"httpdate",
"itoa",
"pin-project-lite",
"smallvec",
"tokio",
]
[[package]]
name = "hyper-util"
version = "0.1.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0"
dependencies = [
"bytes",
"http",
"http-body",
"hyper",
"pin-project-lite",
"tokio",
"tower-service",
]
[[package]]
name = "itoa"
version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "libc"
version = "0.2.186"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
[[package]]
name = "log"
version = "0.4.33"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
[[package]]
name = "matchit"
version = "0.7.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94"
[[package]]
name = "memchr"
version = "2.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4"
[[package]]
name = "mime"
version = "0.3.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
[[package]]
name = "mio"
version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda"
dependencies = [
"libc",
"wasi",
"windows-sys",
]
[[package]]
name = "once_cell"
version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
[[package]]
name = "percent-encoding"
version = "2.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
[[package]]
name = "pin-project-lite"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
[[package]]
name = "proc-macro2"
version = "1.0.106"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.46"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368"
dependencies = [
"proc-macro2",
]
[[package]]
name = "rustversion"
version = "1.0.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
[[package]]
name = "ryu"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
[[package]]
name = "serde"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
dependencies = [
"serde_core",
"serde_derive",
]
[[package]]
name = "serde_core"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "serde_json"
version = "1.0.150"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9"
dependencies = [
"itoa",
"memchr",
"serde",
"serde_core",
"zmij",
]
[[package]]
name = "serde_path_to_error"
version = "0.1.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457"
dependencies = [
"itoa",
"serde",
"serde_core",
]
[[package]]
name = "serde_urlencoded"
version = "0.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd"
dependencies = [
"form_urlencoded",
"itoa",
"ryu",
"serde",
]
[[package]]
name = "signal-hook-registry"
version = "1.4.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b"
dependencies = [
"errno",
"libc",
]
[[package]]
name = "slab"
version = "0.4.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
[[package]]
name = "smallvec"
version = "1.15.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90"
[[package]]
name = "socket2"
version = "0.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51"
dependencies = [
"libc",
"windows-sys",
]
[[package]]
name = "syn"
version = "2.0.118"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "sync_wrapper"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263"
[[package]]
name = "tether-rendezvous"
version = "0.1.0"
dependencies = [
"axum",
"serde",
"serde_json",
"tokio",
]
[[package]]
name = "tokio"
version = "1.52.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe"
dependencies = [
"libc",
"mio",
"pin-project-lite",
"signal-hook-registry",
"socket2",
"tokio-macros",
"windows-sys",
]
[[package]]
name = "tokio-macros"
version = "2.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "tower"
version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4"
dependencies = [
"futures-core",
"futures-util",
"pin-project-lite",
"sync_wrapper",
"tokio",
"tower-layer",
"tower-service",
"tracing",
]
[[package]]
name = "tower-layer"
version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e"
[[package]]
name = "tower-service"
version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3"
[[package]]
name = "tracing"
version = "0.1.44"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
dependencies = [
"log",
"pin-project-lite",
"tracing-core",
]
[[package]]
name = "tracing-core"
version = "0.1.36"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
dependencies = [
"once_cell",
]
[[package]]
name = "unicode-ident"
version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
[[package]]
name = "wasi"
version = "0.11.1+wasi-snapshot-preview1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
[[package]]
name = "windows-link"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
[[package]]
name = "windows-sys"
version = "0.61.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
dependencies = [
"windows-link",
]
[[package]]
name = "zmij"
version = "1.0.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"

15
rendezvous/Cargo.toml Normal file
View File

@@ -0,0 +1,15 @@
[package]
name = "tether-rendezvous"
version = "0.1.0"
edition = "2021"
description = "Tiny bootstrap rendezvous for tether's iroh gossip: room → {EndpointId}. Metadata-only, content never touches it. The one thing the supernode does for zero-config discovery."
[[bin]]
name = "tether-rendezvous"
path = "src/main.rs"
[dependencies]
axum = "0.7"
tokio = { version = "1", features = ["rt-multi-thread", "macros", "net", "time", "signal"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"

77
rendezvous/README.md Normal file
View File

@@ -0,0 +1,77 @@
# 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
```ini
[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
```
## Security note
Registration is unauthenticated — anyone who knows a `room` (a hash) can register
an id or enumerate members. That's metadata only and content stays E2E, but a
production deployment should add a token/auth gate and rate-limiting (the
`MAX_PER_ROOM` cap is only a flood blunt). Tracked as a follow-up.

104
rendezvous/src/main.rs Normal file
View File

@@ -0,0 +1,104 @@
//! tether-rendezvous — the only thing the server still does once tether is on
//! iroh: hand a joining device one current peer so it can bootstrap into the
//! gossip topic. After that, everything is peer-to-peer.
//!
//! It maps `room → {EndpointId: last_seen}`, in memory, with a short TTL. The
//! room is already sha256(account)[..4], so the account is never exposed; and it
//! only ever sees iroh public keys + timestamps — **never content** (that's E2E
//! between peers). This is "supernode #1, the boring part"; the iroh-relay is
//! the other part.
//!
//! Run: tether-rendezvous # binds 0.0.0.0:8765
//! PORT=9000 tether-rendezvous
//! Behind Caddy at e.g. rendezvous.pecord.io. Clients point cfg.server at it.
//!
//! API:
//! POST /rendezvous/register {"room": "...", "id": "<endpoint-id>"}
//! → {"peers": ["<other-id>", ...]} (current members minus the caller)
//! GET /rendezvous/peers?room=... → {"peers": [...]}
//! GET /healthz → "ok"
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
use axum::{
extract::{Query, State},
routing::{get, post},
Json, Router,
};
use serde::{Deserialize, Serialize};
/// Members are forgotten this long after their last register — so a member list
/// reflects who's actually online (clients re-register on a shorter interval).
const TTL: Duration = Duration::from_secs(90);
/// Cap members per room to blunt a junk-registration flood.
const MAX_PER_ROOM: usize = 64;
type Rooms = Arc<Mutex<HashMap<String, HashMap<String, Instant>>>>;
#[derive(Deserialize)]
struct Register {
room: String,
id: String,
}
#[derive(Deserialize)]
struct PeersQuery {
room: String,
}
#[derive(Serialize)]
struct Peers {
peers: Vec<String>,
}
fn live_peers(rooms: &Rooms, room: &str, exclude: Option<&str>) -> Vec<String> {
let now = Instant::now();
let mut map = rooms.lock().unwrap();
let members = map.entry(room.to_string()).or_default();
members.retain(|_, seen| now.duration_since(*seen) < TTL);
members
.keys()
.filter(|id| Some(id.as_str()) != exclude)
.cloned()
.collect()
}
async fn register(State(rooms): State<Rooms>, Json(r): Json<Register>) -> Json<Peers> {
if !r.room.is_empty() && !r.id.is_empty() {
let now = Instant::now();
let mut map = rooms.lock().unwrap();
let members = map.entry(r.room.clone()).or_default();
members.retain(|_, seen| now.duration_since(*seen) < TTL);
if members.len() < MAX_PER_ROOM || members.contains_key(&r.id) {
members.insert(r.id.clone(), now);
}
}
Json(Peers { peers: live_peers(&rooms, &r.room, Some(&r.id)) })
}
async fn peers(State(rooms): State<Rooms>, Query(q): Query<PeersQuery>) -> Json<Peers> {
Json(Peers { peers: live_peers(&rooms, &q.room, None) })
}
#[tokio::main]
async fn main() {
let port: u16 = std::env::var("PORT").ok().and_then(|p| p.parse().ok()).unwrap_or(8765);
let rooms: Rooms = Arc::new(Mutex::new(HashMap::new()));
let app = Router::new()
.route("/rendezvous/register", post(register))
.route("/rendezvous/peers", get(peers))
.route("/healthz", get(|| async { "ok" }))
.with_state(rooms);
let listener = tokio::net::TcpListener::bind(("0.0.0.0", port)).await.unwrap();
println!("tether-rendezvous listening on 0.0.0.0:{port}");
axum::serve(listener, app)
.with_graceful_shutdown(async {
let _ = tokio::signal::ctrl_c().await;
})
.await
.unwrap();
}