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:
+52
-6
@@ -45,6 +45,14 @@ pub(crate) struct RtcTransport {
|
|||||||
api: Arc<API>,
|
api: Arc<API>,
|
||||||
peers: Arc<Mutex<HashMap<String, Arc<Peer>>>>,
|
peers: Arc<Mutex<HashMap<String, Arc<Peer>>>>,
|
||||||
sink: StdMutex<Option<Sink>>,
|
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 {
|
impl RtcTransport {
|
||||||
@@ -59,6 +67,7 @@ impl RtcTransport {
|
|||||||
api: Arc::new(api),
|
api: Arc::new(api),
|
||||||
peers: Arc::new(Mutex::new(HashMap::new())),
|
peers: Arc::new(Mutex::new(HashMap::new())),
|
||||||
sink: StdMutex::new(None),
|
sink: StdMutex::new(None),
|
||||||
|
ice: StdMutex::new(default_ice()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -66,16 +75,49 @@ impl RtcTransport {
|
|||||||
self.sink.lock().unwrap().clone()
|
self.sink.lock().unwrap().clone()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn rtc_config() -> RTCConfiguration {
|
fn rtc_config(&self) -> RTCConfiguration {
|
||||||
RTCConfiguration {
|
RTCConfiguration {
|
||||||
ice_servers: vec![RTCIceServer {
|
ice_servers: self.ice.lock().unwrap().clone(),
|
||||||
urls: vec!["stun:stun.l.google.com:19302".to_owned()],
|
|
||||||
..Default::default()
|
|
||||||
}],
|
|
||||||
..Default::default()
|
..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 ────────────────────────────────────────────────────────
|
// ── bus helpers ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
async fn post(&self, body: Value) {
|
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> {
|
async fn new_peer(self: &Arc<Self>, remote: &str, offerer: bool) -> Arc<Peer> {
|
||||||
let pc = Arc::new(
|
let pc = Arc::new(
|
||||||
self.api
|
self.api
|
||||||
.new_peer_connection(Self::rtc_config())
|
.new_peer_connection(self.rtc_config())
|
||||||
.await
|
.await
|
||||||
.expect("peer connection"),
|
.expect("peer connection"),
|
||||||
);
|
);
|
||||||
@@ -416,6 +458,10 @@ impl Transport for RtcTransport {
|
|||||||
|
|
||||||
// Signaling receive loop with reconnect/backoff.
|
// Signaling receive loop with reconnect/backoff.
|
||||||
rt.spawn(async move {
|
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 min = Duration::from_millis(500);
|
||||||
let max = Duration::from_secs(10);
|
let max = Duration::from_secs(10);
|
||||||
let mut backoff = min;
|
let mut backoff = min;
|
||||||
|
|||||||
Reference in New Issue
Block a user