Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .mise/config.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
10 changes: 6 additions & 4 deletions .mise/config.zig.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand All @@ -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"

Expand Down
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 4 additions & 0 deletions config/ast-grep/rules/doc-summary-ends-with-period.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions services/ws-server/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
19 changes: 19 additions & 0 deletions services/ws-server/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>,
}

/// Application config shared across ws-server services.
#[derive(Clone, Debug, DefaultFromSerde, Deserialize)]
#[non_exhaustive]
Expand All @@ -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,
Expand Down
1 change: 1 addition & 0 deletions services/ws-server/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
25 changes: 18 additions & 7 deletions services/ws-server/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 _};

Expand Down Expand Up @@ -50,7 +50,18 @@ async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
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;
Expand All @@ -66,17 +77,17 @@ async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
};
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);
Expand Down
93 changes: 93 additions & 0 deletions services/ws-server/src/net.rs
Original file line number Diff line number Diff line change
@@ -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<String> = 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<Vec<(String, IpAddr)>, 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)
}
96 changes: 96 additions & 0 deletions services/ws-server/tests/net.rs
Original file line number Diff line number Diff line change
@@ -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();
}
Loading