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

@@ -61,6 +61,13 @@ pub struct DirectCall {
/// interface addresses from the `DirectCallAnswer`. Cross-
/// wired into the caller's `CallSetup.peer_local_addrs`.
pub callee_local_addrs: Vec<String>,
/// Phase 8 (Tailscale-inspired): caller's port-mapped
/// external address from NAT-PMP/PCP/UPnP. Cross-wired
/// into callee's `CallSetup.peer_mapped_addr`.
pub caller_mapped_addr: Option<String>,
/// Phase 8: callee's port-mapped external address.
/// Cross-wired into caller's `CallSetup.peer_mapped_addr`.
pub callee_mapped_addr: Option<String>,
}
/// Registry of active direct calls.
@@ -92,6 +99,8 @@ impl CallRegistry {
peer_relay_fp: None,
caller_local_addrs: Vec::new(),
callee_local_addrs: Vec::new(),
caller_mapped_addr: None,
callee_mapped_addr: None,
};
self.calls.insert(call_id.clone(), call);
self.calls.get(&call_id).unwrap()
@@ -142,6 +151,22 @@ impl CallRegistry {
}
}
/// Phase 8: stash the caller's port-mapped address from
/// the `DirectCallOffer`.
pub fn set_caller_mapped_addr(&mut self, call_id: &str, addr: Option<String>) {
if let Some(call) = self.calls.get_mut(call_id) {
call.caller_mapped_addr = addr;
}
}
/// Phase 8: stash the callee's port-mapped address from
/// the `DirectCallAnswer`.
pub fn set_callee_mapped_addr(&mut self, call_id: &str, addr: Option<String>) {
if let Some(call) = self.calls.get_mut(call_id) {
call.callee_mapped_addr = addr;
}
}
/// Get a call by ID.
pub fn get(&self, call_id: &str) -> Option<&DirectCall> {
self.calls.get(call_id)
@@ -340,6 +365,49 @@ mod tests {
reg.set_peer_relay_fp("does-not-exist", Some("x".into()));
}
#[test]
fn call_registry_stores_mapped_addrs() {
let mut reg = CallRegistry::new();
reg.create_call("c1".into(), "alice".into(), "bob".into());
// Default: both mapped addrs are None.
let c = reg.get("c1").unwrap();
assert!(c.caller_mapped_addr.is_none());
assert!(c.callee_mapped_addr.is_none());
// Caller advertises its port-mapped addr via DirectCallOffer.
reg.set_caller_mapped_addr("c1", Some("203.0.113.5:12345".into()));
assert_eq!(
reg.get("c1").unwrap().caller_mapped_addr.as_deref(),
Some("203.0.113.5:12345")
);
// Callee responds with its mapped addr.
reg.set_callee_mapped_addr("c1", Some("198.51.100.9:54321".into()));
assert_eq!(
reg.get("c1").unwrap().callee_mapped_addr.as_deref(),
Some("198.51.100.9:54321")
);
// Both addrs readable — relay uses them to cross-wire
// peer_mapped_addr in CallSetup.
let c = reg.get("c1").unwrap();
assert_eq!(c.caller_mapped_addr.as_deref(), Some("203.0.113.5:12345"));
assert_eq!(c.callee_mapped_addr.as_deref(), Some("198.51.100.9:54321"));
// Setter on unknown call is a no-op.
reg.set_caller_mapped_addr("nope", Some("x".into()));
}
#[test]
fn call_registry_clearing_mapped_addr_works() {
let mut reg = CallRegistry::new();
reg.create_call("c1".into(), "alice".into(), "bob".into());
reg.set_caller_mapped_addr("c1", Some("1.2.3.4:5".into()));
reg.set_caller_mapped_addr("c1", None);
assert!(reg.get("c1").unwrap().caller_mapped_addr.is_none());
}
#[test]
fn call_registry_clearing_reflex_addr_works() {
// Passing None to the setter must clear a previously-set value

View File

@@ -87,6 +87,14 @@ pub struct RelayConfig {
/// Unlike [[peers]], no url is needed — the peer connects to us.
#[serde(default)]
pub trusted: Vec<TrustedConfig>,
/// Phase 8: geographic region identifier (e.g., "us-east", "eu-west").
/// Sent to clients in `RegisterPresenceAck.relay_region` so they can
/// build a relay map for automatic selection.
pub region: Option<String>,
/// Phase 8: externally-advertised address for this relay. Used to
/// populate `available_relays` in `RegisterPresenceAck`. If not set,
/// `listen_addr` is used.
pub advertised_addr: Option<SocketAddr>,
/// Debug tap: log packet headers for matching rooms ("*" = all rooms).
/// Activated via --debug-tap <room> or debug_tap = "room" in TOML.
pub debug_tap: Option<String>,
@@ -114,6 +122,8 @@ impl Default for RelayConfig {
peers: Vec::new(),
global_rooms: Vec::new(),
trusted: Vec::new(),
region: None,
advertised_addr: None,
debug_tap: None,
event_log: None,
}

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;
}

View File

@@ -52,6 +52,7 @@ fn alice_offer(call_id: &str) -> SignalMessage {
supported_profiles: vec![],
caller_reflexive_addr: Some(ALICE_ADDR.into()),
caller_local_addrs: Vec::new(),
caller_mapped_addr: None,
caller_build_version: None,
}
}
@@ -133,6 +134,7 @@ fn bob_answer(call_id: &str) -> SignalMessage {
chosen_profile: None,
callee_reflexive_addr: Some(BOB_ADDR.into()),
callee_local_addrs: Vec::new(),
callee_mapped_addr: None,
callee_build_version: None,
}
}
@@ -178,6 +180,7 @@ fn relay_b_handle_local_answer(
relay_addr: RELAY_B_ADDR.into(),
peer_direct_addr: caller_addr,
peer_local_addrs: Vec::new(),
peer_mapped_addr: None,
};
let _ = callee_addr;
(forward, setup_for_bob)
@@ -219,6 +222,7 @@ fn relay_a_handle_forwarded_answer(
relay_addr: RELAY_A_ADDR.into(),
peer_direct_addr: callee_reflexive_addr,
peer_local_addrs: Vec::new(),
peer_mapped_addr: None,
}
}

View File

@@ -82,6 +82,7 @@ fn handle_answer_and_build_setups(
relay_addr: "203.0.113.5:4433".into(),
peer_direct_addr: callee_addr,
peer_local_addrs: Vec::new(),
peer_mapped_addr: None,
};
let setup_for_callee = SignalMessage::CallSetup {
call_id,
@@ -89,6 +90,7 @@ fn handle_answer_and_build_setups(
relay_addr: "203.0.113.5:4433".into(),
peer_direct_addr: caller_addr,
peer_local_addrs: Vec::new(),
peer_mapped_addr: None,
};
(setup_for_caller, setup_for_callee)
}
@@ -105,6 +107,7 @@ fn mk_offer(call_id: &str, caller_reflexive_addr: Option<&str>) -> SignalMessage
supported_profiles: vec![],
caller_reflexive_addr: caller_reflexive_addr.map(String::from),
caller_local_addrs: Vec::new(),
caller_mapped_addr: None,
caller_build_version: None,
}
}
@@ -123,6 +126,7 @@ fn mk_answer(
chosen_profile: None,
callee_reflexive_addr: callee_reflexive_addr.map(String::from),
callee_local_addrs: Vec::new(),
callee_mapped_addr: None,
callee_build_version: None,
}
}

View File

@@ -66,6 +66,8 @@ async fn spawn_mock_relay() -> (SocketAddr, tokio::task::JoinHandle<()>) {
success: true,
error: None,
relay_build: None,
relay_region: None,
available_relays: Vec::new(),
})
.await;
}