diff --git a/crates/collab-net/src/code_tests.rs b/crates/collab-net/src/code_tests.rs index ca7cc51fb..eafb63763 100644 --- a/crates/collab-net/src/code_tests.rs +++ b/crates/collab-net/src/code_tests.rs @@ -3,7 +3,7 @@ //! older than join codes, and the links that name a code. (A `figcraft-server` handing codes out //! and resolving them is tested in its own crate, with the same link.) -use std::net::{Ipv4Addr, TcpListener}; +use std::net::{IpAddr, Ipv4Addr, SocketAddr, TcpListener}; use std::sync::Arc; use std::time::{Duration, Instant}; @@ -18,8 +18,9 @@ use crate::driver::{Driver, Socket}; use crate::guest::GuestConfig; use crate::inbox::Inbox; use crate::invite::{Endpoint, Invite, Lookup}; +use crate::link::reachable; use crate::loopback_tests::{fast_limits, quick}; -use crate::{NetLink, manage}; +use crate::{NetLink, lan_ip, manage}; fn session(name: &str) -> Session { let mut s = Session::new(); @@ -197,6 +198,44 @@ fn a_local_view_share_s_link_and_url_only_let_people_look() { assert_eq!(host.execute("collab.stop", &json!({})).unwrap()["ended"], true); } +/// A share listening on every interface (`bind` 0.0.0.0) names where others reach it, never +/// 0.0.0.0: this machine's local network address (loopback without one) in its codes, links, URL +/// and address, and its view code and edit link both join it. +#[test] +fn a_share_on_every_interface_names_an_address_others_reach() { + let (mut host, mut viewer, mut editor) = (session("Hana"), session("Ada"), session("Bo")); + let shared = host.execute("collab.share", &json!({"via": "local", "bind": "0.0.0.0", "access": "view"})).unwrap(); + let Code::Local(viewing) = code::parse(shared["code"].as_str().unwrap()).unwrap() else { panic!("not a local code") }; + let at = lan_ip().unwrap_or(Ipv4Addr::LOCALHOST.into()); + assert_eq!(IpAddr::from(viewing.ip), at); + let address = format!("{at}:{}", viewing.port); + assert_eq!(shared["address"], address); + let Code::Local(editing) = code::parse(shared["share"]["editCode"].as_str().unwrap()).unwrap() else { panic!("not a local code") }; + assert_eq!((editing.ip, editing.port), (viewing.ip, viewing.port)); + for way in [&shared["link"], &shared["url"], &shared["share"]["viewLink"], &shared["share"]["editLink"], &shared["sessions"][0]["link"]] { + let way = way.as_str().unwrap_or_default(); + assert!(way.contains(&address) && !way.contains("0.0.0.0"), "{way}"); + } + viewer.execute("collab.join", &json!({"code": shared["code"]})).unwrap(); + editor.execute("collab.join", &json!({"link": shared["share"]["editLink"]})).unwrap(); + pump(&mut [&mut host, &mut viewer, &mut editor], "the joins", |s| live(s[1]) && live(s[2]) && s[0].live().peers.len() == 2); + assert_eq!((&viewer.live_summary()["access"], &editor.live_summary()["access"]), (&json!("view"), &json!("edit"))); + assert_eq!(host.execute("collab.stop", &json!({})).unwrap()["ended"], true); +} + +/// A listener on one address is reached at it; one on every interface at an address of its own +/// family, on its port. +#[test] +fn a_listener_on_every_interface_is_reached_at_an_address_of_its_family() { + let one: SocketAddr = "192.168.1.20:47900".parse().unwrap(); + assert_eq!(reachable(one), one); + for any in ["0.0.0.0:47900", "[::]:47901"] { + let any: SocketAddr = any.parse().unwrap(); + let at = reachable(any); + assert!(!at.ip().is_unspecified() && at.is_ipv4() == any.is_ipv4() && at.port() == any.port(), "{any}: {at}"); + } +} + /// An invite naming only `server` (what a join code is asked at). fn code_at(server: &Endpoint) -> Invite { Invite { server: server.clone(), file: String::new(), token: String::new() } diff --git a/crates/collab-net/src/link.rs b/crates/collab-net/src/link.rs index 35bbe7188..66d90bc22 100644 --- a/crates/collab-net/src/link.rs +++ b/crates/collab-net/src/link.rs @@ -1,6 +1,6 @@ //! [`NetLink`]: the engine's [`LiveLink`] over the network. -use std::net::{IpAddr, Ipv4Addr, SocketAddr, UdpSocket}; +use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr, UdpSocket}; use std::sync::Arc; use std::sync::atomic::{AtomicBool, Ordering}; use std::time::Duration; @@ -120,18 +120,34 @@ fn view_secret(short: bool, edit: &str) -> Result { /// This machine's address on the local network: the interface the default route leaves by (no /// packet is sent). pub fn lan_ip() -> Option { - let sock = UdpSocket::bind(SocketAddr::from((Ipv4Addr::UNSPECIFIED, 0))).ok()?; // Documentation and private ranges: only the route matters. - for probe in ["192.0.2.1:9", "10.255.255.255:9"] { - if sock.connect(probe).is_ok() - && let Ok(a) = sock.local_addr() - && !a.ip().is_loopback() - && !a.ip().is_unspecified() - { - return Some(a.ip()); - } - } - None + route_ip(Ipv4Addr::UNSPECIFIED.into(), &["192.0.2.1:9", "10.255.255.255:9"]) +} + +/// The address of `any`'s family that the route to the first of `probes` that has one leaves by +/// (no packet is sent): never a loopback, unspecified or IPv6 link-local one, which others can't +/// reach this machine at. +fn route_ip(any: IpAddr, probes: &[&str]) -> Option { + let sock = UdpSocket::bind(SocketAddr::new(any, 0)).ok()?; + probes.iter().find_map(|probe| { + sock.connect(probe).ok()?; + let ip = sock.local_addr().ok()?.ip(); + let link_local = matches!(ip, IpAddr::V6(v6) if v6.is_unicast_link_local()); + (!ip.is_loopback() && !ip.is_unspecified() && !link_local).then_some(ip) + }) +} + +/// Where others reach a session listening on `addr`: `addr` itself, or for a listener on every +/// interface (`0.0.0.0`, `::`) this machine's local network address of the same family (its +/// loopback without one), on the same port. Links name it; the listener keeps every interface. +pub(crate) fn reachable(addr: SocketAddr) -> SocketAddr { + let ip = match addr.ip() { + ip if !ip.is_unspecified() => ip, + IpAddr::V4(_) => lan_ip().unwrap_or(Ipv4Addr::LOCALHOST.into()), + // Documentation and unique local ranges, as for IPv4. + IpAddr::V6(_) => route_ip(Ipv6Addr::UNSPECIFIED.into(), &["[2001:db8::1]:9", "[fd00::1]:9"]).unwrap_or(Ipv6Addr::LOCALHOST.into()), + }; + SocketAddr::new(ip, addr.port()) } impl LiveLink for NetLink { @@ -165,8 +181,10 @@ impl LiveLink for NetLink { core: figcraft_collab::Limits::default(), }; let host = Host::start(cfg, inbox.clone()).map_err(|e| format!("can't listen on {ip}: {e}"))?; - let view = view_token.map(|v| Invite::new(host.addr().to_string(), file.clone(), v)).map(|v| WayIn { link: v.link(), url: v.url() }); - let invite = Invite::new(host.addr().to_string(), file, token); + // Listening on every interface (`0.0.0.0`, `::`), the links name an address others reach. + let at = reachable(host.addr()).to_string(); + let view = view_token.map(|v| Invite::new(at.clone(), file.clone(), v)).map(|v| WayIn { link: v.link(), url: v.url() }); + let invite = Invite::new(at.clone(), file, token); let info = SessionInfo { role: Role::Host, me: host.me().client_id, @@ -176,7 +194,7 @@ impl LiveLink for NetLink { pages, link: invite.link(), url: invite.url(), - address: host.addr().to_string(), + address: at, network: !ip.is_loopback(), access: Access::Edit, view,