diff --git a/cli/Cargo.lock b/cli/Cargo.lock index 374ac0ce..e6a2204e 100644 --- a/cli/Cargo.lock +++ b/cli/Cargo.lock @@ -1944,7 +1944,6 @@ dependencies = [ "dirs", "futures-util", "gethostname", - "if-addrs", "localsend", "mime_guess", "pem 4.0.0", diff --git a/cli/Cargo.toml b/cli/Cargo.toml index 465b2bfb..2cec36da 100644 --- a/cli/Cargo.toml +++ b/cli/Cargo.toml @@ -16,7 +16,6 @@ crossterm = "0.29" dirs = "6.0" futures-util = "0.3" gethostname = "1.0" -if-addrs = "0.15" mime_guess = "2.0" pem = "4.0" ratatui = "0.30" diff --git a/cli/src/app/mod.rs b/cli/src/app/mod.rs index 813efbf8..588f15bf 100644 --- a/cli/src/app/mod.rs +++ b/cli/src/app/mod.rs @@ -172,7 +172,9 @@ pub async fn run(args: Args) -> anyhow::Result<()> { }; match app.preselected.is_empty() { - true => app.ui.log_plain(&crate::banner::render(&app.storage)), + true => app + .ui + .log_plain(&crate::banner::render(&app.storage, &app.server)), false => app.open_device_list(), } diff --git a/cli/src/banner.rs b/cli/src/banner.rs index f8e2f921..2a03366d 100644 --- a/cli/src/banner.rs +++ b/cli/src/banner.rs @@ -1,6 +1,7 @@ use crate::storage::Repository; -use crate::util; use crossterm::style::Stylize; +use localsend::http::server::ServerHandle; +use std::net::SocketAddr; #[rustfmt::skip] const LOGO: [&str; 4] = [ @@ -10,7 +11,7 @@ const LOGO: [&str; 4] = [ " ▀▄ ▄ ▄▀ ", ]; -pub fn render(storage: &Repository) -> String { +pub fn render(storage: &Repository, server: &ServerHandle) -> String { let logo = LOGO .iter() .enumerate() @@ -25,13 +26,9 @@ pub fn render(storage: &Repository) -> String { .collect::>() .join("\n"); - let listening = match util::local_ipv4_addresses() { - addresses if addresses.is_empty() => " - (no network interface found)".to_string(), - addresses => addresses - .iter() - .map(|address| format!(" - https://{address}:{}", storage.identity.port)) - .collect::>() - .join("\n"), + let listening = match listening_lines(&server.local_addresses()) { + lines if lines.is_empty() => " - (no network interface found)".to_string(), + lines => lines.join("\n"), }; format!( @@ -47,3 +44,82 @@ pub fn render(storage: &Repository) -> String { "Listening on:".green(), ) } + +/// One line per IPv4 address, but only one per IPv6 scope (global, +/// unique-local) with a `(+N more)` suffix for the rest: interfaces usually carry +/// several equivalent IPv6 addresses (e.g. temporary privacy addresses), +/// which would flood the banner. +/// +/// IPv6 addresses render bracketed ("[::1]:53317"), as a URL needs them. +/// Relies on the addresses being sorted, so that each scope is contiguous. +fn listening_lines(addresses: &[SocketAddr]) -> Vec { + let mut lines: Vec<(String, usize)> = Vec::new(); + let mut current_scope: Option = None; + for address in addresses { + match address { + SocketAddr::V4(_) => lines.push((format!(" - https://{address}"), 0)), + SocketAddr::V6(v6) => { + let scope = v6.ip().is_unique_local(); + match current_scope == Some(scope) { + true => lines.last_mut().unwrap().1 += 1, + false => { + current_scope = Some(scope); + lines.push((format!(" - https://{address}"), 0)); + } + } + } + } + } + + lines + .into_iter() + .map(|(line, hidden)| match hidden { + 0 => line, + hidden => format!("{line} (+{hidden} more)"), + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn address(s: &str) -> SocketAddr { + SocketAddr::new(s.parse().unwrap(), 53317) + } + + #[test] + fn test_every_ipv4_gets_a_line() { + let lines = listening_lines(&[address("10.0.0.1"), address("192.168.0.1")]); + assert_eq!( + lines, + [" - https://10.0.0.1:53317", " - https://192.168.0.1:53317"] + ); + } + + #[test] + fn test_ipv6_collapses_per_scope() { + let lines = listening_lines(&[ + address("192.168.0.1"), + address("2a02::1"), + address("2a02::2"), + address("2a02::3"), + address("fd44::1"), + address("fd44::2"), + ]); + assert_eq!( + lines, + [ + " - https://192.168.0.1:53317", + " - https://[2a02::1]:53317 (+2 more)", + " - https://[fd44::1]:53317 (+1 more)", + ] + ); + } + + #[test] + fn test_single_ipv6_has_no_suffix() { + let lines = listening_lines(&[address("2a02::1")]); + assert_eq!(lines, [" - https://[2a02::1]:53317"]); + } +} diff --git a/cli/src/util.rs b/cli/src/util.rs index c8565577..089cbeb5 100644 --- a/cli/src/util.rs +++ b/cli/src/util.rs @@ -1,6 +1,5 @@ use crossterm::terminal::{Clear, ClearType}; use crossterm::{cursor, execute}; -use std::net::Ipv4Addr; use std::path::{Path, PathBuf}; use std::time::{Duration, Instant}; @@ -112,25 +111,6 @@ pub fn unique_path(dir: &Path, file_name: &str) -> PathBuf { .unwrap() } -/// The IPv4 addresses of all non-loopback interfaces, i.e. the addresses this -/// device can be reached at. Empty when the interfaces cannot be enumerated. -pub fn local_ipv4_addresses() -> Vec { - let Ok(interfaces) = if_addrs::get_if_addrs() else { - return Vec::new(); - }; - let mut addresses: Vec = interfaces - .into_iter() - .filter(|interface| !interface.is_loopback()) - .filter_map(|interface| match interface.ip() { - std::net::IpAddr::V4(address) => Some(address), - std::net::IpAddr::V6(_) => None, - }) - .collect(); - addresses.sort(); - addresses.dedup(); - addresses -} - /// Estimates the transfer speed from cumulative byte counts, smoothed with an /// exponential moving average. pub struct SpeedMeter { diff --git a/packages/core/Cargo.toml b/packages/core/Cargo.toml index b2169533..fbdbde40 100644 --- a/packages/core/Cargo.toml +++ b/packages/core/Cargo.toml @@ -44,7 +44,7 @@ x509-parser = { version = "0.18.1", features = ["verify"], optional = true } default = [] crypto = ["ed25519-dalek", "rcgen", "rsa", "sha2", "tokio-util"] discovery = ["http", "multicast"] -http = ["crypto", "form_urlencoded", "http-body-util", "hyper", "hyper-util", "pem", "percent-encoding", "reqwest", "rustls", "socket2", "tokio-rustls", "tokio-util", "x509-parser"] +http = ["crypto", "form_urlencoded", "http-body-util", "hyper", "hyper-util", "if-addrs", "pem", "percent-encoding", "reqwest", "rustls", "socket2", "tokio-rustls", "tokio-util", "x509-parser"] multicast = ["if-addrs", "socket2", "tokio-util"] webrtc-signaling = ["tokio-tungstenite"] webrtc = ["crypto", "flate2", "dep:webrtc", "webrtc-signaling", "x509-parser"] diff --git a/packages/core/src/http/server/mod.rs b/packages/core/src/http/server/mod.rs index 65c63a3d..cb4292a8 100644 --- a/packages/core/src/http/server/mod.rs +++ b/packages/core/src/http/server/mod.rs @@ -118,6 +118,12 @@ impl AppState { pub struct ServerHandle { v2: Option>, + /// The port the listeners are bound to. + port: u16, + + /// Whether the IPv6 wildcard listener could be bound. + ipv6_bound: bool, + /// The task running the accept loops. Completes after a stop has been /// requested, the listeners have been dropped and all connections have /// been closed. @@ -125,6 +131,35 @@ pub struct ServerHandle { } impl ServerHandle { + /// The socket addresses this server can be reached at: every address of + /// the non-loopback interfaces, restricted to the address families that + /// are actually bound. The listeners themselves only know the wildcard + /// addresses, so the concrete addresses come from interface enumeration. + /// + /// Link-local IPv6 addresses are skipped: peers can only use them together + /// with their own scope, which this device cannot know. + /// + /// Empty when the interfaces cannot be enumerated. + pub fn local_addresses(&self) -> Vec { + let Ok(interfaces) = if_addrs::get_if_addrs() else { + return Vec::new(); + }; + let mut addresses: Vec = interfaces + .into_iter() + .filter(|interface| !interface.is_loopback()) + .filter_map(|interface| match interface.ip() { + IpAddr::V4(address) => Some(SocketAddr::new(address.into(), self.port)), + IpAddr::V6(address) if self.ipv6_bound && !address.is_unicast_link_local() => { + Some(SocketAddr::new(address.into(), self.port)) + } + IpAddr::V6(_) => None, + }) + .collect(); + addresses.sort(); + addresses.dedup(); + addresses + } + /// Waits until the server task has terminated, the listeners are closed /// and all connections have been dropped, so that the port can be bound again. /// Must be called after requesting a stop via the stop channel. @@ -169,11 +204,13 @@ pub async fn start_with_port( stop_rx: oneshot::Receiver<()>, ) -> anyhow::Result { let ipv4_socket_addr = SocketAddr::new(Ipv4Addr::UNSPECIFIED.into(), port); - let ipv6_socket_addr = SocketAddr::new(Ipv6Addr::UNSPECIFIED.into(), port); let info = Arc::new(Mutex::new(info)); let state = AppState::new(info.clone(), internal_config, v2_config, web_send_config); let ipv4_listener = tokio::net::TcpListener::bind(ipv4_socket_addr).await?; + // With port 0, the IPv6 listener must reuse the port the IPv4 listener got. + let bound_port = ipv4_listener.local_addr()?.port(); + let ipv6_socket_addr = SocketAddr::new(Ipv6Addr::UNSPECIFIED.into(), bound_port); let ipv6_listener = match bind_ipv6_only(ipv6_socket_addr) { Ok(listener) => Some(listener), Err(err) => { @@ -181,6 +218,7 @@ pub async fn start_with_port( None } }; + let ipv6_bound = ipv6_listener.is_some(); let cancel = CancellationToken::new(); let connections = TaskTracker::new(); @@ -215,6 +253,8 @@ pub async fn start_with_port( Ok(ServerHandle { v2: state.v2.clone(), + port: bound_port, + ipv6_bound, task: Mutex::new(Some(task)), }) }