feat(nat): Tailscale-inspired STUN/ICE + port mapping + mid-call re-gathering (#28)
Some checks failed
Mirror to GitHub / mirror (push) Failing after 23s
Build Release Binaries / build-amd64 (push) Failing after 6m8s

Phase 8: 5 new modules bringing NAT traversal close to Tailscale's approach.

- stun.rs: RFC 5389 STUN client — public server reflexive discovery,
  XOR-MAPPED-ADDRESS parsing, parallel probe with retry, STUN fallback
  in desktop try_reflect_own_addr()
- portmap.rs: NAT-PMP (RFC 6886) + PCP (RFC 6887) + UPnP IGD port
  mapping — gateway discovery, acquire/release/refresh lifecycle,
  new PeerCandidates.mapped candidate type in dial order
- ice_agent.rs: candidate lifecycle — gather(), re_gather(),
  apply_peer_update() with monotonic generation counter,
  CandidateUpdate signal message forwarded by relay
- netcheck.rs: comprehensive diagnostic — NAT type, IPv4/v6,
  port mapping availability, relay latencies, CLI --netcheck
- relay_map.rs: RTT-sorted relay map, preferred() selection,
  populate_from_ack() for RegisterPresenceAck.available_relays

Relay: CallRegistry stores + cross-wires caller/callee_mapped_addr
into CallSetup.peer_mapped_addr. Region config + available_relays
populated from federation peers in RegisterPresenceAck.

Desktop: place_call/answer_call call acquire_port_mapping() and
fill caller/callee_mapped_addr. STUN+relay combined NAT detection.

571 tests pass (66 new), 0 regressions, 0 warnings.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Siavash Sameni
2026-04-14 10:17:17 +04:00
parent 9377a9009c
commit 8fcf1be341
26 changed files with 4555 additions and 44 deletions

View File

@@ -538,6 +538,7 @@ async fn main() -> anyhow::Result<()> {
ref call_id,
ref caller_reflexive_addr,
ref caller_local_addrs,
ref caller_mapped_addr,
..
} => {
// Is the target on THIS relay? If not, drop —
@@ -557,7 +558,8 @@ async fn main() -> anyhow::Result<()> {
// Stash in local registry so the answer path
// can find the call + route the reply back
// through the same federation link. Include
// Phase 5.5 LAN host candidates too.
// Phase 5.5 LAN host candidates + Phase 8
// port-mapped addr.
{
let mut reg = call_registry_d.lock().await;
reg.create_call(
@@ -567,6 +569,7 @@ async fn main() -> anyhow::Result<()> {
);
reg.set_caller_reflexive_addr(call_id, caller_reflexive_addr.clone());
reg.set_caller_local_addrs(call_id, caller_local_addrs.clone());
reg.set_caller_mapped_addr(call_id, caller_mapped_addr.clone());
reg.set_peer_relay_fp(call_id, Some(origin_relay_fp.clone()));
}
// Deliver the offer to the local target.
@@ -585,6 +588,7 @@ async fn main() -> anyhow::Result<()> {
accept_mode,
ref callee_reflexive_addr,
ref callee_local_addrs,
ref callee_mapped_addr,
..
} => {
// Look up the local caller fp from the registry.
@@ -616,14 +620,11 @@ async fn main() -> anyhow::Result<()> {
}
// Accept — stash the callee's reflex addr + LAN
// host candidates + mark the call active,
// then read back everything needed to cross-
// wire peer_direct_addr + peer_local_addrs in
// the local CallSetup.
// Also set peer_relay_fp so the originating
// relay knows where to forward MediaPathReport.
// host candidates + mapped addr + mark the call
// active, then read back everything needed to
// cross-wire into the local CallSetup.
let room_name = format!("call-{call_id}");
let (callee_addr_for_setup, callee_local_for_setup) = {
let (callee_addr_for_setup, callee_local_for_setup, callee_mapped_for_setup) = {
let mut reg = call_registry_d.lock().await;
reg.set_active(call_id, accept_mode, room_name.clone());
reg.set_peer_relay_fp(call_id, Some(origin_relay_fp.clone()));
@@ -632,10 +633,12 @@ async fn main() -> anyhow::Result<()> {
callee_reflexive_addr.clone(),
);
reg.set_callee_local_addrs(call_id, callee_local_addrs.clone());
reg.set_callee_mapped_addr(call_id, callee_mapped_addr.clone());
let c = reg.get(call_id);
(
c.and_then(|c| c.callee_reflexive_addr.clone()),
c.map(|c| c.callee_local_addrs.clone()).unwrap_or_default(),
c.and_then(|c| c.callee_mapped_addr.clone()),
)
};
@@ -648,19 +651,13 @@ async fn main() -> anyhow::Result<()> {
}
// Emit the LOCAL CallSetup to our local caller.
// relay_addr = our own advertised addr so if P2P
// fails the caller will at least dial OUR relay
// (single-relay fallback — Phase 4.1 will wire
// federated media so that actually reaches the
// peer). peer_direct_addr = the callee's reflex
// addr carried in the answer. peer_local_addrs
// = callee's LAN host candidates (Phase 5.5 ICE).
let setup = SignalMessage::CallSetup {
call_id: call_id.clone(),
room: room_name.clone(),
relay_addr: advertised_addr_d.clone(),
peer_direct_addr: callee_addr_for_setup,
peer_local_addrs: callee_local_for_setup,
peer_mapped_addr: callee_mapped_for_setup,
};
let hub = signal_hub_d.lock().await;
let _ = hub.send_to(&caller_fp, &setup).await;
@@ -772,6 +769,14 @@ async fn main() -> anyhow::Result<()> {
let signal_hub = signal_hub.clone();
let call_registry = call_registry.clone();
let advertised_addr_str = advertised_addr_str.clone();
// Phase 8: relay region + peer addresses for RegisterPresenceAck
let relay_region = config.region.clone();
let relay_peers_for_ack: Vec<String> = config.peers.iter()
.filter_map(|p| {
let label = p.label.as_deref().unwrap_or("peer");
Some(format!("{label}|{}", p.url))
})
.collect();
// Phase 4: per-task clone of this relay's federation TLS
// fingerprint so the FederatedSignalForward envelopes the
// spawned signal handler builds carry `origin_relay_fp`.
@@ -1005,6 +1010,8 @@ async fn main() -> anyhow::Result<()> {
success: true,
error: None,
relay_build: Some(BUILD_GIT_HASH.to_string()),
relay_region: relay_region.clone(),
available_relays: relay_peers_for_ack.clone(),
}).await;
info!(%addr, fingerprint = %client_fp, alias = ?client_alias, "signal client registered");
@@ -1019,12 +1026,14 @@ async fn main() -> anyhow::Result<()> {
ref call_id,
ref caller_reflexive_addr,
ref caller_local_addrs,
ref caller_mapped_addr,
..
} => {
let target_fp = target_fingerprint.clone();
let call_id = call_id.clone();
let caller_addr_for_registry = caller_reflexive_addr.clone();
let caller_local_for_registry = caller_local_addrs.clone();
let caller_mapped_for_registry = caller_mapped_addr.clone();
// Check if target is online
let online = {
@@ -1097,6 +1106,10 @@ async fn main() -> anyhow::Result<()> {
&call_id,
caller_local_for_registry.clone(),
);
reg.set_caller_mapped_addr(
&call_id,
caller_mapped_for_registry.clone(),
);
}
// Send ringing to caller immediately
@@ -1118,6 +1131,7 @@ async fn main() -> anyhow::Result<()> {
reg.create_call(call_id.clone(), client_fp.clone(), target_fp.clone());
reg.set_caller_reflexive_addr(&call_id, caller_addr_for_registry);
reg.set_caller_local_addrs(&call_id, caller_local_for_registry);
reg.set_caller_mapped_addr(&call_id, caller_mapped_for_registry);
}
// Forward offer to callee
@@ -1139,12 +1153,14 @@ async fn main() -> anyhow::Result<()> {
ref accept_mode,
ref callee_reflexive_addr,
ref callee_local_addrs,
ref callee_mapped_addr,
..
} => {
let call_id = call_id.clone();
let mode = *accept_mode;
let callee_addr_for_registry = callee_reflexive_addr.clone();
let callee_local_for_registry = callee_local_addrs.clone();
let callee_mapped_for_registry = callee_mapped_addr.clone();
// Phase 4: look up peer fingerprint AND
// peer_relay_fp in one lock acquisition.
@@ -1207,17 +1223,20 @@ async fn main() -> anyhow::Result<()> {
// BOTH parties' addrs so we can cross-wire
// peer_direct_addr on the CallSetups below.
let room = format!("call-{call_id}");
let (caller_addr, callee_addr, caller_local, callee_local) = {
let (caller_addr, callee_addr, caller_local, callee_local, caller_mapped, callee_mapped) = {
let mut reg = call_registry.lock().await;
reg.set_active(&call_id, mode, room.clone());
reg.set_callee_reflexive_addr(&call_id, callee_addr_for_registry);
reg.set_callee_local_addrs(&call_id, callee_local_for_registry.clone());
reg.set_callee_mapped_addr(&call_id, callee_mapped_for_registry);
let call = reg.get(&call_id);
(
call.and_then(|c| c.caller_reflexive_addr.clone()),
call.and_then(|c| c.callee_reflexive_addr.clone()),
call.map(|c| c.caller_local_addrs.clone()).unwrap_or_default(),
call.map(|c| c.callee_local_addrs.clone()).unwrap_or_default(),
call.and_then(|c| c.caller_mapped_addr.clone()),
call.and_then(|c| c.callee_mapped_addr.clone()),
)
};
info!(
@@ -1266,6 +1285,7 @@ async fn main() -> anyhow::Result<()> {
relay_addr: relay_addr_for_setup,
peer_direct_addr: caller_addr.clone(),
peer_local_addrs: caller_local.clone(),
peer_mapped_addr: caller_mapped.clone(),
};
let hub = signal_hub.lock().await;
let _ = hub.send_to(&client_fp, &setup_for_callee).await;
@@ -1278,14 +1298,15 @@ async fn main() -> anyhow::Result<()> {
}
// Send CallSetup to BOTH parties with
// cross-wired peer_direct_addr +
// peer_local_addrs (Phase 5.5 ICE).
// cross-wired candidates (Phase 5.5 ICE
// + Phase 8 port-mapped addrs).
let setup_for_caller = SignalMessage::CallSetup {
call_id: call_id.clone(),
room: room.clone(),
relay_addr: relay_addr_for_setup.clone(),
peer_direct_addr: callee_addr.clone(),
peer_local_addrs: callee_local.clone(),
peer_mapped_addr: callee_mapped,
};
let setup_for_callee = SignalMessage::CallSetup {
call_id: call_id.clone(),
@@ -1293,6 +1314,7 @@ async fn main() -> anyhow::Result<()> {
relay_addr: relay_addr_for_setup,
peer_direct_addr: caller_addr.clone(),
peer_local_addrs: caller_local.clone(),
peer_mapped_addr: caller_mapped,
};
let hub = signal_hub.lock().await;
let _ = hub.send_to(&peer_fp, &setup_for_caller).await;
@@ -1382,6 +1404,45 @@ async fn main() -> anyhow::Result<()> {
}
}
// Phase 8: forward CandidateUpdate to the
// call peer for mid-call ICE re-gathering.
// Same forwarding pattern as MediaPathReport.
SignalMessage::CandidateUpdate { ref call_id, .. } => {
let (peer_fp, peer_relay_fp) = {
let reg = call_registry.lock().await;
match reg.get(call_id) {
Some(c) => (
reg.peer_fingerprint(call_id, &client_fp)
.map(|s| s.to_string()),
c.peer_relay_fp.clone(),
),
None => (None, None),
}
};
if let Some(fp) = peer_fp {
if let Some(ref origin_fp) = peer_relay_fp {
if let Some(ref fm) = federation_mgr {
let forward = SignalMessage::FederatedSignalForward {
inner: Box::new(msg.clone()),
origin_relay_fp: tls_fp.clone(),
};
if let Err(e) = fm.send_signal_to_peer(origin_fp, &forward).await {
warn!(
%call_id,
%origin_fp,
error = %e,
"cross-relay CandidateUpdate forward failed"
);
}
}
} else {
let hub = signal_hub.lock().await;
let _ = hub.send_to(&fp, &msg).await;
}
}
}
SignalMessage::Ping { timestamp_ms } => {
let _ = transport.send_signal(&SignalMessage::Pong { timestamp_ms }).await;
}