From 10df192b013b1772ed739b93f30d7fc0e2f88aa3 Mon Sep 17 00:00:00 2001 From: Filip Strajnar Date: Fri, 6 Nov 2026 21:27:47 +0100 Subject: [PATCH] M9: a share on every interface links to an address others reach A session hosted with `bind` 0.0.0.0 (or ::) kept listening on every interface but put 0.0.0.0 in its edit and view links, URLs, join codes and shown address, which nobody can connect to. They now name this machine's local network address of the listener's family (loopback without one), on the same port. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LvG6DxbDYxuPEA2pJirCLQ --- crates/collab-net/src/code_tests.rs | 43 ++++++++++++++++++++++++-- crates/collab-net/src/link.rs | 48 ++++++++++++++++++++--------- 2 files changed, 74 insertions(+), 17 deletions(-) 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,