Merge pull request #1 from TheNeikos/claude/fix-ice-direct-ip-Q0OBq

Add IP address tracking and mDNS resolution for direct LAN connectivity
This commit is contained in:
Marcel Müller 2026-02-09 08:42:27 +01:00 committed by GitHub
commit 6193fe4cf1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
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));