diff --git a/.mise/config.toml b/.mise/config.toml index b45bf87..b0bf7ae 100644 --- a/.mise/config.toml +++ b/.mise/config.toml @@ -1216,6 +1216,14 @@ description = "Run the WebSocket server without OTLP/o2 observability (OpenTelem # Use this when you don't want the o2/OpenObserve stack (`mise run o2`) running alongside the server. run = "watchexec --restart --clear --exts rs,toml -i '**/tests/**' --shell=none -- cargo run -p et-ws-server" +[tasks.ws-server-offline-demo] +description = "Run the WebSocket server for a hotspot demo: no OTLP, no watchexec, cargo --offline" +# Demo the server from a machine that shares its own Wi-Fi hotspot and has no internet uplink. +# Sets no OTLP_* env, runs cargo directly (no watchexec restart-on-change), and `--offline` makes cargo +# resolve and build from the crates already in the local cache instead of touching the network. +env = { NET_LOG_INTERFACE = "bridge100" } +run = "cargo run --offline -p et-ws-server" + [tasks.ws-wasi-runner] description = "Run the WebSocket WASI runner (set RUNNER_MODULE, e.g. wasi-graphics-info; RUNNER_FEATURES=cuda on CUDA)" dir = "services/ws-wasi-runner" diff --git a/.mise/config.zig.toml b/.mise/config.zig.toml index 447e2b2..6a09a78 100644 --- a/.mise/config.zig.toml +++ b/.mise/config.zig.toml @@ -82,9 +82,10 @@ git ls-files '*.c' '*.cpp' '*.h' ':!services/ws-web-runner/mingw-shim' ':!servic llvm_ct="$(mise where 'github:mstorsjo/llvm-mingw')/bin/clang-tidy" git ls-files 'services/ws-web-runner/mingw-shim/*.c' | xargs -r -I{} "$llvm_ct" --config-file=config/clang-tidy.yaml {} -- --target=x86_64-w64-mingw32 -std=c11 -wasm_flags=({{ vars.clang_resource_arg }} --target=wasm32-unknown-unknown -fwasm-exceptions -mexception-handling) +# set -- (POSIX positional params), not a bash array: busybox ash on the Windows lane has no arrays. +set -- {{ vars.clang_resource_arg }} --target=wasm32-unknown-unknown -fwasm-exceptions -mexception-handling -fno-rtti git ls-files 'services/ws-modules/zig-*/src/*.c' 'services/ws-modules/zig-*/src/*.cpp' | - xargs -r -I{} clang-tidy --config-file=config/clang-tidy.yaml {} -- "${wasm_flags[@]}" -fno-rtti + xargs -r -I{} clang-tidy --config-file=config/clang-tidy.yaml {} -- "$@" """ shell = "bash -euo pipefail -c" @@ -99,9 +100,10 @@ git ls-files '*.c' '*.cpp' '*.h' ':!services/ws-web-runner/mingw-shim' ':!servic llvm_ct="$(mise where 'github:mstorsjo/llvm-mingw')/bin/clang-tidy" git ls-files 'services/ws-web-runner/mingw-shim/*.c' | xargs -r -I{} "$llvm_ct" --fix --config-file=config/clang-tidy.yaml {} -- --target=x86_64-w64-mingw32 -std=c11 -wasm_flags=({{ vars.clang_resource_arg }} --target=wasm32-unknown-unknown -fwasm-exceptions -mexception-handling) +# set -- (POSIX positional params), not a bash array: busybox ash on the Windows lane has no arrays. +set -- {{ vars.clang_resource_arg }} --target=wasm32-unknown-unknown -fwasm-exceptions -mexception-handling -fno-rtti git ls-files 'services/ws-modules/zig-*/src/*.c' 'services/ws-modules/zig-*/src/*.cpp' | - xargs -r -I{} clang-tidy --fix --config-file=config/clang-tidy.yaml {} -- "${wasm_flags[@]}" -fno-rtti + xargs -r -I{} clang-tidy --fix --config-file=config/clang-tidy.yaml {} -- "$@" """ shell = "bash -euo pipefail -c" diff --git a/Cargo.lock b/Cargo.lock index 1b09b9c..45072f4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4652,6 +4652,7 @@ dependencies = [ "serde_json", "serde_yaml", "tempfile", + "thiserror 2.0.18", "tokio", "tracing", "tracing-actix-web", diff --git a/config/ast-grep/rules/doc-summary-ends-with-period.yaml b/config/ast-grep/rules/doc-summary-ends-with-period.yaml index 35756c0..768f28b 100644 --- a/config/ast-grep/rules/doc-summary-ends-with-period.yaml +++ b/config/ast-grep/rules/doc-summary-ends-with-period.yaml @@ -32,7 +32,11 @@ files: - services/websockify/tests/relay.rs - services/ws-modules/except1/src/lib.rs - services/ws-pyo3-runner/tests/modules.rs + - services/ws-server/src/config.rs - services/ws-server/src/lib.rs + - services/ws-server/src/main.rs + - services/ws-server/src/net.rs + - services/ws-server/tests/net.rs - services/ws-wasi-runner/tests/modules.rs - services/ws-wasi-runner/tests/otel_propagation.rs - services/ws-wasi-runner/tests/vector_otlp_relay.rs diff --git a/services/ws-server/Cargo.toml b/services/ws-server/Cargo.toml index 0a81173..559cf42 100644 --- a/services/ws-server/Cargo.toml +++ b/services/ws-server/Cargo.toml @@ -44,6 +44,7 @@ serde-inline-default.workspace = true serde_default.workspace = true serde_json.workspace = true serde_yaml.workspace = true +thiserror.workspace = true tokio = { workspace = true, features = ["full"] } tracing.workspace = true tracing-actix-web.workspace = true diff --git a/services/ws-server/src/config.rs b/services/ws-server/src/config.rs index 5994912..2260cd8 100644 --- a/services/ws-server/src/config.rs +++ b/services/ws-server/src/config.rs @@ -19,6 +19,21 @@ pub struct TlsConfig { pub key_file: PathBuf, } +/// Network-address selection for the startup banner and QR code. +#[derive(Clone, Debug, DefaultFromSerde, Deserialize)] +#[non_exhaustive] +pub struct NetConfig { + /// Name of the interface whose address the startup banner and QR code should advertise first. + /// + /// Set it to `bridge100` when the machine shares its internet connection as a Wi-Fi hotspot: macOS + /// Internet Sharing puts the hotspot's gateway address there, and it is the only address the devices + /// scanning the QR code can reach. Naming an interface makes it a hard requirement -- startup fails + /// if it is absent or holds no usable IPv4 address. When unset, ranking is automatic: `en*` NICs + /// first, then `bridge*`, then everything else. + #[serde(default)] + pub log_interface: Option, +} + /// Application config shared across ws-server services. #[derive(Clone, Debug, DefaultFromSerde, Deserialize)] #[non_exhaustive] @@ -29,6 +44,10 @@ pub struct Config { /// Modules config. #[serde(default)] pub modules: ModulesConfig, + /// Startup banner / QR code address selection. + /// `serde-env` maps the inner fields as `NET_*`, e.g. `NET_PREFER_HOTSPOT_IP`. + #[serde(default)] + pub net: NetConfig, /// Storage config. #[serde(default)] pub storage: StorageConfig, diff --git a/services/ws-server/src/lib.rs b/services/ws-server/src/lib.rs index eca279a..816adcb 100644 --- a/services/ws-server/src/lib.rs +++ b/services/ws-server/src/lib.rs @@ -4,6 +4,7 @@ use actix_web::{HttpResponse, web}; pub use et_ws_service::{AgentSession, WsAgentRegistry}; pub mod config; +pub mod net; pub mod routes; pub mod tls; diff --git a/services/ws-server/src/main.rs b/services/ws-server/src/main.rs index 8c8c5dc..c6acd35 100644 --- a/services/ws-server/src/main.rs +++ b/services/ws-server/src/main.rs @@ -15,7 +15,7 @@ use et_ws_server::config::Config; use et_ws_server::configure_app; use et_ws_server::tls; use et_ws_service::load_registry; -use tracing::{error, info}; +use tracing::{error, info, warn}; use tracing_actix_web::TracingLogger; use tracing_subscriber::{layer::SubscriberExt as _, util::SubscriberInitExt as _}; @@ -50,7 +50,18 @@ async fn main() -> Result<(), Box> { None }; - let network_ip = local_ip_address::local_ip().map_or_else(|_unused| "127.0.0.1".to_string(), |ip| ip.to_string()); + let log_interface = env.net.log_interface.as_deref(); + info!( + "Selecting advertised IP with log_interface={}", + log_interface.unwrap_or("(unset)") + ); + let ip_candidates = et_ws_server::net::candidate_ipv4s(log_interface).inspect_err(|e| error!("{e}"))?; + let network_ip = ip_candidates + .first() + .map_or_else(|| "127.0.0.1".to_string(), |(_, addr)| addr.to_string()); + if ip_candidates.is_empty() { + warn!("No usable IPv4 candidate found; advertising 127.0.0.1 (see the interface lines above)"); + } let cert_filename = &env.tls.cert_file; let key_filename = &env.tls.key_file; @@ -66,17 +77,17 @@ async fn main() -> Result<(), Box> { }; let rustls_config = tls::build_tls_server_config(cert_der, key_der); - let https_url = format!( - "https://{}:{}", - network_ip, - edge_toolkit::ports::Services::SecureWebSocketServer.port() - ); + let https_port = edge_toolkit::ports::Services::SecureWebSocketServer.port(); + let https_url = format!("https://{network_ip}:{https_port}"); info!( "Starting WebSocket server on http://{}:{}", network_ip, edge_toolkit::ports::Services::InsecureWebSocketServer.port() ); info!("Starting WebSocket server on {}", https_url); + for (interface, addr) in &ip_candidates { + info!("Reachable via {} at https://{}:{}", interface, addr, https_port); + } info!("Scan this QR code to open the browser interface:"); if let Err(e) = qr2term::print_qr(&https_url) { error!("Failed to generate QR code: {}", e); diff --git a/services/ws-server/src/net.rs b/services/ws-server/src/net.rs new file mode 100644 index 0000000..6dcf40a --- /dev/null +++ b/services/ws-server/src/net.rs @@ -0,0 +1,93 @@ +//! Discovers which of the host's IPv4 addresses the startup banner URLs and QR code should advertise. + +use std::net::IpAddr; + +use tracing::{info, warn}; + +/// Errors from startup network-interface selection. +#[derive(Debug, thiserror::Error)] +#[non_exhaustive] +pub enum NetError { + /// The OS interface enumeration failed while an explicit interface preference was configured. + #[error("failed to enumerate network interfaces while NET_LOG_INTERFACE is set: {0}")] + Enumeration(#[from] local_ip_address::Error), + /// The explicitly configured interface is absent or holds no usable IPv4 address. + #[error("configured interface {name} is not up with a usable IPv4 address; usable candidates: {usable}")] + InterfaceUnusable { name: String, usable: String }, +} + +/// Filters an interface list to reachable IPv4 addresses and orders them by likely client reachability. +/// +/// Keeps only non-loopback, non-link-local, non-unspecified IPv4 addresses, then sorts them by interface +/// class, preserving enumeration order within each class: the `log_interface` name (if given) first, +/// then `en*` (Wi-Fi / Ethernet), then `bridge*`, then everything else (VPN tunnels and the like). The +/// `bridge*` class still outranks the tunnels because when macOS Internet Sharing is active and the Wi-Fi +/// NIC holds no address, its `bridge100` gateway is the best guess even without an explicit preference. +#[must_use] +pub fn rank_candidates(ifas: Vec<(String, IpAddr)>, log_interface: Option<&str>) -> Vec<(String, IpAddr)> { + let mut candidates: Vec<(String, IpAddr)> = ifas + .into_iter() + .filter(|(_, addr)| match addr { + IpAddr::V4(ipv4) => !ipv4.is_loopback() && !ipv4.is_link_local() && !ipv4.is_unspecified(), + IpAddr::V6(_) => false, + }) + .collect(); + candidates.sort_by_key(|(name, _)| { + if log_interface == Some(name.as_str()) { + 0_u8 + } else if name.starts_with("en") { + 1 + } else if name.starts_with("bridge") { + 2 + } else { + 3 + } + }); + candidates +} + +fn format_ifas(ifas: &[(String, IpAddr)]) -> String { + if ifas.is_empty() { + return "(none)".to_string(); + } + let entries: Vec = ifas.iter().map(|(name, addr)| format!("{name}={addr}")).collect(); + entries.join(", ") +} + +/// Enumerates the host's network interfaces and returns the ranked IPv4 candidates for the startup banner. +/// +/// Logs the complete raw enumeration (every interface and address, before any filtering) so a machine +/// with no usable candidate can be diagnosed from the startup log alone -- the offline-hotspot demo is +/// exactly the situation where no second machine is available to poke around from. An explicitly +/// configured `log_interface` is a hard requirement: if it is absent or holds no usable IPv4 address, +/// this returns an error (aborting startup) rather than silently advertising some other address. +pub fn candidate_ipv4s(log_interface: Option<&str>) -> Result, NetError> { + let ifas = match local_ip_address::list_afinet_netifas() { + Ok(ifas) => ifas, + Err(e) => { + if log_interface.is_some() { + return Err(NetError::Enumeration(e)); + } + warn!("Failed to enumerate network interfaces: {e}"); + return Ok(Vec::new()); + } + }; + info!("Enumerated network interfaces (pre-filter): {}", format_ifas(&ifas)); + let ranked = rank_candidates(ifas, log_interface); + match log_interface { + Some(preferred) => { + if !ranked.iter().any(|(name, _)| name == preferred) { + return Err(NetError::InterfaceUnusable { + name: preferred.to_string(), + usable: format_ifas(&ranked), + }); + } + } + None => { + if ranked.is_empty() { + warn!("Every enumerated address was filtered out (IPv6, loopback, link-local, or unspecified)"); + } + } + } + Ok(ranked) +} diff --git a/services/ws-server/tests/net.rs b/services/ws-server/tests/net.rs new file mode 100644 index 0000000..4d01bb9 --- /dev/null +++ b/services/ws-server/tests/net.rs @@ -0,0 +1,96 @@ +//! Covers the startup-banner IPv4 selection: filtering, class ranking, and the preferred-interface rules. +#![cfg(test)] + +use std::net::{IpAddr, Ipv4Addr, Ipv6Addr}; + +use et_ws_server::net::{NetError, candidate_ipv4s, rank_candidates}; + +fn ifa(name: &str, octets: [u8; 4]) -> (String, IpAddr) { + (name.to_string(), IpAddr::V4(Ipv4Addr::from(octets))) +} + +fn names(ranked: &[(String, IpAddr)]) -> Vec<&str> { + ranked.iter().map(|(name, _)| name.as_str()).collect() +} + +#[test] +fn filters_out_unusable_addresses() { + let ranked = rank_candidates( + vec![ + ifa("lo0", [127, 0, 0, 1]), + ifa("en0", [169, 254, 13, 37]), + ifa("en1", [0, 0, 0, 0]), + ("utun0".to_string(), IpAddr::V6(Ipv6Addr::LOCALHOST)), + ], + None, + ); + assert!( + ranked.is_empty(), + "loopback, link-local, unspecified, and IPv6 must all be dropped" + ); +} + +#[test] +fn log_interface_ranks_first() { + // Internet Sharing with a wired uplink: the phone scanning the QR code sits on the bridge100 + // network, so the explicitly preferred hotspot gateway must beat the uplink NIC's LAN address. + let ranked = rank_candidates( + vec![ifa("en8", [10, 128, 116, 231]), ifa("bridge100", [192, 168, 2, 1])], + Some("bridge100"), + ); + assert_eq!(names(&ranked), ["bridge100", "en8"]); + assert_eq!(ranked[0].1, IpAddr::V4(Ipv4Addr::new(192, 168, 2, 1))); +} + +#[test] +fn nic_outranks_bridge_and_tunnels_without_a_preference() { + // Normal LAN mode on a host running a VM: vmnet parks a bridge100 that must not hijack the QR code. + let ranked = rank_candidates( + vec![ + ifa("utun4", [10, 8, 0, 2]), + ifa("bridge100", [192, 168, 64, 1]), + ifa("en0", [10, 130, 108, 148]), + ], + None, + ); + assert_eq!(names(&ranked), ["en0", "bridge100", "utun4"]); +} + +#[test] +fn bridge_outranks_tunnels_without_a_preference() { + // Hotspot mode with no uplink NIC address: the bridge is the best guess even with nothing configured. + let ranked = rank_candidates( + vec![ifa("utun4", [10, 8, 0, 2]), ifa("bridge100", [192, 168, 2, 1])], + None, + ); + assert_eq!(names(&ranked), ["bridge100", "utun4"]); +} + +#[test] +fn enumeration_order_is_kept_within_a_class() { + let ranked = rank_candidates( + vec![ifa("en5", [192, 168, 1, 20]), ifa("en0", [10, 130, 108, 148])], + None, + ); + assert_eq!( + names(&ranked), + ["en5", "en0"], + "sort must be stable: en5 enumerated first stays first" + ); +} + +#[test] +fn missing_log_interface_is_a_startup_error() { + let err = candidate_ipv4s(Some("no-such-interface0")).unwrap_err(); + assert!(matches!(err, NetError::InterfaceUnusable { .. })); + let message = err.to_string(); + assert!( + message.contains("no-such-interface0"), + "error must name the missing interface: {message}" + ); +} + +#[test] +fn no_preference_never_errors() { + let _ranked = candidate_ipv4s(None).unwrap(); +}