core: RTC fetches server TURN creds from /api/turn-cred (cross-network P2P)

The RTC transport hardcoded Google STUN, so native clients never used the TURN
relay the homelab server already runs — meaning hole-punch failures (symmetric
NAT / CGNAT, i.e. cellular) had no E2E fallback. Now RtcTransport fetches
{server}/api/turn-cred at startup (same contract the web client uses) and feeds
the returned STUN+TURN iceServers into every RTCPeerConnection. Falls back to
STUN if the endpoint is absent (verified: local server 404s → STUN, delivery
still works via the other transports).

This is the client half of "cross-network P2P that just works" — the server
half (integrated pion TURN + /api/turn-cred) is already deployed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-21 00:54:38 -05:00
parent 377aa67d4d
commit 0e0b504808

View File

@@ -45,6 +45,14 @@ pub(crate) struct RtcTransport {
api: Arc<API>,
peers: Arc<Mutex<HashMap<String, Arc<Peer>>>>,
sink: StdMutex<Option<Sink>>,
ice: StdMutex<Vec<RTCIceServer>>, // refreshed from /api/turn-cred at start
}
fn default_ice() -> Vec<RTCIceServer> {
vec![RTCIceServer {
urls: vec!["stun:stun.l.google.com:19302".to_owned()],
..Default::default()
}]
}
impl RtcTransport {
@@ -59,6 +67,7 @@ impl RtcTransport {
api: Arc::new(api),
peers: Arc::new(Mutex::new(HashMap::new())),
sink: StdMutex::new(None),
ice: StdMutex::new(default_ice()),
}
}
@@ -66,16 +75,49 @@ impl RtcTransport {
self.sink.lock().unwrap().clone()
}
fn rtc_config() -> RTCConfiguration {
fn rtc_config(&self) -> RTCConfiguration {
RTCConfiguration {
ice_servers: vec![RTCIceServer {
urls: vec!["stun:stun.l.google.com:19302".to_owned()],
..Default::default()
}],
ice_servers: self.ice.lock().unwrap().clone(),
..Default::default()
}
}
/// Fetch server-issued ICE servers (STUN + short-lived TURN creds) from
/// `/api/turn-cred`. This is what makes cross-network P2P actually connect
/// when hole-punching fails (symmetric NAT / CGNAT). Falls back to STUN.
async fn refresh_ice(&self) {
let url = format!("{}/api/turn-cred", self.cfg.server.trim_end_matches('/'));
let resp = match self.http.get(&url).send().await {
Ok(r) if r.status().is_success() => r,
_ => return, // keep the STUN fallback
};
let Ok(v) = resp.json::<serde_json::Value>().await else {
return;
};
let Some(arr) = v["iceServers"].as_array() else {
return;
};
let mut servers = Vec::new();
for s in arr {
let urls: Vec<String> = s["urls"]
.as_array()
.map(|a| a.iter().filter_map(|u| u.as_str().map(String::from)).collect())
.unwrap_or_default();
if urls.is_empty() {
continue;
}
servers.push(RTCIceServer {
urls,
username: s["username"].as_str().unwrap_or("").to_string(),
credential: s["credential"].as_str().unwrap_or("").to_string(),
..Default::default()
});
}
if !servers.is_empty() {
*self.ice.lock().unwrap() = servers;
}
}
// ── bus helpers ────────────────────────────────────────────────────────
async fn post(&self, body: Value) {
@@ -112,7 +154,7 @@ impl RtcTransport {
async fn new_peer(self: &Arc<Self>, remote: &str, offerer: bool) -> Arc<Peer> {
let pc = Arc::new(
self.api
.new_peer_connection(Self::rtc_config())
.new_peer_connection(self.rtc_config())
.await
.expect("peer connection"),
);
@@ -416,6 +458,10 @@ impl Transport for RtcTransport {
// Signaling receive loop with reconnect/backoff.
rt.spawn(async move {
// Pull TURN creds before any peer is built so they're in the ICE
// config (presence→offer takes a beat, so this lands in time).
self.refresh_ice().await;
let min = Duration::from_millis(500);
let max = Duration::from_secs(10);
let mut backoff = min;