From cc57356a533592c49106085e848915b3c384b9bf Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 9 Feb 2026 07:38:49 +0000 Subject: [PATCH] Fix ICE failures by replacing mDNS .local addresses with real peer IPs Modern browsers obfuscate local IP addresses with mDNS hostnames (e.g. abcdef12-3456.local) for privacy. When mDNS resolution is unavailable on the LAN, WebRTC ICE candidates cannot be resolved and the peer connection fails. This fix makes the signaling server extract each client's real IP from the TCP connection and share it via Welcome/PeerJoined messages. The client then replaces any .local mDNS addresses in SDP offers, answers, and trickle ICE candidates with the peer's actual IP address, enabling direct IP connectivity without relying on mDNS. https://claude.ai/code/session_01EZrFdyAR35RLwRdHUA9zPJ --- lanshare/src/main.rs | 30 ++++++++++++-- lanshare/static/index.html | 81 ++++++++++++++++++++++++-------------- 2 files changed, 78 insertions(+), 33 deletions(-) diff --git a/lanshare/src/main.rs b/lanshare/src/main.rs index 270521b..04fd728 100644 --- a/lanshare/src/main.rs +++ b/lanshare/src/main.rs @@ -1,5 +1,6 @@ use axum::{ extract::{ + connect_info::ConnectInfo, ws::{Message, WebSocket, WebSocketUpgrade}, State, }, @@ -63,6 +64,7 @@ enum SignalMessage { /// Server assigns an ID to a new peer Welcome { peer_id: String, + ip: String, peers: Vec, }, /// Peer announces themselves (with optional name) @@ -73,6 +75,7 @@ enum SignalMessage { PeerJoined { peer_id: String, name: String, + ip: String, }, /// Broadcast: a peer left PeerLeft { @@ -120,6 +123,7 @@ enum SignalMessage { struct PeerInfo { peer_id: String, name: String, + ip: String, is_sharing: bool, has_audio: bool, } @@ -127,6 +131,7 @@ struct PeerInfo { /// State for a connected peer struct PeerState { name: String, + ip: String, is_sharing: bool, has_audio: bool, tx: broadcast::Sender, @@ -150,6 +155,7 @@ impl AppState { .map(|entry| PeerInfo { peer_id: entry.key().clone(), name: entry.value().name.clone(), + ip: entry.value().ip.clone(), is_sharing: entry.value().is_sharing, has_audio: entry.value().has_audio, }) @@ -216,19 +222,27 @@ async fn main() { let config = RustlsConfig::from_pem_file(args.certificate, args.key).await.unwrap(); - axum_server::bind_rustls(addr, config).serve(app.into_make_service()).await.unwrap(); + axum_server::bind_rustls(addr, config) + .serve(app.into_make_service_with_connect_info::()) + .await + .unwrap(); } async fn serve_index() -> impl IntoResponse { Html(include_str!("../static/index.html")) } -async fn ws_handler(ws: WebSocketUpgrade, State(state): State>) -> impl IntoResponse { - ws.on_upgrade(move |socket| handle_socket(socket, state)) +async fn ws_handler( + ws: WebSocketUpgrade, + ConnectInfo(addr): ConnectInfo, + State(state): State>, +) -> impl IntoResponse { + ws.on_upgrade(move |socket| handle_socket(socket, state, addr)) } -async fn handle_socket(socket: WebSocket, state: Arc) { +async fn handle_socket(socket: WebSocket, state: Arc, addr: SocketAddr) { let peer_id = Uuid::new_v4().to_string(); + let peer_ip = addr.ip().to_string(); let (tx, _) = broadcast::channel::(64); // Add peer to state with default name @@ -236,6 +250,7 @@ async fn handle_socket(socket: WebSocket, state: Arc) { peer_id.clone(), PeerState { name: format!("Peer {}", &peer_id[..8]), + ip: peer_ip.clone(), is_sharing: false, has_audio: false, tx: tx.clone(), @@ -250,6 +265,7 @@ async fn handle_socket(socket: WebSocket, state: Arc) { // Send welcome message with current peer list let welcome = SignalMessage::Welcome { peer_id: peer_id.clone(), + ip: peer_ip, peers: peers.into_iter().filter(|p| p.peer_id != peer_id).collect(), }; @@ -319,6 +335,11 @@ async fn handle_socket(socket: WebSocket, state: Arc) { async fn handle_signal(state: &AppState, from_peer: &str, signal: SignalMessage) { match signal { SignalMessage::Announce { name } => { + let ip = state + .peers + .get(from_peer) + .map(|p| p.ip.clone()) + .unwrap_or_default(); if let Some(mut peer) = state.peers.get_mut(from_peer) { peer.name = name.clone(); } @@ -326,6 +347,7 @@ async fn handle_signal(state: &AppState, from_peer: &str, signal: SignalMessage) SignalMessage::PeerJoined { peer_id: from_peer.to_string(), name, + ip, }, Some(from_peer), ); diff --git a/lanshare/static/index.html b/lanshare/static/index.html index 20efc15..60cf32a 100644 --- a/lanshare/static/index.html +++ b/lanshare/static/index.html @@ -428,8 +428,9 @@ // ==================== State ==================== const state = { peerId: null, + peerIp: null, // Our own IP as seen by the server peerName: localStorage.getItem('lanshare_name') || null, - peers: new Map(), // peer_id -> { name, isSharing, hasAudio, stream, peerConnection } + peers: new Map(), // peer_id -> { name, ip, isSharing, hasAudio, stream, peerConnection } localStream: null, isSharing: false, includeAudio: true, @@ -456,6 +457,14 @@ return candidates; } + // Replace mDNS .local addresses with a real IP. + // Modern browsers obfuscate local IPs as UUIDs ending in .local for privacy, + // which breaks direct LAN connections when mDNS resolution isn't available. + function replaceMdnsWithIp(str, realIp) { + if (!str || !realIp) return str; + return str.replace(/[a-f0-9-]+\.local/gi, realIp); + } + console.log(`ICE mode: ${USE_TRICKLE_ICE ? 'TRICKLE' : 'BUNDLED'}`); // ==================== DOM Elements ==================== @@ -537,20 +546,22 @@ switch (msg.type) { case 'welcome': state.peerId = msg.peer_id; + state.peerIp = msg.ip; + console.log(`Our IP as seen by server: ${msg.ip}`); connectingOverlay.style.display = 'none'; updateConnectionStatus(true); - + // Set name if (!state.peerName) { state.peerName = prompt('Enter your name:', `User ${msg.peer_id.slice(0, 4)}`) || `User ${msg.peer_id.slice(0, 4)}`; localStorage.setItem('lanshare_name', state.peerName); } - + send({ type: 'announce', name: state.peerName }); - + // Add existing peers for (const peer of msg.peers) { - addPeer(peer.peer_id, peer.name, peer.is_sharing, peer.has_audio); + addPeer(peer.peer_id, peer.name, peer.ip, peer.is_sharing, peer.has_audio); // Request stream from peers that are already sharing if (peer.is_sharing) { requestStreamFrom(peer.peer_id); @@ -560,7 +571,7 @@ break; case 'peer_joined': - addPeer(msg.peer_id, msg.name, false, false); + addPeer(msg.peer_id, msg.name, msg.ip, false, false); // If we're sharing, proactively send an offer to the new peer if (state.isSharing && state.localStream) { await sendOfferTo(msg.peer_id); @@ -607,7 +618,7 @@ if (state.isSharing && state.localStream) { // Ensure we have this peer in our state (handles race condition) if (!state.peers.has(msg.from)) { - addPeer(msg.from, `Peer ${msg.from.slice(0, 4)}`, false, false); + addPeer(msg.from, `Peer ${msg.from.slice(0, 4)}`, null, false, false); } await sendOfferTo(msg.from); } @@ -628,11 +639,12 @@ } // ==================== Peer Management ==================== - function addPeer(peerId, name, isSharing, hasAudio) { + function addPeer(peerId, name, ip, isSharing, hasAudio) { if (peerId === state.peerId) return; - + state.peers.set(peerId, { name, + ip, isSharing, hasAudio, stream: null, @@ -681,13 +693,16 @@ const c = event.candidate; console.log(`[${peerId.slice(0,8)}] Local candidate: ${c.candidate}`); console.log(` → type: ${c.type}, protocol: ${c.protocol}, address: ${c.address}, port: ${c.port}`); - + if (USE_TRICKLE_ICE) { + // Replace mDNS .local addresses with our real IP for direct LAN connectivity + const candidateObj = event.candidate.toJSON(); + candidateObj.candidate = replaceMdnsWithIp(candidateObj.candidate, state.peerIp); send({ type: 'ice_candidate', from: state.peerId, to: peerId, - candidate: JSON.stringify(event.candidate), + candidate: JSON.stringify(candidateObj), }); } } else { @@ -784,10 +799,11 @@ await waitForIceGathering(pc, peerId); } - const sdpToSend = pc.localDescription; + // Replace mDNS .local addresses with our real IP for direct LAN connectivity + const sdpToSend = { type: pc.localDescription.type, sdp: replaceMdnsWithIp(pc.localDescription.sdp, state.peerIp) }; const candidates = extractCandidatesFromSDP(sdpToSend.sdp); console.log(`[${peerId.slice(0,8)}] Sending offer with ${candidates.length} candidates in SDP`); - + send({ type: 'offer', from: state.peerId, @@ -799,39 +815,42 @@ async function handleOffer(fromPeerId, sdpJson) { // Ensure we have this peer in state if (!state.peers.has(fromPeerId)) { - addPeer(fromPeerId, `Peer ${fromPeerId.slice(0, 4)}`, true, false); + addPeer(fromPeerId, `Peer ${fromPeerId.slice(0, 4)}`, null, true, false); } - + const peer = state.peers.get(fromPeerId); - + // Close existing connection if any (new offer = renegotiation) if (peer.peerConnection) { peer.peerConnection.close(); } - + console.log(`[${fromPeerId.slice(0,8)}] handleOffer: creating answer (${USE_TRICKLE_ICE ? 'trickle' : 'bundled'})`); const pc = createPeerConnection(fromPeerId); peer.peerConnection = pc; - + + // Replace any mDNS .local addresses in the remote SDP with the peer's real IP const sdp = JSON.parse(sdpJson); + sdp.sdp = replaceMdnsWithIp(sdp.sdp, peer.ip); const remoteCandidates = extractCandidatesFromSDP(sdp.sdp); console.log(`[${fromPeerId.slice(0,8)}] Received offer with ${remoteCandidates.length} candidates in SDP:`); remoteCandidates.forEach(c => console.log(` ${c}`)); - + await pc.setRemoteDescription(new RTCSessionDescription(sdp)); - + const answer = await pc.createAnswer(); await pc.setLocalDescription(answer); - + if (!USE_TRICKLE_ICE) { // Bundled mode: wait for all candidates to be in SDP await waitForIceGathering(pc, fromPeerId); } - - const sdpToSend = pc.localDescription; + + // Replace mDNS .local addresses with our real IP for direct LAN connectivity + const sdpToSend = { type: pc.localDescription.type, sdp: replaceMdnsWithIp(pc.localDescription.sdp, state.peerIp) }; const localCandidates = extractCandidatesFromSDP(sdpToSend.sdp); console.log(`[${fromPeerId.slice(0,8)}] Sending answer with ${localCandidates.length} candidates in SDP`); - + send({ type: 'answer', from: state.peerId, @@ -846,18 +865,20 @@ console.warn(`[${fromPeerId.slice(0,8)}] handleAnswer: no peer connection`); return; } - + // Only set remote description if we're in the right state if (peer.peerConnection.signalingState !== 'have-local-offer') { console.warn(`[${fromPeerId.slice(0,8)}] handleAnswer: wrong state: ${peer.peerConnection.signalingState}`); return; } - + + // Replace any mDNS .local addresses in the remote SDP with the peer's real IP const sdp = JSON.parse(sdpJson); + sdp.sdp = replaceMdnsWithIp(sdp.sdp, peer.ip); const remoteCandidates = extractCandidatesFromSDP(sdp.sdp); console.log(`[${fromPeerId.slice(0,8)}] Received answer with ${remoteCandidates.length} candidates in SDP:`); remoteCandidates.forEach(c => console.log(` ${c}`)); - + console.log(`[${fromPeerId.slice(0,8)}] handleAnswer: setting remote description`); await peer.peerConnection.setRemoteDescription(new RTCSessionDescription(sdp)); } @@ -868,10 +889,12 @@ console.warn(`[${fromPeerId.slice(0,8)}] handleIceCandidate: no peer connection`); return; } - + + // Replace any mDNS .local addresses with the peer's real IP const candidate = JSON.parse(candidateJson); + candidate.candidate = replaceMdnsWithIp(candidate.candidate, peer.ip); console.log(`[${fromPeerId.slice(0,8)}] Remote candidate: ${candidate.candidate}`); - + peer.peerConnection.addIceCandidate(new RTCIceCandidate(candidate)) .then(() => console.log(`[${fromPeerId.slice(0,8)}] Successfully added remote candidate`)) .catch(err => console.error(`[${fromPeerId.slice(0,8)}] Error adding ICE candidate:`, err));