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
This commit is contained in:
Claude 2026-02-09 07:38:49 +00:00
parent accbfdb1ea
commit cc57356a53
No known key found for this signature in database
2 changed files with 78 additions and 33 deletions

View file

@ -1,5 +1,6 @@
use axum::{ use axum::{
extract::{ extract::{
connect_info::ConnectInfo,
ws::{Message, WebSocket, WebSocketUpgrade}, ws::{Message, WebSocket, WebSocketUpgrade},
State, State,
}, },
@ -63,6 +64,7 @@ enum SignalMessage {
/// Server assigns an ID to a new peer /// Server assigns an ID to a new peer
Welcome { Welcome {
peer_id: String, peer_id: String,
ip: String,
peers: Vec<PeerInfo>, peers: Vec<PeerInfo>,
}, },
/// Peer announces themselves (with optional name) /// Peer announces themselves (with optional name)
@ -73,6 +75,7 @@ enum SignalMessage {
PeerJoined { PeerJoined {
peer_id: String, peer_id: String,
name: String, name: String,
ip: String,
}, },
/// Broadcast: a peer left /// Broadcast: a peer left
PeerLeft { PeerLeft {
@ -120,6 +123,7 @@ enum SignalMessage {
struct PeerInfo { struct PeerInfo {
peer_id: String, peer_id: String,
name: String, name: String,
ip: String,
is_sharing: bool, is_sharing: bool,
has_audio: bool, has_audio: bool,
} }
@ -127,6 +131,7 @@ struct PeerInfo {
/// State for a connected peer /// State for a connected peer
struct PeerState { struct PeerState {
name: String, name: String,
ip: String,
is_sharing: bool, is_sharing: bool,
has_audio: bool, has_audio: bool,
tx: broadcast::Sender<SignalMessage>, tx: broadcast::Sender<SignalMessage>,
@ -150,6 +155,7 @@ impl AppState {
.map(|entry| PeerInfo { .map(|entry| PeerInfo {
peer_id: entry.key().clone(), peer_id: entry.key().clone(),
name: entry.value().name.clone(), name: entry.value().name.clone(),
ip: entry.value().ip.clone(),
is_sharing: entry.value().is_sharing, is_sharing: entry.value().is_sharing,
has_audio: entry.value().has_audio, 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(); 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::<SocketAddr>())
.await
.unwrap();
} }
async fn serve_index() -> impl IntoResponse { async fn serve_index() -> impl IntoResponse {
Html(include_str!("../static/index.html")) Html(include_str!("../static/index.html"))
} }
async fn ws_handler(ws: WebSocketUpgrade, State(state): State<Arc<AppState>>) -> impl IntoResponse { async fn ws_handler(
ws.on_upgrade(move |socket| handle_socket(socket, state)) ws: WebSocketUpgrade,
ConnectInfo(addr): ConnectInfo<SocketAddr>,
State(state): State<Arc<AppState>>,
) -> impl IntoResponse {
ws.on_upgrade(move |socket| handle_socket(socket, state, addr))
} }
async fn handle_socket(socket: WebSocket, state: Arc<AppState>) { async fn handle_socket(socket: WebSocket, state: Arc<AppState>, addr: SocketAddr) {
let peer_id = Uuid::new_v4().to_string(); let peer_id = Uuid::new_v4().to_string();
let peer_ip = addr.ip().to_string();
let (tx, _) = broadcast::channel::<SignalMessage>(64); let (tx, _) = broadcast::channel::<SignalMessage>(64);
// Add peer to state with default name // Add peer to state with default name
@ -236,6 +250,7 @@ async fn handle_socket(socket: WebSocket, state: Arc<AppState>) {
peer_id.clone(), peer_id.clone(),
PeerState { PeerState {
name: format!("Peer {}", &peer_id[..8]), name: format!("Peer {}", &peer_id[..8]),
ip: peer_ip.clone(),
is_sharing: false, is_sharing: false,
has_audio: false, has_audio: false,
tx: tx.clone(), tx: tx.clone(),
@ -250,6 +265,7 @@ async fn handle_socket(socket: WebSocket, state: Arc<AppState>) {
// Send welcome message with current peer list // Send welcome message with current peer list
let welcome = SignalMessage::Welcome { let welcome = SignalMessage::Welcome {
peer_id: peer_id.clone(), peer_id: peer_id.clone(),
ip: peer_ip,
peers: peers.into_iter().filter(|p| p.peer_id != peer_id).collect(), peers: peers.into_iter().filter(|p| p.peer_id != peer_id).collect(),
}; };
@ -319,6 +335,11 @@ async fn handle_socket(socket: WebSocket, state: Arc<AppState>) {
async fn handle_signal(state: &AppState, from_peer: &str, signal: SignalMessage) { async fn handle_signal(state: &AppState, from_peer: &str, signal: SignalMessage) {
match signal { match signal {
SignalMessage::Announce { name } => { 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) { if let Some(mut peer) = state.peers.get_mut(from_peer) {
peer.name = name.clone(); peer.name = name.clone();
} }
@ -326,6 +347,7 @@ async fn handle_signal(state: &AppState, from_peer: &str, signal: SignalMessage)
SignalMessage::PeerJoined { SignalMessage::PeerJoined {
peer_id: from_peer.to_string(), peer_id: from_peer.to_string(),
name, name,
ip,
}, },
Some(from_peer), Some(from_peer),
); );

View file

@ -428,8 +428,9 @@
// ==================== State ==================== // ==================== State ====================
const state = { const state = {
peerId: null, peerId: null,
peerIp: null, // Our own IP as seen by the server
peerName: localStorage.getItem('lanshare_name') || null, 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, localStream: null,
isSharing: false, isSharing: false,
includeAudio: true, includeAudio: true,
@ -456,6 +457,14 @@
return candidates; 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'}`); console.log(`ICE mode: ${USE_TRICKLE_ICE ? 'TRICKLE' : 'BUNDLED'}`);
// ==================== DOM Elements ==================== // ==================== DOM Elements ====================
@ -537,20 +546,22 @@
switch (msg.type) { switch (msg.type) {
case 'welcome': case 'welcome':
state.peerId = msg.peer_id; state.peerId = msg.peer_id;
state.peerIp = msg.ip;
console.log(`Our IP as seen by server: ${msg.ip}`);
connectingOverlay.style.display = 'none'; connectingOverlay.style.display = 'none';
updateConnectionStatus(true); updateConnectionStatus(true);
// Set name // Set name
if (!state.peerName) { if (!state.peerName) {
state.peerName = prompt('Enter your name:', `User ${msg.peer_id.slice(0, 4)}`) || `User ${msg.peer_id.slice(0, 4)}`; 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); localStorage.setItem('lanshare_name', state.peerName);
} }
send({ type: 'announce', name: state.peerName }); send({ type: 'announce', name: state.peerName });
// Add existing peers // Add existing peers
for (const peer of msg.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 // Request stream from peers that are already sharing
if (peer.is_sharing) { if (peer.is_sharing) {
requestStreamFrom(peer.peer_id); requestStreamFrom(peer.peer_id);
@ -560,7 +571,7 @@
break; break;
case 'peer_joined': 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 we're sharing, proactively send an offer to the new peer
if (state.isSharing && state.localStream) { if (state.isSharing && state.localStream) {
await sendOfferTo(msg.peer_id); await sendOfferTo(msg.peer_id);
@ -607,7 +618,7 @@
if (state.isSharing && state.localStream) { if (state.isSharing && state.localStream) {
// Ensure we have this peer in our state (handles race condition) // Ensure we have this peer in our state (handles race condition)
if (!state.peers.has(msg.from)) { 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); await sendOfferTo(msg.from);
} }
@ -628,11 +639,12 @@
} }
// ==================== Peer Management ==================== // ==================== Peer Management ====================
function addPeer(peerId, name, isSharing, hasAudio) { function addPeer(peerId, name, ip, isSharing, hasAudio) {
if (peerId === state.peerId) return; if (peerId === state.peerId) return;
state.peers.set(peerId, { state.peers.set(peerId, {
name, name,
ip,
isSharing, isSharing,
hasAudio, hasAudio,
stream: null, stream: null,
@ -681,13 +693,16 @@
const c = event.candidate; const c = event.candidate;
console.log(`[${peerId.slice(0,8)}] Local candidate: ${c.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}`); console.log(` → type: ${c.type}, protocol: ${c.protocol}, address: ${c.address}, port: ${c.port}`);
if (USE_TRICKLE_ICE) { 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({ send({
type: 'ice_candidate', type: 'ice_candidate',
from: state.peerId, from: state.peerId,
to: peerId, to: peerId,
candidate: JSON.stringify(event.candidate), candidate: JSON.stringify(candidateObj),
}); });
} }
} else { } else {
@ -784,10 +799,11 @@
await waitForIceGathering(pc, peerId); 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); const candidates = extractCandidatesFromSDP(sdpToSend.sdp);
console.log(`[${peerId.slice(0,8)}] Sending offer with ${candidates.length} candidates in SDP`); console.log(`[${peerId.slice(0,8)}] Sending offer with ${candidates.length} candidates in SDP`);
send({ send({
type: 'offer', type: 'offer',
from: state.peerId, from: state.peerId,
@ -799,39 +815,42 @@
async function handleOffer(fromPeerId, sdpJson) { async function handleOffer(fromPeerId, sdpJson) {
// Ensure we have this peer in state // Ensure we have this peer in state
if (!state.peers.has(fromPeerId)) { 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); const peer = state.peers.get(fromPeerId);
// Close existing connection if any (new offer = renegotiation) // Close existing connection if any (new offer = renegotiation)
if (peer.peerConnection) { if (peer.peerConnection) {
peer.peerConnection.close(); peer.peerConnection.close();
} }
console.log(`[${fromPeerId.slice(0,8)}] handleOffer: creating answer (${USE_TRICKLE_ICE ? 'trickle' : 'bundled'})`); console.log(`[${fromPeerId.slice(0,8)}] handleOffer: creating answer (${USE_TRICKLE_ICE ? 'trickle' : 'bundled'})`);
const pc = createPeerConnection(fromPeerId); const pc = createPeerConnection(fromPeerId);
peer.peerConnection = pc; peer.peerConnection = pc;
// Replace any mDNS .local addresses in the remote SDP with the peer's real IP
const sdp = JSON.parse(sdpJson); const sdp = JSON.parse(sdpJson);
sdp.sdp = replaceMdnsWithIp(sdp.sdp, peer.ip);
const remoteCandidates = extractCandidatesFromSDP(sdp.sdp); const remoteCandidates = extractCandidatesFromSDP(sdp.sdp);
console.log(`[${fromPeerId.slice(0,8)}] Received offer with ${remoteCandidates.length} candidates in SDP:`); console.log(`[${fromPeerId.slice(0,8)}] Received offer with ${remoteCandidates.length} candidates in SDP:`);
remoteCandidates.forEach(c => console.log(` ${c}`)); remoteCandidates.forEach(c => console.log(` ${c}`));
await pc.setRemoteDescription(new RTCSessionDescription(sdp)); await pc.setRemoteDescription(new RTCSessionDescription(sdp));
const answer = await pc.createAnswer(); const answer = await pc.createAnswer();
await pc.setLocalDescription(answer); await pc.setLocalDescription(answer);
if (!USE_TRICKLE_ICE) { if (!USE_TRICKLE_ICE) {
// Bundled mode: wait for all candidates to be in SDP // Bundled mode: wait for all candidates to be in SDP
await waitForIceGathering(pc, fromPeerId); 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); const localCandidates = extractCandidatesFromSDP(sdpToSend.sdp);
console.log(`[${fromPeerId.slice(0,8)}] Sending answer with ${localCandidates.length} candidates in SDP`); console.log(`[${fromPeerId.slice(0,8)}] Sending answer with ${localCandidates.length} candidates in SDP`);
send({ send({
type: 'answer', type: 'answer',
from: state.peerId, from: state.peerId,
@ -846,18 +865,20 @@
console.warn(`[${fromPeerId.slice(0,8)}] handleAnswer: no peer connection`); console.warn(`[${fromPeerId.slice(0,8)}] handleAnswer: no peer connection`);
return; return;
} }
// Only set remote description if we're in the right state // Only set remote description if we're in the right state
if (peer.peerConnection.signalingState !== 'have-local-offer') { if (peer.peerConnection.signalingState !== 'have-local-offer') {
console.warn(`[${fromPeerId.slice(0,8)}] handleAnswer: wrong state: ${peer.peerConnection.signalingState}`); console.warn(`[${fromPeerId.slice(0,8)}] handleAnswer: wrong state: ${peer.peerConnection.signalingState}`);
return; return;
} }
// Replace any mDNS .local addresses in the remote SDP with the peer's real IP
const sdp = JSON.parse(sdpJson); const sdp = JSON.parse(sdpJson);
sdp.sdp = replaceMdnsWithIp(sdp.sdp, peer.ip);
const remoteCandidates = extractCandidatesFromSDP(sdp.sdp); const remoteCandidates = extractCandidatesFromSDP(sdp.sdp);
console.log(`[${fromPeerId.slice(0,8)}] Received answer with ${remoteCandidates.length} candidates in SDP:`); console.log(`[${fromPeerId.slice(0,8)}] Received answer with ${remoteCandidates.length} candidates in SDP:`);
remoteCandidates.forEach(c => console.log(` ${c}`)); remoteCandidates.forEach(c => console.log(` ${c}`));
console.log(`[${fromPeerId.slice(0,8)}] handleAnswer: setting remote description`); console.log(`[${fromPeerId.slice(0,8)}] handleAnswer: setting remote description`);
await peer.peerConnection.setRemoteDescription(new RTCSessionDescription(sdp)); await peer.peerConnection.setRemoteDescription(new RTCSessionDescription(sdp));
} }
@ -868,10 +889,12 @@
console.warn(`[${fromPeerId.slice(0,8)}] handleIceCandidate: no peer connection`); console.warn(`[${fromPeerId.slice(0,8)}] handleIceCandidate: no peer connection`);
return; return;
} }
// Replace any mDNS .local addresses with the peer's real IP
const candidate = JSON.parse(candidateJson); const candidate = JSON.parse(candidateJson);
candidate.candidate = replaceMdnsWithIp(candidate.candidate, peer.ip);
console.log(`[${fromPeerId.slice(0,8)}] Remote candidate: ${candidate.candidate}`); console.log(`[${fromPeerId.slice(0,8)}] Remote candidate: ${candidate.candidate}`);
peer.peerConnection.addIceCandidate(new RTCIceCandidate(candidate)) peer.peerConnection.addIceCandidate(new RTCIceCandidate(candidate))
.then(() => console.log(`[${fromPeerId.slice(0,8)}] Successfully added remote 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)); .catch(err => console.error(`[${fromPeerId.slice(0,8)}] Error adding ICE candidate:`, err));