rendezvous: optional bearer-token auth (TETHER_RENDEZVOUS_TOKEN)
When TETHER_RENDEZVOUS_TOKEN is set, register/peers require `Authorization: Bearer <token>` (healthz stays open); empty token = open, so the first cross-CGNAT test needs no token but production can gate. IrohTransport sends the token from the same env var, so agent/CLI clients pick it up automatically (iOS needs a token field — small Swift follow-up). Verified: 401 without/with wrong token, 200 with the right one, healthz open; and the full engine flow (text + 200KB photo) still syncs end-to-end through a token-gated rendezvous. Default ship build untouched. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -448,10 +448,16 @@ multi-modal payloads (text inline, audio/photo/data via `iroh-blobs`). Receiving
|
||||
./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.
|
||||
- [x] **Rendezvous auth** — optional bearer token
|
||||
(`TETHER_RENDEZVOUS_TOKEN`) on register/peers; client sends it
|
||||
via the same env var. Off by default (open) so the first test
|
||||
needs no token; set one before public exposure. Verified 401/200.
|
||||
- [ ] 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.
|
||||
publicly (homelab) for the on-device cross-CGNAT test (set the
|
||||
token; iOS needs a token field); per-IP rate-limiting; make
|
||||
webrtc/mdns/reqwest optional for a lean iroh/wasm build (note:
|
||||
also needs a wasm-compatible runtime — Engine uses tokio
|
||||
multi-thread today); A/B vs RTC+LAN; flip the feature 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
|
||||
|
||||
@@ -99,7 +99,14 @@ async fn rendezvous_register(server: &str, room: &str, my_id: &str) -> Vec<Endpo
|
||||
}
|
||||
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 {
|
||||
let mut req = reqwest::Client::new().post(&url).json(&body);
|
||||
// Optional bearer token, matching the rendezvous's TETHER_RENDEZVOUS_TOKEN.
|
||||
if let Ok(token) = std::env::var("TETHER_RENDEZVOUS_TOKEN") {
|
||||
if !token.is_empty() {
|
||||
req = req.bearer_auth(token);
|
||||
}
|
||||
}
|
||||
let resp = match req.send().await {
|
||||
Ok(r) => r,
|
||||
Err(_) => return Vec::new(),
|
||||
};
|
||||
|
||||
@@ -69,9 +69,15 @@ RestartSec=2
|
||||
WantedBy=multi-user.target
|
||||
```
|
||||
|
||||
## Security note
|
||||
## Auth
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
@@ -24,6 +24,7 @@ use std::time::{Duration, Instant};
|
||||
|
||||
use axum::{
|
||||
extract::{Query, State},
|
||||
http::{HeaderMap, StatusCode},
|
||||
routing::{get, post},
|
||||
Json, Router,
|
||||
};
|
||||
@@ -37,6 +38,33 @@ const MAX_PER_ROOM: usize = 64;
|
||||
|
||||
type Rooms = Arc<Mutex<HashMap<String, HashMap<String, Instant>>>>;
|
||||
|
||||
/// Shared state: the room table + an optional bearer token. When the token is
|
||||
/// non-empty, register/peers require `Authorization: Bearer <token>`; when empty
|
||||
/// (default) the endpoints are open — fine for a LAN/test run, but set a token
|
||||
/// before exposing it publicly.
|
||||
#[derive(Clone)]
|
||||
struct AppState {
|
||||
rooms: Rooms,
|
||||
token: Arc<String>,
|
||||
}
|
||||
|
||||
/// Constant-time-ish bearer check (length + byte compare). Empty token → open.
|
||||
fn authorized(headers: &HeaderMap, token: &str) -> bool {
|
||||
if token.is_empty() {
|
||||
return true;
|
||||
}
|
||||
let Some(auth) = headers.get("authorization").and_then(|v| v.to_str().ok()) else {
|
||||
return false;
|
||||
};
|
||||
let presented = auth.strip_prefix("Bearer ").unwrap_or("");
|
||||
presented.len() == token.len()
|
||||
&& presented
|
||||
.bytes()
|
||||
.zip(token.bytes())
|
||||
.fold(0u8, |acc, (a, b)| acc | (a ^ b))
|
||||
== 0
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct Register {
|
||||
room: String,
|
||||
@@ -65,36 +93,55 @@ fn live_peers(rooms: &Rooms, room: &str, exclude: Option<&str>) -> Vec<String> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
async fn register(State(rooms): State<Rooms>, Json(r): Json<Register>) -> Json<Peers> {
|
||||
async fn register(
|
||||
State(st): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Json(r): Json<Register>,
|
||||
) -> Result<Json<Peers>, StatusCode> {
|
||||
if !authorized(&headers, &st.token) {
|
||||
return Err(StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
if !r.room.is_empty() && !r.id.is_empty() {
|
||||
let now = Instant::now();
|
||||
let mut map = rooms.lock().unwrap();
|
||||
let mut map = st.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)) })
|
||||
Ok(Json(Peers { peers: live_peers(&st.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) })
|
||||
async fn peers(
|
||||
State(st): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Query(q): Query<PeersQuery>,
|
||||
) -> Result<Json<Peers>, StatusCode> {
|
||||
if !authorized(&headers, &st.token) {
|
||||
return Err(StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
Ok(Json(Peers { peers: live_peers(&st.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 token = std::env::var("TETHER_RENDEZVOUS_TOKEN").unwrap_or_default();
|
||||
let state = AppState {
|
||||
rooms: Arc::new(Mutex::new(HashMap::new())),
|
||||
token: Arc::new(token.clone()),
|
||||
};
|
||||
|
||||
let app = Router::new()
|
||||
.route("/rendezvous/register", post(register))
|
||||
.route("/rendezvous/peers", get(peers))
|
||||
.route("/healthz", get(|| async { "ok" }))
|
||||
.with_state(rooms);
|
||||
.with_state(state);
|
||||
|
||||
let listener = tokio::net::TcpListener::bind(("0.0.0.0", port)).await.unwrap();
|
||||
println!("tether-rendezvous listening on 0.0.0.0:{port}");
|
||||
let auth = if token.is_empty() { "OPEN (no token — set TETHER_RENDEZVOUS_TOKEN before public exposure)" } else { "token-gated" };
|
||||
println!("tether-rendezvous listening on 0.0.0.0:{port} — {auth}");
|
||||
axum::serve(listener, app)
|
||||
.with_graceful_shutdown(async {
|
||||
let _ = tokio::signal::ctrl_c().await;
|
||||
|
||||
Reference in New Issue
Block a user