From c986f2a9a4ff9041661558c905edafb9adea708b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Damian=20K=C4=99ska?= <372403+keskad@users.noreply.github.com> Date: Wed, 12 Aug 2026 07:54:03 +0200 Subject: [PATCH] Wire the programming runtime and add fake Soft-AP mode. Complete scan/probe/program over nl80211 with a tokio worker, and add wp-fake plus --interface fake / fake subcommand for hardware-free testing. Co-authored-by: Cursor --- Cargo.lock | 16 + Cargo.toml | 1 + Makefile | 12 +- README.md | 31 +- crates/wireless-programmer/Cargo.toml | 5 + crates/wireless-programmer/src/cli/client.rs | 1 + crates/wireless-programmer/src/cli/daemon.rs | 165 ++++- crates/wireless-programmer/src/cli/fake.rs | 70 ++ crates/wireless-programmer/src/cli/mod.rs | 13 + crates/wireless-programmer/src/config.rs | 195 +++++- crates/wireless-programmer/src/drivers.rs | 73 +- crates/wireless-programmer/src/ipc.rs | 447 +++++++++--- crates/wireless-programmer/src/jobs.rs | 49 +- crates/wireless-programmer/src/lib.rs | 12 + crates/wireless-programmer/src/main.rs | 32 +- crates/wireless-programmer/src/runtime.rs | 656 ++++++++++++++++++ .../tests/fake_mode_test.rs | 172 +++++ crates/wp-core/src/driver.rs | 2 +- crates/wp-core/src/request.rs | 6 +- crates/wp-core/src/transport.rs | 4 +- crates/wp-drivers/src/wifred/mod.rs | 2 +- crates/wp-fake/Cargo.toml | 29 + crates/wp-fake/src/composite.rs | 47 ++ crates/wp-fake/src/device.rs | 70 ++ crates/wp-fake/src/lib.rs | 17 + crates/wp-fake/src/longfred.rs | 200 ++++++ crates/wp-fake/src/radio.rs | 85 +++ crates/wp-fake/src/server.rs | 156 +++++ crates/wp-fake/src/wifred.rs | 456 ++++++++++++ crates/wp-link/src/lib.rs | 4 +- crates/wp-link/src/radio.rs | 351 +++++++--- docs/api.md | 37 +- docs/cli.md | 47 +- docs/go-client.md | 13 +- 34 files changed, 3148 insertions(+), 328 deletions(-) create mode 100644 crates/wireless-programmer/src/cli/fake.rs create mode 100644 crates/wireless-programmer/src/lib.rs create mode 100644 crates/wireless-programmer/src/runtime.rs create mode 100644 crates/wireless-programmer/tests/fake_mode_test.rs create mode 100644 crates/wp-fake/Cargo.toml create mode 100644 crates/wp-fake/src/composite.rs create mode 100644 crates/wp-fake/src/device.rs create mode 100644 crates/wp-fake/src/lib.rs create mode 100644 crates/wp-fake/src/longfred.rs create mode 100644 crates/wp-fake/src/radio.rs create mode 100644 crates/wp-fake/src/server.rs create mode 100644 crates/wp-fake/src/wifred.rs diff --git a/Cargo.lock b/Cargo.lock index dcf9d35..b32d7b3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1048,6 +1048,7 @@ dependencies = [ "wp-client", "wp-core", "wp-drivers", + "wp-fake", "wp-link", "wp-proto", ] @@ -1105,6 +1106,21 @@ dependencies = [ "wp-link", ] +[[package]] +name = "wp-fake" +version = "0.1.0" +dependencies = [ + "log", + "parking_lot", + "quick-xml", + "serde", + "serde_json", + "tokio", + "wp-core", + "wp-drivers", + "wp-link", +] + [[package]] name = "wp-link" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 05c3c2f..5280ae3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,6 +6,7 @@ members = [ "crates/wp-link", "crates/wp-drivers", "crates/wp-client", + "crates/wp-fake", "crates/wireless-programmer", ] diff --git a/Makefile b/Makefile index 08e296d..be01feb 100644 --- a/Makefile +++ b/Makefile @@ -5,13 +5,23 @@ CARGO ?= cargo RUSTUP_TOOLCHAIN ?= stable export RUSTUP_TOOLCHAIN -.PHONY: all build release release-musl check test test-release-assertions clean fmt clippy +# Optional wireless iface for `make dev` (e.g. INTERFACE=wlan0). +INTERFACE ?= + +.PHONY: all build release release-musl check test test-release-assertions clean fmt clippy dev all: build build: $(CARGO) build --workspace +# Build and run the daemon in the foreground (local development). +# Override iface: make dev INTERFACE=wlp2s0 +# Override data root / socket: DATA_DIR=/tmp/wp-dev make dev +dev: + $(CARGO) run -p wireless-programmer -- daemon --verbose \ + $(if $(INTERFACE),--interface $(INTERFACE),) + release: $(CARGO) build --workspace --release diff --git a/README.md b/README.md index 1919222..814a2c9 100644 --- a/README.md +++ b/README.md @@ -27,13 +27,29 @@ crates/ wp-proto/ socket wire types + 4-byte-LE length+JSON framing wp-core/ DeviceDriver trait, capabilities, typed errors wp-link/ radio (nl80211/rtnetlink) + bounded HTTP client - wp-drivers/ wifred/ — NewHeiko WiFred driver + wp-drivers/ wifred/, longfred/ — Soft-AP programming drivers + wp-fake/ FakeRadio + Soft-AP HTTP mocks (dev / tests) wp-client/ Rust client SDK (mirrors go/client) wireless-programmer/ bin: socket server, job registry, dispatch + CLI go/client/ Go client (vendored by bigfred) -docs/ api.md, cli.md, go-client.md, drivers/wifred.md +docs/ api.md, cli.md, go-client.md, drivers/ ``` +## Fake mode (no WiFi hardware) + +```bash +# Full daemon with fake radio + Soft-AP HTTP mock (one candidate per driver) +wireless-programmer daemon --interface fake --verbose +# Optional: --fake-webserver-port 8070 (default) or 0 for ephemeral + +# Standalone Soft-AP HTTP mock only (no IPC / radio) +wireless-programmer fake --driver wifred --bind 127.0.0.1:8070 +wireless-programmer fake --driver longfred +``` + +With `--interface fake`, scan always returns one WiFred and one LongFred +candidate; programming talks to an in-process HTTP mock on `127.0.0.1`. + ## Memory profile Every crate is **allocation-conscious** (an administrative service, not a hot @@ -46,10 +62,14 @@ max 64 scan results, max 8 socket connections, max 1 MiB socket frame, max ```bash make build # debug +make dev # build + run daemon in foreground (`--verbose`) make release # release (opt-level z, LTO, strip) make release-musl TARGET_MUSL=aarch64-unknown-linux-musl # static arm64 → dist/ ``` +`make dev` accepts `INTERFACE=wlan0` and the usual env vars (`DATA_DIR`, +`WIRELESS_PROGRAMMER_ALLOW_USERS`, …). + Or the usual Cargo checks: ```bash @@ -67,10 +87,9 @@ workflow (`dcc-bigfred/common` `rust-musl-ci`); tagged releases inject ## Socket API Length-prefixed JSON on `$BIGFRED_DATA_DIR/run/wireless-programmer/wireless-programmer.sock` -(`DATA_DIR`, fallback `/data`), mode `0660`, peers verified with -`SO_PEERCRED`. The socket is chowned to the primary group of the first -allowlist entry, without which `0660` would refuse every non-root peer before -its credentials could be checked. See `docs/api.md`. +(`DATA_DIR`, fallback `/data`). Peer auth is **off by default** (socket +`0666`); enable with `--require-auth` / `WIRELESS_PROGRAMMER_REQUIRE_AUTH` +for `0660` + `SO_PEERCRED` allowlist. See `docs/api.md`. ## CLI diff --git a/crates/wireless-programmer/Cargo.toml b/crates/wireless-programmer/Cargo.toml index 3598f80..9ab66d9 100644 --- a/crates/wireless-programmer/Cargo.toml +++ b/crates/wireless-programmer/Cargo.toml @@ -11,12 +11,17 @@ description = "Daemon that discovers and programs physical throttle hardware for name = "wireless-programmer" path = "src/main.rs" +[lib] +name = "wireless_programmer" +path = "src/lib.rs" + [dependencies] wp-proto = { path = "../wp-proto" } wp-core = { path = "../wp-core" } wp-link = { path = "../wp-link" } wp-drivers = { path = "../wp-drivers" } wp-client = { path = "../wp-client" } +wp-fake = { path = "../wp-fake" } serde = { version = "1", features = ["derive"] } serde_json = "1" clap = { version = "4", features = ["derive"] } diff --git a/crates/wireless-programmer/src/cli/client.rs b/crates/wireless-programmer/src/cli/client.rs index 40a9480..01a24d3 100644 --- a/crates/wireless-programmer/src/cli/client.rs +++ b/crates/wireless-programmer/src/cli/client.rs @@ -24,6 +24,7 @@ pub fn run(command: Command, socket_override: Option) -> ExitCode { Command::Hello(a) => hello(&socket, a), Command::Job(a) => job(&socket, a), Command::Daemon(_) => unreachable!("daemon is not a client command"), + Command::Fake(_) => unreachable!("fake is not a client command"), }; match result { Ok(()) => ExitCode::SUCCESS, diff --git a/crates/wireless-programmer/src/cli/daemon.rs b/crates/wireless-programmer/src/cli/daemon.rs index e1ef332..1883ebb 100644 --- a/crates/wireless-programmer/src/cli/daemon.rs +++ b/crates/wireless-programmer/src/cli/daemon.rs @@ -1,14 +1,19 @@ //! Daemon subcommand runner (the previous `main` behaviour). +use std::net::{Ipv4Addr, SocketAddr}; use std::path::PathBuf; use std::process::ExitCode; +use std::sync::Arc; use clap::Args; use tracing_subscriber::EnvFilter; +use wp_link::{Nl80211Radio, Radio}; use crate::config::Config; use crate::drivers::DriverRegistry; use crate::ipc::Server; +use crate::jobs::JobRegistry; +use crate::runtime::Runtime; /// `daemon` arguments. #[derive(Debug, Clone, Default, Args)] @@ -21,8 +26,28 @@ pub struct DaemonArgs { /// /// When omitted, the first wireless interface under `/sys/class/net` is /// selected. Overrides `WIRELESS_PROGRAMMER_INTERFACE` when set. + /// + /// The special value `fake` enables an in-process fake radio and Soft-AP + /// HTTP mock (one candidate per driver) without real WiFi hardware. #[arg(short = 'i', long = "interface", value_name = "IFACE")] pub interface: Option, + + /// Require SO_PEERCRED peer authentication against the allowlist. + /// + /// Off by default. Also enabled by `WIRELESS_PROGRAMMER_REQUIRE_AUTH=1`. + #[arg(long = "require-auth")] + pub require_auth: bool, + + /// Comma-separated login names allowed when `--require-auth` is set. + /// Defaults to `bigfred,bigfred-wizard`. Overrides + /// `WIRELESS_PROGRAMMER_ALLOW_USERS`. + #[arg(long = "allow-users", value_name = "USERS")] + pub allow_users: Option, + + /// Listen port for the in-process fake Soft-AP HTTP server when + /// `--interface fake`. Default 8070. Use `0` for an ephemeral port. + #[arg(long = "fake-webserver-port", value_name = "PORT")] + pub fake_webserver_port: Option, } /// Run the IPC daemon until shutdown. @@ -38,7 +63,6 @@ pub fn run_daemon(args: DaemonArgs, socket_override: Option) -> ExitCod if let Some(s) = socket_override { cfg.socket = s; } - // CLI wins over the environment default baked into Config::default. if let Some(iface) = args.interface { let iface = iface.trim().to_string(); if iface.is_empty() { @@ -47,28 +71,126 @@ pub fn run_daemon(args: DaemonArgs, socket_override: Option) -> ExitCod } cfg.interface = Some(iface); } + if let Some(port) = args.fake_webserver_port { + cfg.fake_webserver_port = Some(port); + } + if args.require_auth { + cfg.require_auth = true; + } + if let Some(list) = args.allow_users { + cfg.allow_users = list + .split(',') + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(Into::into) + .collect(); + if !cfg.allow_users.is_empty() { + cfg.require_auth = true; + } + } - // Validate the preferred interface early so a typo fails at start-up - // rather than on the first scan/program request. - if let Some(ref name) = cfg.interface { - match wp_link::resolve_wireless_interface(Some(name)) { - Ok(resolved) => cfg.interface = Some(resolved), - Err(e) => { - tracing::error!("wireless interface: {e}"); - return ExitCode::FAILURE; + let fake = cfg.is_fake_radio(); + if fake && cfg.require_auth { + tracing::warn!("fake radio mode: forcing peer auth off"); + cfg.require_auth = false; + } + cfg.finalize_auth(); + + if !fake { + if let Some(ref name) = cfg.interface { + match wp_link::resolve_wireless_interface(Some(name)) { + Ok(resolved) => cfg.interface = Some(resolved), + Err(e) => { + tracing::error!("wireless interface: {e}"); + return ExitCode::FAILURE; + } } } } match &cfg.interface { + Some(name) if name == "fake" => { + tracing::info!("wireless interface: fake (in-process mock)") + } Some(name) => tracing::info!("wireless interface: {name}"), None => tracing::info!("wireless interface: auto (first wireless)"), } + if cfg.require_auth { + tracing::info!( + "peer auth: enabled (allow_users={:?}, mode={:o})", + cfg.allow_users, + cfg.socket_mode + ); + } else { + tracing::info!( + "peer auth: disabled (socket mode {:o}; any local peer may connect)", + cfg.socket_mode + ); + } + + // For fake mode, bind the HTTP mock first so we know the port (supports 0). + let fake_listener = if fake { + let want = cfg.fake_webserver_port.unwrap_or(8070); + let bind = SocketAddr::from((Ipv4Addr::LOCALHOST, want)); + match std::net::TcpListener::bind(bind) { + Ok(l) => { + if let Err(e) = l.set_nonblocking(true) { + tracing::error!("fake webserver: {e}"); + return ExitCode::FAILURE; + } + match l.local_addr() { + Ok(addr) => { + cfg.commissioning_net_override = + Some(Config::localhost_commissioning(addr.port())); + tracing::info!("fake Soft-AP HTTP mock will listen on {addr}"); + Some(l) + } + Err(e) => { + tracing::error!("fake webserver: {e}"); + return ExitCode::FAILURE; + } + } + } + Err(e) => { + tracing::error!("fake webserver bind: {e}"); + return ExitCode::FAILURE; + } + } + } else { + None + }; let registry = DriverRegistry::new(); - let runtime = Server::new(cfg, registry); + let jobs = JobRegistry::new(); - match runtime.run() { + let radio: Box = if fake { + Box::new(wp_fake::FakeRadio::one_per_driver()) + } else { + match Nl80211Radio::with_interface_opt(cfg.interface.as_deref()) { + Ok(r) => Box::new(r), + Err(e) => { + tracing::error!("radio open: {e}"); + return ExitCode::FAILURE; + } + } + }; + + let runtime = match Runtime::new(cfg, registry, jobs, radio) { + Ok(r) => r, + Err(e) => { + tracing::error!("runtime: {e}"); + return ExitCode::FAILURE; + } + }; + + if let Some(std_listener) = fake_listener { + if let Err(e) = spawn_fake_from_std_listener(&runtime, std_listener) { + tracing::error!("fake webserver: {e}"); + return ExitCode::FAILURE; + } + } + + match Server::new(runtime).run() { Ok(()) => ExitCode::SUCCESS, Err(e) => { tracing::error!("fatal: {e}"); @@ -76,3 +198,24 @@ pub fn run_daemon(args: DaemonArgs, socket_override: Option) -> ExitCod } } } + +fn spawn_fake_from_std_listener( + runtime: &Arc, + std_listener: std::net::TcpListener, +) -> Result<(), String> { + let device: Arc> = + Arc::new(tokio::sync::Mutex::new(wp_fake::CompositeFakeDevice::all())); + // `TcpListener::from_std` needs a Tokio reactor — enter via the daemon runtime. + runtime + .handle() + .block_on(async move { + let listener = + tokio::net::TcpListener::from_std(std_listener).map_err(|e| e.to_string())?; + tokio::spawn(async move { + if let Err(e) = wp_fake::FakeHttpServer::serve(listener, device).await { + tracing::error!("fake Soft-AP HTTP mock stopped: {e}"); + } + }); + Ok::<(), String>(()) + }) +} diff --git a/crates/wireless-programmer/src/cli/fake.rs b/crates/wireless-programmer/src/cli/fake.rs new file mode 100644 index 0000000..2696617 --- /dev/null +++ b/crates/wireless-programmer/src/cli/fake.rs @@ -0,0 +1,70 @@ +//! Standalone Soft-AP HTTP mock (`wireless-programmer fake`). + +use std::net::SocketAddr; +use std::process::ExitCode; +use std::sync::Arc; + +use clap::Parser; +use tracing_subscriber::EnvFilter; + +/// `fake` arguments — runs only the Soft-AP HTTP mock (no daemon / radio / IPC). +#[derive(Debug, Parser)] +pub struct FakeArgs { + /// Driver to emulate (`wifred` | `longfred`). + #[arg(long)] + pub driver: String, + + /// Bind address for the mock HTTP server. + #[arg(long, default_value = "127.0.0.1:8070")] + pub bind: SocketAddr, + + /// Verbose logging. + #[arg(short, long)] + pub verbose: bool, +} + +/// Run a standalone fake Soft-AP HTTP server until Ctrl-C. +pub fn run_fake(args: FakeArgs) -> ExitCode { + let filter = if args.verbose { + EnvFilter::new("debug") + } else { + EnvFilter::new("info") + }; + tracing_subscriber::fmt().with_env_filter(filter).init(); + + let device: Arc> = match args.driver.as_str() { + "wifred" => Arc::new(tokio::sync::Mutex::new(wp_fake::WifredFake::new())), + "longfred" => Arc::new(tokio::sync::Mutex::new(wp_fake::LongFredFake::new())), + other => { + tracing::error!("unknown driver {other:?}; expected wifred or longfred"); + return ExitCode::FAILURE; + } + }; + + let rt = match tokio::runtime::Builder::new_multi_thread() + .enable_all() + .build() + { + Ok(r) => r, + Err(e) => { + tracing::error!("tokio runtime: {e}"); + return ExitCode::FAILURE; + } + }; + + match rt.block_on(async { + let local = wp_fake::bind_and_serve(args.bind, device).await?; + tracing::info!( + "fake Soft-AP for driver={} listening on {local} (Ctrl-C to stop)", + args.driver + ); + tokio::signal::ctrl_c().await?; + Ok::<(), std::io::Error>(()) + }) { + Ok(()) => ExitCode::SUCCESS, + Err(e) => { + tracing::error!("fake server: {e}"); + ExitCode::FAILURE + } + } +} diff --git a/crates/wireless-programmer/src/cli/mod.rs b/crates/wireless-programmer/src/cli/mod.rs index 871da62..47c4ee9 100644 --- a/crates/wireless-programmer/src/cli/mod.rs +++ b/crates/wireless-programmer/src/cli/mod.rs @@ -2,6 +2,7 @@ mod client; mod daemon; +mod fake; mod program; use std::path::{Path, PathBuf}; @@ -10,6 +11,7 @@ use clap::{Parser, Subcommand}; use wp_client::ClientError; pub use daemon::{run_daemon, DaemonArgs}; +pub use fake::{run_fake, FakeArgs}; /// Command-line interface. #[derive(Debug, Parser)] @@ -35,6 +37,15 @@ pub struct Cli { /// `daemon --interface`. Overrides `WIRELESS_PROGRAMMER_INTERFACE`. #[arg(short = 'i', long = "interface", value_name = "IFACE")] pub interface: Option, + + /// Require SO_PEERCRED peer authentication (daemon only). Also accepted + /// on `daemon --require-auth`. + #[arg(long = "require-auth")] + pub require_auth: bool, + + /// Comma-separated allowlist when peer auth is on (daemon only). + #[arg(long = "allow-users", value_name = "USERS")] + pub allow_users: Option, } /// Top-level subcommands. @@ -56,6 +67,8 @@ pub enum Command { Hello(CommonArgs), /// Inspect or control a running job. Job(JobArgs), + /// Run a standalone Soft-AP HTTP mock for one driver (no daemon). + Fake(FakeArgs), } /// Shared client-side flags. diff --git a/crates/wireless-programmer/src/config.rs b/crates/wireless-programmer/src/config.rs index fde2225..0a32a74 100644 --- a/crates/wireless-programmer/src/config.rs +++ b/crates/wireless-programmer/src/config.rs @@ -1,8 +1,10 @@ //! Daemon configuration. -use std::net::SocketAddr; +use std::net::Ipv4Addr; use std::path::PathBuf; +use wp_core::CommissioningNet; + /// Daemon configuration, resolved from CLI + environment. #[derive(Debug, Clone)] pub struct Config { @@ -10,12 +12,17 @@ pub struct Config { pub socket: PathBuf, /// Socket mode (permissions). pub socket_mode: u32, + /// When `true`, enforce [`Self::allow_users`] via `SO_PEERCRED`. + /// Off by default (open to any local peer that can open the socket). + pub require_auth: bool, /// Users allowed to connect (login names), matched via SO_PEERCRED. + /// Used only when [`Self::require_auth`] is `true`. pub allow_users: Vec, /// Login name whose primary group owns the socket. `None` means "use the - /// first entry of `allow_users`", matching microinit's `socketAllowUsers` - /// model. Without a group owner a `0660` socket is unreachable for every - /// allowlisted peer, since DAC rejects `connect(2)` before `SO_PEERCRED`. + /// first entry of `allow_users`" when auth is on, matching microinit's + /// `socketAllowUsers` model. Without a group owner a `0660` socket is + /// unreachable for every allowlisted peer, since DAC rejects `connect(2)` + /// before `SO_PEERCRED`. pub socket_group_user: Option, /// Data directory (BIGFRED_DATA_DIR / DATA_DIR / /data). pub data_dir: PathBuf, @@ -23,23 +30,33 @@ pub struct Config { pub version: String, /// Git commit, when built with WIRELESS_PROGRAMMER_GIT_COMMIT. pub commit: Option, - /// Source address bound on the wireless interface during programming. - pub source_addr: SocketAddr, /// Wireless interface to use (`wlan0`, `wlp2s0`, …). `None` means auto- - /// select the first wireless interface at radio open time. + /// select the first wireless interface at radio open time. The special + /// value `"fake"` enables in-process fake radio + HTTP device mock. pub interface: Option, + /// When set, override driver Soft-AP addressing (fake mode points at + /// `127.0.0.1:port`). + pub commissioning_net_override: Option, + /// Listen port for the in-process fake HTTP server when + /// `interface == "fake"`. `None` defaults to 8070; `Some(0)` asks the OS + /// for an ephemeral port. + pub fake_webserver_port: Option, } impl Default for Config { fn default() -> Self { let data_dir = resolve_data_dir(); + let require_auth = resolve_require_auth(); + let allow_users = resolve_allow_users(require_auth); + let socket_mode = if require_auth { 0o660 } else { 0o666 }; Self { socket: data_dir .join("run") .join("wireless-programmer") .join("wireless-programmer.sock"), - socket_mode: 0o660, - allow_users: resolve_allow_users(), + socket_mode, + require_auth, + allow_users, socket_group_user: std::env::var("WIRELESS_PROGRAMMER_SOCKET_GROUP_USER") .ok() .map(|s| s.trim().to_string()) @@ -47,8 +64,9 @@ impl Default for Config { data_dir, version: resolve_version(), commit: resolve_commit(), - source_addr: "192.168.4.2:0".parse().expect("valid default source addr"), interface: resolve_interface_env(), + commissioning_net_override: None, + fake_webserver_port: resolve_fake_web_port_env(), } } } @@ -77,28 +95,88 @@ fn resolve_commit() -> Option { } impl Config { + /// Apply auth-related settings after CLI overrides. Keeps socket mode in + /// sync with [`Self::require_auth`] and fills the default allowlist when + /// auth is enabled without an explicit list. + pub fn finalize_auth(&mut self) { + if self.require_auth && self.allow_users.is_empty() { + self.allow_users = default_allow_users(); + } + if !self.require_auth { + // Open socket when peer auth is off — any local process may connect. + self.socket_mode = 0o666; + } else if self.socket_mode == 0o666 { + self.socket_mode = 0o660; + } + } + + /// Whether this config requests fake radio mode (`--interface fake`). + #[must_use] + pub fn is_fake_radio(&self) -> bool { + self.interface.as_deref() == Some("fake") + } + /// Login name whose primary group should own the socket: the explicit - /// override when set, otherwise the first allowlist entry. + /// override when set, otherwise the first allowlist entry (auth on only). #[must_use] pub fn socket_group_owner(&self) -> Option<&str> { - self.socket_group_user - .as_deref() - .or_else(|| self.allow_users.first().map(String::as_str)) + if let Some(ref u) = self.socket_group_user { + return Some(u.as_str()); + } + if self.require_auth { + self.allow_users.first().map(String::as_str) + } else { + None + } + } + + /// Build a local commissioning override pointing at `127.0.0.1:port`. + #[must_use] + pub fn localhost_commissioning(port: u16) -> CommissioningNet { + CommissioningNet { + host: Ipv4Addr::LOCALHOST, + port, + source: Ipv4Addr::LOCALHOST, + prefix: 8, + } + } +} + +/// `WIRELESS_PROGRAMMER_REQUIRE_AUTH` — truthy values enable peer auth. +/// Default: off. +fn resolve_require_auth() -> bool { + match std::env::var("WIRELESS_PROGRAMMER_REQUIRE_AUTH") { + Ok(v) => { + let v = v.trim().to_ascii_lowercase(); + matches!(v.as_str(), "1" | "true" | "yes" | "on") + } + Err(_) => false, } } -/// Resolve the peer allowlist. Defaults to `bigfred` and `bigfred-wizard`; -/// override with `WIRELESS_PROGRAMMER_ALLOW_USERS` (comma-separated login -/// names, replaces the default). -fn resolve_allow_users() -> Vec { +fn default_allow_users() -> Vec { + vec!["bigfred".into(), "bigfred-wizard".into()] +} + +/// Resolve the peer allowlist. When auth is off, returns empty (unused). +/// When auth is on: `WIRELESS_PROGRAMMER_ALLOW_USERS` or the BigFred defaults. +fn resolve_allow_users(require_auth: bool) -> Vec { match std::env::var("WIRELESS_PROGRAMMER_ALLOW_USERS") { - Ok(v) => v - .split(',') - .map(str::trim) - .filter(|s| !s.is_empty()) - .map(Into::into) - .collect::>(), - Err(_) => vec!["bigfred".into(), "bigfred-wizard".into()], + Ok(v) => { + let list = v + .split(',') + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(Into::into) + .collect::>(); + if require_auth && list.is_empty() { + default_allow_users() + } else { + list + } + } + Err(_) if require_auth => default_allow_users(), + Err(_) => Vec::new(), } } @@ -110,6 +188,12 @@ fn resolve_interface_env() -> Option { .filter(|s| !s.is_empty()) } +fn resolve_fake_web_port_env() -> Option { + std::env::var("WIRELESS_PROGRAMMER_FAKE_WEB_PORT") + .ok() + .and_then(|s| s.trim().parse().ok()) +} + /// Resolve the BigFred data directory. pub fn resolve_data_dir() -> PathBuf { if let Ok(d) = std::env::var("BIGFRED_DATA_DIR") { @@ -120,3 +204,64 @@ pub fn resolve_data_dir() -> PathBuf { } PathBuf::from("/data") } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn finalize_auth_fills_default_allowlist() { + let mut cfg = Config { + require_auth: true, + allow_users: Vec::new(), + socket_mode: 0o666, + ..Config::default() + }; + cfg.finalize_auth(); + assert_eq!(cfg.allow_users, default_allow_users()); + assert_eq!(cfg.socket_mode, 0o660); + } + + #[test] + fn finalize_auth_opens_socket_when_auth_off() { + let mut cfg = Config { + require_auth: false, + allow_users: default_allow_users(), + socket_mode: 0o660, + ..Config::default() + }; + cfg.finalize_auth(); + assert_eq!(cfg.socket_mode, 0o666); + } + + #[test] + fn socket_group_owner_none_when_auth_off() { + let cfg = Config { + require_auth: false, + allow_users: default_allow_users(), + socket_group_user: None, + ..Config::default() + }; + assert_eq!(cfg.socket_group_owner(), None); + } + + #[test] + fn socket_group_owner_uses_allowlist_when_auth_on() { + let cfg = Config { + require_auth: true, + allow_users: vec!["bigfred".into()], + socket_group_user: None, + ..Config::default() + }; + assert_eq!(cfg.socket_group_owner(), Some("bigfred")); + } + + #[test] + fn is_fake_radio_detects_interface() { + let cfg = Config { + interface: Some("fake".into()), + ..Config::default() + }; + assert!(cfg.is_fake_radio()); + } +} diff --git a/crates/wireless-programmer/src/drivers.rs b/crates/wireless-programmer/src/drivers.rs index e3fa30d..a11126e 100644 --- a/crates/wireless-programmer/src/drivers.rs +++ b/crates/wireless-programmer/src/drivers.rs @@ -3,7 +3,12 @@ //! The driver set is closed at compile time, so dispatch uses an enum //! (guidelines §8.2) rather than `Box`. -use wp_core::{DeviceCandidate, DeviceDriver, DriverCapabilities, Observation}; +use std::net::Ipv4Addr; + +use wp_core::{ + CommissioningNet, DeviceCandidate, DeviceDriver, DriverCapabilities, DriverError, Observation, + Outcome, ProgressSink, ProgramRequest, Transport, +}; use wp_drivers::{LongFredDriver, WiFredDriver}; /// All registered drivers. @@ -31,6 +36,28 @@ impl Driver { Driver::LongFred => "LongFred", } } + + /// Soft-AP addressing for commissioning. + pub fn commissioning_net(self) -> CommissioningNet { + match self { + Driver::WiFred => CommissioningNet { + host: Ipv4Addr::new(192, 168, 4, 1), + port: 80, + source: Ipv4Addr::new(192, 168, 4, 2), + prefix: 24, + }, + Driver::LongFred => wp_drivers::longfred::commissioning_net(), + } + } + + /// Parse a driver id string. + pub fn from_id(id: &str) -> Option { + match id { + "wifred" => Some(Driver::WiFred), + "longfred" => Some(Driver::LongFred), + _ => None, + } + } } /// A registry of all drivers, owning their instances. @@ -71,11 +98,7 @@ impl DriverRegistry { /// Find the driver owning a candidate. pub fn driver_for(&self, candidate: &wp_proto::CandidateRef) -> Option { - match candidate.driver.as_str() { - "wifred" => Some(Driver::WiFred), - "longfred" => Some(Driver::LongFred), - _ => None, - } + Driver::from_id(candidate.driver.as_str()) } /// Claim a raw observation against every driver. @@ -85,6 +108,44 @@ impl DriverRegistry { .or_else(|| self.wifred.identify(obs)) } + /// Validate a request against the driver's capabilities. + pub fn validate( + &self, + driver: Driver, + req: &ProgramRequest<'_>, + ) -> Result<(), wp_core::ValidationError> { + match driver { + Driver::WiFred => self.wifred.validate(req), + Driver::LongFred => self.longfred.validate(req), + } + } + + /// Probe a device over the supplied transport. + pub async fn probe( + &self, + driver: Driver, + transport: Transport<'_>, + ) -> Result { + match driver { + Driver::WiFred => self.wifred.probe(transport).await, + Driver::LongFred => self.longfred.probe(transport).await, + } + } + + /// Program a device over the supplied transport. + pub async fn program( + &self, + driver: Driver, + transport: Transport<'_>, + req: &ProgramRequest<'_>, + progress: &mut dyn ProgressSink, + ) -> Result { + match driver { + Driver::WiFred => self.wifred.program(transport, req, progress).await, + Driver::LongFred => self.longfred.program(transport, req, progress).await, + } + } + /// Borrow the WiFred driver. pub fn wifred(&self) -> &WiFredDriver { &self.wifred diff --git a/crates/wireless-programmer/src/ipc.rs b/crates/wireless-programmer/src/ipc.rs index e94da67..49c0dad 100644 --- a/crates/wireless-programmer/src/ipc.rs +++ b/crates/wireless-programmer/src/ipc.rs @@ -1,9 +1,10 @@ -//! Unix socket server: length-prefixed JSON, SO_PEERCRED, 0660. +//! Unix socket server: length-prefixed JSON, optional SO_PEERCRED, 0660/0666. //! //! Wire format matches `microinit` (see `microinit/src/ipc.rs`): a 4-byte LE //! length prefix followed by JSON, with each message `type`-tagged. -//! Permissions follow the microinit `socketAllowUsers` model: the socket is -//! `0660` and peer credentials are checked against an allowlist. +//! Peer authentication is **off by default**. When enabled (`--require-auth` +//! / `WIRELESS_PROGRAMMER_REQUIRE_AUTH`), the socket is `0660` and peers are +//! checked against an allowlist via `SO_PEERCRED`. use std::io; use std::os::unix::fs::PermissionsExt; @@ -18,24 +19,18 @@ use wp_proto::{ }; use crate::config::Config; -use crate::drivers::DriverRegistry; -use crate::jobs::{JobRegistry, JobState}; +use crate::jobs::JobState; +use crate::runtime::Runtime; /// The IPC server. pub struct Server { - cfg: Config, - registry: DriverRegistry, - jobs: JobRegistry, + runtime: Arc, } impl Server { - /// Construct the server. - pub fn new(cfg: Config, registry: DriverRegistry) -> Self { - Self { - cfg, - registry, - jobs: JobRegistry::new(), - } + /// Construct the server around a shared [`Runtime`]. + pub fn new(runtime: Arc) -> Self { + Self { runtime } } /// Bind and serve until shutdown. @@ -44,23 +39,21 @@ impl Server { /// /// Returns [`io::Error`] on bind/listen failure. pub fn run(self) -> io::Result<()> { - let socket = &self.cfg.socket; + let socket = self.runtime.config().socket.clone(); if let Some(parent) = socket.parent() { std::fs::create_dir_all(parent)?; } if socket.exists() { - std::fs::remove_file(socket)?; + std::fs::remove_file(&socket)?; } - let listener = UnixListener::bind(socket)?; - let perms = std::fs::Permissions::from_mode(self.cfg.socket_mode); - std::fs::set_permissions(socket, perms)?; - set_socket_group(socket, &self.cfg); + let listener = UnixListener::bind(&socket)?; + let perms = std::fs::Permissions::from_mode(self.runtime.config().socket_mode); + std::fs::set_permissions(&socket, perms)?; + set_socket_group(&socket, self.runtime.config()); tracing::info!("listening on {}", socket.display()); let inner = Arc::new(ServerInner { - cfg: self.cfg, - registry: self.registry, - jobs: self.jobs, + runtime: self.runtime, }); for stream in listener.incoming() { @@ -77,9 +70,7 @@ impl Server { } struct ServerInner { - cfg: Config, - registry: DriverRegistry, - jobs: JobRegistry, + runtime: Arc, } impl ServerInner { @@ -100,6 +91,13 @@ impl ServerInner { return Ok(()); } }; + // JobWatch streams many frames on one connection until terminal. + if req.kind == RequestKind::JobWatch { + if let Err(e) = self.stream_job_watch(&mut stream, req) { + tracing::warn!("job.watch stream error: {e}"); + } + return Ok(()); + } let resp = self.dispatch(req); if let Err(e) = write_frame(&mut stream, &resp) { tracing::warn!("frame write error: {e}"); @@ -109,9 +107,15 @@ impl ServerInner { } fn peer_allowed(&self, stream: &UnixStream) -> bool { - if self.cfg.allow_users.is_empty() { + let cfg = self.runtime.config(); + if !cfg.require_auth { return true; } + if cfg.allow_users.is_empty() { + // Auth on with an empty list should never happen after + // finalize_auth, but fail closed. + return false; + } let creds = match getsockopt(stream, PeerCredentials) { Ok(c) => c, Err(_) => return false, @@ -119,86 +123,267 @@ impl ServerInner { let uid = creds.uid(); let name = username_for_uid(uid); match name { - Some(n) => self.cfg.allow_users.iter().any(|u| u == &n), + Some(n) => cfg.allow_users.iter().any(|u| u == &n), None => false, } } + fn stream_job_watch(&self, stream: &mut UnixStream, req: Request) -> io::Result<()> { + let write = |stream: &mut UnixStream, resp: &Response| { + write_frame(stream, resp).map_err(|e| io::Error::other(e.to_string())) + }; + let job_id = match req.params { + Some(Params::Job(p)) => crate::jobs::JobId(p.job_id), + _ => { + write( + stream, + &err_response(RequestKind::JobWatch, "bad_params", "missing params"), + )?; + return Ok(()); + } + }; + if self.runtime.jobs().snapshot(&job_id).is_none() { + write( + stream, + &err_response(RequestKind::JobWatch, "not_found", "no such job"), + )?; + return Ok(()); + } + let mut since = 0usize; + loop { + let Some(frames) = self.runtime.jobs().frames_since(&job_id, since) else { + write( + stream, + &err_response(RequestKind::JobWatch, "not_found", "no such job"), + )?; + return Ok(()); + }; + let mut terminal = false; + for f in &frames { + let wire = job_frame_to_wire(f); + terminal = wire.state.is_terminal(); + write( + stream, + &Response { + kind: RequestKind::JobWatch, + result: Some(ResultBody::JobWatch(wire)), + error: None, + }, + )?; + } + since += frames.len(); + if terminal { + return Ok(()); + } + // If no frames yet, still emit a snapshot once so the client sees Queued. + if since == 0 { + if let Some(s) = self.runtime.jobs().snapshot(&job_id) { + let wire = snapshot_to_frame(s); + let terminal = wire.state.is_terminal(); + write( + stream, + &Response { + kind: RequestKind::JobWatch, + result: Some(ResultBody::JobWatch(wire)), + error: None, + }, + )?; + since = self.runtime.jobs().frame_count(&job_id).max(1); + if terminal { + return Ok(()); + } + } + } + std::thread::sleep(std::time::Duration::from_millis(200)); + } + } + fn dispatch(&self, req: Request) -> Response { match req.kind { RequestKind::Hello => Response { kind: RequestKind::Hello, result: Some(ResultBody::Hello(wp_proto::HelloResult { - version: self.cfg.version.clone(), - commit: self.cfg.commit.clone(), - drivers: self.registry.driver_infos(), + version: self.runtime.config().version.clone(), + commit: self.runtime.config().commit.clone(), + drivers: self.runtime.registry().driver_infos(), })), error: None, }, - RequestKind::Scan => Response { - kind: RequestKind::Scan, - result: Some(ResultBody::Scan(Vec::new())), - error: None, - }, - RequestKind::Probe => Response { - kind: RequestKind::Probe, - result: None, - error: Some(ErrorBody::new( - "not_implemented", - "probe requires a live radio (hardware)", - )), - }, - RequestKind::Program => match req.params { - Some(Params::Program(p)) => match self.registry.driver_for(&p.candidate) { - Some(_d) => match self.jobs.start(&p.candidate.driver, &p.candidate.key) { - Ok(id) => Response { - kind: RequestKind::Program, - result: Some(ResultBody::Program(wp_proto::ProgramResult { - job_id: id.0.clone(), - })), + RequestKind::Scan => { + tracing::info!("scan started"); + match self.runtime.scan() { + Ok(found) => { + let candidates: Vec = found + .iter() + .map(|c| wp_proto::CandidateWire { + driver: c.driver.clone(), + key: c.key.clone(), + label: c.label.clone(), + rssi: c.rssi, + }) + .collect(); + if candidates.is_empty() { + tracing::info!("scan finished: no handsets found"); + } else { + let names: Vec<&str> = + candidates.iter().map(|c| c.label.as_str()).collect(); + tracing::info!( + count = candidates.len(), + ?names, + "scan finished: found handsets" + ); + for c in &candidates { + tracing::info!( + driver = %c.driver, + key = %c.key, + label = %c.label, + rssi = ?c.rssi, + "scan candidate" + ); + } + } + Response { + kind: RequestKind::Scan, + result: Some(ResultBody::Scan(candidates)), error: None, + } + } + Err(e) => { + tracing::warn!(error = %e, "scan failed"); + err_response(RequestKind::Scan, "scan_failed", &e.to_string()) + } + } + } + RequestKind::Probe => match req.params { + Some(Params::Probe(p)) => { + match self.runtime.registry().driver_for(&p.candidate) { + Some(d) => match self.runtime.probe(d, &p.candidate.key) { + Ok(info) => Response { + kind: RequestKind::Probe, + result: Some(ResultBody::Probe(device_info_from_probe( + d.id_str(), + &p.candidate.key, + &info, + ))), + error: None, + }, + Err(e) => { + err_response(RequestKind::Probe, "probe_failed", &e.to_string()) + } }, - Err(e) => err_response(RequestKind::Program, "busy", &e.to_string()), - }, - None => err_response( - RequestKind::Program, - "unknown_driver", - "no driver owns this candidate", - ), - }, - _ => err_response(RequestKind::Program, "bad_params", "missing params"), + None => err_response( + RequestKind::Probe, + "unknown_driver", + "no driver owns this candidate", + ), + } + } + _ => err_response(RequestKind::Probe, "bad_params", "missing params"), }, - RequestKind::JobGet => match req.params { - Some(Params::Job(p)) => match self.jobs.snapshot(&crate::jobs::JobId(p.job_id)) { - Some(s) => Response { - kind: RequestKind::JobGet, - result: Some(ResultBody::Job(snapshot_to_wire(s))), - error: None, - }, - None => err_response(RequestKind::JobGet, "not_found", "no such job"), - }, - _ => err_response(RequestKind::JobGet, "bad_params", "missing params"), + RequestKind::Program => match req.params { + Some(Params::Program(p)) => { + let roster_addrs: Vec = p + .request + .roster + .iter() + .filter_map(|e| e.address) + .collect(); + tracing::info!( + driver = %p.candidate.driver, + key = %p.candidate.key, + identity = %p.request.identity, + wifi_ssid = %p.request.wifi.ssid, + server = %format!("{}:{}", p.request.server.host, p.request.server.port), + automatic = ?p.request.server.automatic, + roster = ?roster_addrs, + bigfred_login = ?p.request.bigfred.as_ref().map(|b| b.login.as_str()), + roster_mode = ?p.request.roster_mode, + "program request received" + ); + match self.runtime.registry().driver_for(&p.candidate) { + Some(d) => { + match self.runtime.submit_program( + d, + &p.candidate.key, + p.request, + ) { + Ok(id) => { + tracing::info!( + job_id = %id.0, + driver = %p.candidate.driver, + key = %p.candidate.key, + "program job queued" + ); + Response { + kind: RequestKind::Program, + result: Some(ResultBody::Program( + wp_proto::ProgramResult { + job_id: id.0.clone(), + }, + )), + error: None, + } + } + Err(e) => { + tracing::warn!( + driver = %p.candidate.driver, + key = %p.candidate.key, + error = %e, + "program rejected" + ); + let code = match &e { + crate::jobs::JobError::Busy(_) => "busy", + crate::jobs::JobError::Validation(_) => "validation", + _ => "program_failed", + }; + err_response(RequestKind::Program, code, &e.to_string()) + } + } + } + None => { + tracing::warn!( + driver = %p.candidate.driver, + "program rejected: unknown driver" + ); + err_response( + RequestKind::Program, + "unknown_driver", + "no driver owns this candidate", + ) + } + } + } + _ => { + tracing::warn!("program rejected: missing params"); + err_response(RequestKind::Program, "bad_params", "missing params") + } }, - RequestKind::JobWatch => match req.params { + RequestKind::JobGet => match req.params { Some(Params::Job(p)) => { - // Streaming is handled by the caller draining frames; here - // we return the current snapshot as a single frame. - match self.jobs.snapshot(&crate::jobs::JobId(p.job_id)) { + match self.runtime.jobs().snapshot(&crate::jobs::JobId(p.job_id)) { Some(s) => Response { - kind: RequestKind::JobWatch, - result: Some(ResultBody::JobWatch(snapshot_to_frame(s))), + kind: RequestKind::JobGet, + result: Some(ResultBody::Job(snapshot_to_wire(s))), error: None, }, - None => err_response(RequestKind::JobWatch, "not_found", "no such job"), + None => err_response(RequestKind::JobGet, "not_found", "no such job"), } } - _ => err_response(RequestKind::JobWatch, "bad_params", "missing params"), + _ => err_response(RequestKind::JobGet, "bad_params", "missing params"), }, + RequestKind::JobWatch => { + // Handled in handle_conn via stream_job_watch. + err_response( + RequestKind::JobWatch, + "internal", + "job.watch must stream", + ) + } RequestKind::JobCancel => match req.params { Some(Params::Job(p)) => { let id = crate::jobs::JobId(p.job_id); - self.jobs.cancel(&id); - match self.jobs.snapshot(&id) { + self.runtime.jobs().cancel(&id); + match self.runtime.jobs().snapshot(&id) { Some(s) => Response { kind: RequestKind::JobCancel, result: Some(ResultBody::JobCancelled(snapshot_to_wire(s))), @@ -211,30 +396,37 @@ impl ServerInner { }, RequestKind::Identify => Response { kind: RequestKind::Identify, - result: Some(ResultBody::Identify), - error: None, - }, - RequestKind::LinkStatus => Response { - kind: RequestKind::LinkStatus, - result: Some(ResultBody::LinkStatus(wp_proto::LinkStatusWire { - busy: self.jobs_is_busy(), - interface: self - .cfg - .interface - .clone() - .or_else(|| wp_link::first_wireless_interface().ok()), - rfkill_blocked: false, - })), - error: None, + result: None, + error: Some(ErrorBody::new( + "not_implemented", + "driver has no identify support", + )), }, + RequestKind::LinkStatus => { + let cfg = self.runtime.config(); + let rfkill_blocked = wp_link::rfkill::aggregate_state() + .ok() + .flatten() + .map(|s| s.blocked()) + .unwrap_or(false); + Response { + kind: RequestKind::LinkStatus, + result: Some(ResultBody::LinkStatus(wp_proto::LinkStatusWire { + busy: self.runtime.jobs().is_busy(), + interface: cfg.interface.clone().or_else(|| { + if cfg.is_fake_radio() { + Some("fake".into()) + } else { + wp_link::first_wireless_interface().ok() + } + }), + rfkill_blocked, + })), + error: None, + } + } } } - - fn jobs_is_busy(&self) -> bool { - // The registry tracks one active job; busy when a non-terminal job - // exists. Approximated by checking whether any job is non-terminal. - false - } } /// Give the socket a group owner so allowlisted peers can actually open it. @@ -312,6 +504,31 @@ fn primary_gid_for_user(name: &str) -> Option { found } +fn device_info_from_probe( + driver: &str, + key: &str, + info: &serde_json::Value, +) -> wp_proto::DeviceInfoWire { + let identity = info + .get("throttleName") + .and_then(|v| v.as_str()) + .or_else(|| info.pointer("/wifi/hostname").and_then(|v| v.as_str())) + .map(str::to_string); + let firmware_revision = info + .get("firmwareRevision") + .and_then(|v| v.as_str()) + .map(str::to_string); + let battery_mv = info.get("batteryMv").and_then(|v| v.as_u64()).map(|n| n as u32); + wp_proto::DeviceInfoWire { + driver: driver.into(), + key: key.into(), + firmware_revision, + identity, + battery_mv, + roster: Vec::new(), + } +} + fn snapshot_to_wire(s: crate::jobs::JobSnapshot) -> wp_proto::JobSnapshot { wp_proto::JobSnapshot { job_id: s.id.0.clone(), @@ -332,6 +549,16 @@ fn snapshot_to_frame(s: crate::jobs::JobSnapshot) -> wp_proto::JobFrame { } } +fn job_frame_to_wire(f: &crate::jobs::JobFrame) -> wp_proto::JobFrame { + wp_proto::JobFrame { + job_id: f.id.0.clone(), + state: state_to_wire(f.state), + step: f.step.clone(), + progress: f.progress, + detail: f.detail.clone(), + } +} + fn state_to_wire(s: JobState) -> wp_proto::JobStateWire { match s { JobState::Queued => wp_proto::JobStateWire::Queued, @@ -388,6 +615,7 @@ bigfred:x:1000:1001:BigFred loco-server:/home/bigfred:/bin/false #[test] fn socket_group_owner_defaults_to_first_allowlist_entry() { let cfg = Config { + require_auth: true, allow_users: vec!["bigfred".into(), "bigfred-wizard".into()], socket_group_user: None, ..Config::default() @@ -398,6 +626,7 @@ bigfred:x:1000:1001:BigFred loco-server:/home/bigfred:/bin/false #[test] fn socket_group_owner_override_wins() { let cfg = Config { + require_auth: true, allow_users: vec!["bigfred".into()], socket_group_user: Some("operators".into()), ..Config::default() @@ -408,10 +637,22 @@ bigfred:x:1000:1001:BigFred loco-server:/home/bigfred:/bin/false #[test] fn socket_group_owner_is_none_without_an_allowlist() { let cfg = Config { + require_auth: true, allow_users: Vec::new(), socket_group_user: None, ..Config::default() }; assert_eq!(cfg.socket_group_owner(), None); } + + #[test] + fn socket_group_owner_is_none_when_auth_disabled() { + let cfg = Config { + require_auth: false, + allow_users: vec!["bigfred".into()], + socket_group_user: None, + ..Config::default() + }; + assert_eq!(cfg.socket_group_owner(), None); + } } diff --git a/crates/wireless-programmer/src/jobs.rs b/crates/wireless-programmer/src/jobs.rs index 228b1f9..2bb9dbd 100644 --- a/crates/wireless-programmer/src/jobs.rs +++ b/crates/wireless-programmer/src/jobs.rs @@ -9,6 +9,7 @@ use std::time::{Duration, Instant}; use parking_lot::Mutex; use wp_core::DriverError; +use wp_proto::ProgramRequestWire; /// Overall job deadline. pub const JOB_DEADLINE: Duration = Duration::from_secs(120); @@ -107,6 +108,7 @@ struct JobRecord { snapshot: JobSnapshot, frames: Vec, cancel: bool, + request: Option, } /// A shared job registry. Only one job may be active at a time. @@ -132,6 +134,16 @@ impl JobRegistry { /// Try to start a job. Returns [`JobError::Busy`] when one is active. pub fn start(&self, driver: &str, key: &str) -> Result { + self.submit(driver, key, None) + } + + /// Start a job and store the programming request for the worker. + pub fn submit( + &self, + driver: &str, + key: &str, + request: Option, + ) -> Result { let mut inner = self.inner.lock(); if let Some(active) = inner.active.as_ref() { return Err(JobError::Busy(active.clone())); @@ -150,12 +162,27 @@ impl JobRegistry { }, frames: Vec::new(), cancel: false, + request, }; inner.active = Some(id.clone()); inner.jobs.insert(id.clone(), rec); Ok(JobId(id)) } + /// Take the stored programming request (worker pulls once). + pub fn take_request(&self, id: &JobId) -> Option { + self.inner + .lock() + .jobs + .get_mut(&id.0) + .and_then(|r| r.request.take()) + } + + /// Whether a non-terminal job currently holds the radio. + pub fn is_busy(&self) -> bool { + self.inner.lock().active.is_some() + } + /// Push a state transition + frame for a job. pub fn transition( &self, @@ -184,11 +211,27 @@ impl JobRegistry { } } - /// Mark a job cancelled (caller request). + /// Mark a job cancelled. Transitions to [`JobState::Cancelled`] when the + /// job is still non-terminal (frees the radio). The worker also observes + /// the cancel flag via [`Self::is_cancelled`]. pub fn cancel(&self, id: &JobId) { let mut inner = self.inner.lock(); - if let Some(rec) = inner.jobs.get_mut(&id.0) { - rec.cancel = true; + let Some(rec) = inner.jobs.get_mut(&id.0) else { + return; + }; + rec.cancel = true; + if !rec.snapshot.state.is_terminal() { + rec.snapshot.state = JobState::Cancelled; + rec.frames.push(JobFrame { + id: id.clone(), + state: JobState::Cancelled, + step: None, + progress: None, + detail: Some("cancelled by caller".into()), + }); + if inner.active.as_deref() == Some(id.0.as_str()) { + inner.active = None; + } } } diff --git a/crates/wireless-programmer/src/lib.rs b/crates/wireless-programmer/src/lib.rs new file mode 100644 index 0000000..4acf374 --- /dev/null +++ b/crates/wireless-programmer/src/lib.rs @@ -0,0 +1,12 @@ +//! Library surface for the `wireless-programmer` binary (and integration tests). + +#![forbid(unsafe_code)] +#![allow(dead_code)] + +pub mod cli; +pub mod config; +pub mod drivers; +pub mod ipc; +pub mod jobs; +pub mod runtime; +pub mod version; diff --git a/crates/wireless-programmer/src/main.rs b/crates/wireless-programmer/src/main.rs index 4809fc8..124f356 100644 --- a/crates/wireless-programmer/src/main.rs +++ b/crates/wireless-programmer/src/main.rs @@ -1,44 +1,44 @@ //! `wireless-programmer` — daemon and CLI client for BigFred device programming. -//! -//! The same binary acts both as the long-running daemon (`wireless-programmer -//! daemon`, the default when no subcommand is given) and as a one-shot client -//! of that daemon (`wireless-programmer scan`, `wireless-programmer program`, -//! ...). The client subcommands are thin wrappers over [`wp_client`]. #![forbid(unsafe_code)] -#![allow(dead_code)] - -mod cli; -mod config; -mod drivers; -mod ipc; -mod jobs; -mod version; use std::process::ExitCode; use clap::Parser; -use cli::{Cli, Command}; +use wireless_programmer::cli::{self, Cli, Command}; fn main() -> ExitCode { let cli = Cli::parse(); match cli.command { Some(Command::Daemon(mut args)) => { - // Top-level `--interface` / `--verbose` apply when the - // subcommand did not set them itself. if args.interface.is_none() { args.interface = cli.interface; } if !args.verbose { args.verbose = cli.verbose; } + if !args.require_auth { + args.require_auth = cli.require_auth; + } + if args.allow_users.is_none() { + args.allow_users = cli.allow_users; + } cli::run_daemon(args, cli.socket) } + Some(Command::Fake(mut args)) => { + if !args.verbose { + args.verbose = cli.verbose; + } + cli::run_fake(args) + } Some(command) => cli::run_client(command, cli.socket), None => cli::run_daemon( cli::DaemonArgs { verbose: cli.verbose, interface: cli.interface, + require_auth: cli.require_auth, + allow_users: cli.allow_users, + fake_webserver_port: None, }, cli.socket, ), diff --git a/crates/wireless-programmer/src/runtime.rs b/crates/wireless-programmer/src/runtime.rs new file mode 100644 index 0000000..abdc85c --- /dev/null +++ b/crates/wireless-programmer/src/runtime.rs @@ -0,0 +1,656 @@ +//! Tokio runtime wrapping radio + programming worker. +//! +//! IPC stays sync (one `std::thread` per connection). Radio work and the +//! programming worker run on a multi-threaded tokio runtime. Sync handlers +//! bridge via [`RuntimeHandle::block_on`]. + +use std::collections::HashMap; +use std::net::SocketAddr; +use std::sync::Arc; +use std::time::Duration; + +use parking_lot::Mutex; +use wp_core::{ + CommissioningNet, Observation, ProgressSink, ProgramRequest, RosterEntry, ThrottleServer, + Transport, WifiCredentials, +}; +use wp_link::{BoundedHttpClient, Radio, ScanResult}; +use wp_proto::ProgramRequestWire; + +use crate::config::Config; +use crate::drivers::{Driver, DriverRegistry}; +use crate::jobs::{JobId, JobRegistry, JobState}; + +/// Cached candidate from the last scan (SSID needed for Soft-AP connect). +#[derive(Debug, Clone)] +pub struct CachedCandidate { + /// Soft-AP SSID. + pub ssid: String, + /// Optional BSSID (colon hex). + pub bssid: Option, + /// Driver id. + pub driver: String, + /// Candidate key. + pub key: String, + /// Label (usually SSID). + pub label: String, + /// RSSI when known. + pub rssi: Option, +} + +/// Shared handle used by IPC and the worker. +pub struct Runtime { + rt: tokio::runtime::Runtime, + radio: Arc>>, + cfg: Config, + registry: Arc, + jobs: JobRegistry, + tx: tokio::sync::mpsc::Sender, + /// Last scan results keyed by `(driver, key)`. + cache: Mutex>, +} + +impl Runtime { + /// Build the runtime, spawn the programming worker, and wrap `radio`. + pub fn new( + cfg: Config, + registry: DriverRegistry, + jobs: JobRegistry, + radio: Box, + ) -> Result, wp_core::DriverError> { + let rt = tokio::runtime::Builder::new_multi_thread() + .enable_all() + .thread_name("wp-runtime") + .build() + .map_err(|e| wp_core::DriverError::Other(format!("tokio runtime: {e}")))?; + + let (tx, rx) = tokio::sync::mpsc::channel::(8); + let radio = Arc::new(tokio::sync::Mutex::new(radio)); + let registry = Arc::new(registry); + + let this = Arc::new(Self { + rt, + radio: Arc::clone(&radio), + cfg, + registry: Arc::clone(®istry), + jobs: jobs.clone(), + tx, + cache: Mutex::new(HashMap::new()), + }); + + let worker = Arc::clone(&this); + this.rt.spawn(async move { + worker_loop(worker, rx).await; + }); + + Ok(this) + } + + /// Borrow the tokio runtime (e.g. to spawn the fake HTTP server). + pub fn handle(&self) -> tokio::runtime::Handle { + self.rt.handle().clone() + } + + /// Shared job registry. + pub fn jobs(&self) -> &JobRegistry { + &self.jobs + } + + /// Driver registry. + pub fn registry(&self) -> &DriverRegistry { + &self.registry + } + + /// Config snapshot. + pub fn config(&self) -> &Config { + &self.cfg + } + + /// Scan the radio and claim candidates via the driver registry. + pub fn scan(&self) -> Result, wp_core::DriverError> { + let radio = Arc::clone(&self.radio); + let results = self + .rt + .handle() + .block_on(async move { + let mut r = radio.lock().await; + r.scan(64).await + })?; + + let mut out = Vec::new(); + let mut cache = self.cache.lock(); + cache.clear(); + for s in results { + let obs = observation_from_scan(&s); + if let Some(c) = self.registry.identify(&obs) { + let ssid = c.label.clone(); + let cached = CachedCandidate { + ssid, + bssid: s.bssid, + driver: c.driver.clone(), + key: c.key.clone(), + label: c.label, + rssi: c.rssi, + }; + cache.insert((c.driver, c.key), cached.clone()); + out.push(cached); + } + } + Ok(out) + } + + /// Look up a cached candidate. + pub fn cached(&self, driver: &str, key: &str) -> Option { + self.cache + .lock() + .get(&(driver.to_string(), key.to_string())) + .cloned() + } + + /// Queue a programming job for the worker. + pub fn submit_program( + &self, + driver: Driver, + key: &str, + request: ProgramRequestWire, + ) -> Result { + // Validate before occupying the radio slot. + let owned = OwnedRequest::from_wire(request.clone()); + let borrowed = owned.borrow(); + self.registry.validate(driver, &borrowed)?; + + let id = self + .jobs + .submit(driver.id_str(), key, Some(request))?; + tracing::info!( + job_id = %id.0, + driver = driver.id_str(), + key, + "program job queued for worker" + ); + if let Err(e) = self.tx.blocking_send(id.clone()) { + tracing::error!(job_id = %id.0, error = %e, "failed to enqueue job to worker"); + self.jobs.transition( + &id, + JobState::Failed, + None, + None, + Some(&format!("worker channel closed: {e}")), + ); + return Err(crate::jobs::JobError::Driver(wp_core::DriverError::Other( + "worker channel closed".into(), + ))); + } + Ok(id) + } + + /// Connect to a candidate Soft-AP and probe. + pub fn probe( + &self, + driver: Driver, + key: &str, + ) -> Result { + let candidate = self.cached(driver.id_str(), key).ok_or_else(|| { + wp_core::DriverError::Other( + "candidate not in scan cache; run scan first".into(), + ) + })?; + let net = self.effective_net(driver); + let radio = Arc::clone(&self.radio); + let registry = Arc::clone(&self.registry); + tracing::info!( + driver = driver.id_str(), + key, + ssid = %candidate.ssid, + bssid = ?candidate.bssid, + "probe: connecting to Soft-AP" + ); + self.rt.handle().block_on(async move { + let mut r = radio.lock().await; + let bssid = parse_bssid(candidate.bssid.as_deref()); + if let Err(e) = r.connect_open(&candidate.ssid, bssid).await { + tracing::warn!( + ssid = %candidate.ssid, + error = %e, + "probe: Soft-AP connect failed" + ); + return Err(e); + } + tracing::info!(ssid = %candidate.ssid, "probe: Soft-AP connect ok"); + r.set_address(net.source, net.prefix).await?; + r.link_up().await?; + + let result = { + let mut client = make_http_client(&net); + let transport = Transport::Http(&mut client); + registry.probe(driver, transport).await + }; + + match &result { + Ok(_) => tracing::info!(ssid = %candidate.ssid, "probe: done"), + Err(e) => tracing::warn!(ssid = %candidate.ssid, error = %e, "probe: failed"), + } + + let _ = r.release().await; + result + }) + } + + fn effective_net(&self, driver: Driver) -> CommissioningNet { + self.cfg + .commissioning_net_override + .unwrap_or_else(|| driver.commissioning_net()) + } +} + +fn observation_from_scan(s: &ScanResult) -> Observation { + Observation { + ssid: s.ssid.clone(), + bssid: s.bssid.clone(), + rssi: s.rssi, + extra: serde_json::Value::Null, + } +} + +fn parse_bssid(s: Option<&str>) -> Option<[u8; 6]> { + let s = s?; + let parts: Vec<&str> = s.split(':').collect(); + if parts.len() != 6 { + return None; + } + let mut out = [0u8; 6]; + for (i, p) in parts.iter().enumerate() { + out[i] = u8::from_str_radix(p, 16).ok()?; + } + Some(out) +} + +fn make_http_client(net: &CommissioningNet) -> BoundedHttpClient { + let source = SocketAddr::from((net.source, 0)); + BoundedHttpClient::new(net.host.to_string(), net.port).with_source(source) +} + +/// Owned copy of a wire request so we can borrow into [`ProgramRequest`]. +struct OwnedRequest { + identity: String, + wifi_ssid: String, + wifi_psk: Option, + server_host: String, + server_port: u16, + server_automatic: bool, + roster: Vec, + bigfred_login: Option, + bigfred_pin: Option, + roster_mode: Option, +} + +struct OwnedRoster { + address: Option, + long_address: Option, + mode: Option, + direction: Option, + functions: Vec, +} + +impl OwnedRequest { + fn from_wire(w: ProgramRequestWire) -> Self { + Self { + identity: w.identity, + wifi_ssid: w.wifi.ssid, + wifi_psk: w.wifi.psk, + server_host: w.server.host, + server_port: w.server.port, + server_automatic: w.server.automatic.unwrap_or(false), + roster: w + .roster + .into_iter() + .map(|e| OwnedRoster { + address: e.address, + long_address: e.long_address, + mode: e.mode, + direction: e.direction, + functions: e + .functions + .into_iter() + .map(|f| wp_core::FunctionMapping { + index: f.index, + value: f.value, + }) + .collect(), + }) + .collect(), + bigfred_login: w.bigfred.as_ref().map(|b| b.login.clone()), + bigfred_pin: w.bigfred.as_ref().map(|b| b.pin.clone()), + roster_mode: w.roster_mode, + } + } + + fn borrow(&self) -> ProgramRequest<'_> { + let roster: Vec> = self + .roster + .iter() + .map(|e| RosterEntry { + address: e.address, + long_address: e.long_address, + mode: e.mode.as_deref(), + direction: e.direction, + functions: e.functions.clone(), + }) + .collect(); + let bigfred = match (&self.bigfred_login, &self.bigfred_pin) { + (Some(login), Some(pin)) => Some(wp_core::BigfredCreds { + login: login.as_str(), + pin: pin.as_str(), + }), + _ => None, + }; + ProgramRequest { + identity: &self.identity, + wifi: WifiCredentials { + ssid: &self.wifi_ssid, + psk: self.wifi_psk.as_deref(), + }, + server: ThrottleServer { + host: &self.server_host, + port: self.server_port, + automatic: self.server_automatic, + }, + roster, + bigfred, + roster_mode: self.roster_mode.as_deref(), + } + } +} + +struct JobProgressSink<'a> { + jobs: &'a JobRegistry, + id: &'a JobId, +} + +impl ProgressSink for JobProgressSink<'_> { + fn step(&mut self, step: &str) { + let state = match step { + "read" | "probe" => JobState::Probing, + "identity" | "locos" | "functions" | "server" | "wifi" | "write" => JobState::Writing, + "verify" => JobState::Verifying, + "restart" | "exit" => JobState::Restarting, + _ => JobState::Writing, + }; + tracing::info!(job_id = %self.id.0, step, ?state, "job step"); + self.jobs + .transition(self.id, state, Some(step), None, None); + } + + fn progress(&mut self, progress: u8) { + let state = self + .jobs + .snapshot(self.id) + .map(|s| s.state) + .unwrap_or(JobState::Writing); + self.jobs + .transition(self.id, state, None, Some(progress), None); + } + + fn detail(&mut self, detail: &str) { + let state = self + .jobs + .snapshot(self.id) + .map(|s| s.state) + .unwrap_or(JobState::Writing); + self.jobs + .transition(self.id, state, None, None, Some(detail)); + } +} + +async fn worker_loop(rt: Arc, mut rx: tokio::sync::mpsc::Receiver) { + while let Some(id) = rx.recv().await { + run_job(&rt, id).await; + } + tracing::warn!("programming worker channel closed; exiting worker loop"); +} + +async fn run_job(rt: &Runtime, id: JobId) { + if rt.jobs.is_cancelled(&id) { + tracing::info!(job_id = %id.0, "job cancelled before start"); + if rt + .jobs + .snapshot(&id) + .map(|s| !s.state.is_terminal()) + .unwrap_or(false) + { + rt.jobs + .transition(&id, JobState::Cancelled, None, None, Some("cancelled")); + } + return; + } + + let Some(wire) = rt.jobs.take_request(&id) else { + tracing::error!(job_id = %id.0, "job missing program request"); + rt.jobs.transition( + &id, + JobState::Failed, + None, + None, + Some("missing program request"), + ); + return; + }; + + let snap = match rt.jobs.snapshot(&id) { + Some(s) => s, + None => { + tracing::error!(job_id = %id.0, "job disappeared before start"); + return; + } + }; + let Some(driver) = Driver::from_id(&snap.driver) else { + tracing::error!(job_id = %id.0, driver = %snap.driver, "unknown driver"); + rt.jobs + .transition(&id, JobState::Failed, None, None, Some("unknown driver")); + return; + }; + + let candidate = match rt.cached(&snap.driver, &snap.key) { + Some(c) => c, + None => { + tracing::error!( + job_id = %id.0, + driver = %snap.driver, + key = %snap.key, + "candidate not in scan cache; run scan first" + ); + rt.jobs.transition( + &id, + JobState::Failed, + None, + None, + Some("candidate not in scan cache; run scan first"), + ); + return; + } + }; + + tracing::info!( + job_id = %id.0, + driver = %snap.driver, + key = %snap.key, + ssid = %candidate.ssid, + bssid = ?candidate.bssid, + identity = %wire.identity, + wifi_ssid = %wire.wifi.ssid, + "job started" + ); + + let owned = OwnedRequest::from_wire(wire); + let net = rt.effective_net(driver); + + rt.jobs + .transition(&id, JobState::Joining, Some("join"), None, None); + + if rt.jobs.is_cancelled(&id) { + tracing::info!(job_id = %id.0, "job cancelled before Soft-AP join"); + rt.jobs + .transition(&id, JobState::Cancelled, None, None, Some("cancelled")); + return; + } + + let mut radio = rt.radio.lock().await; + let bssid = parse_bssid(candidate.bssid.as_deref()); + tracing::info!( + job_id = %id.0, + ssid = %candidate.ssid, + bssid = ?candidate.bssid, + "connecting to Soft-AP" + ); + if let Err(e) = radio.connect_open(&candidate.ssid, bssid).await { + tracing::warn!( + job_id = %id.0, + ssid = %candidate.ssid, + error = %e, + "Soft-AP connect failed" + ); + rt.jobs.transition( + &id, + JobState::Failed, + Some("join"), + None, + Some(&e.to_string()), + ); + let _ = radio.release().await; + return; + } + tracing::info!( + job_id = %id.0, + ssid = %candidate.ssid, + "Soft-AP connect ok" + ); + + tracing::info!( + job_id = %id.0, + source = %net.source, + prefix = net.prefix, + host = %net.host, + port = net.port, + "assigning on-link address" + ); + if let Err(e) = radio.set_address(net.source, net.prefix).await { + tracing::warn!( + job_id = %id.0, + source = %net.source, + error = %e, + "set_address failed" + ); + rt.jobs.transition( + &id, + JobState::Failed, + Some("join"), + None, + Some(&e.to_string()), + ); + let _ = radio.release().await; + return; + } + if let Err(e) = radio.link_up().await { + tracing::warn!(job_id = %id.0, error = %e, "link_up failed"); + rt.jobs.transition( + &id, + JobState::Failed, + Some("join"), + None, + Some(&e.to_string()), + ); + let _ = radio.release().await; + return; + } + tracing::info!( + job_id = %id.0, + target = %format!("{}:{}", net.host, net.port), + "radio ready; starting driver program" + ); + + if rt.jobs.is_cancelled(&id) { + tracing::info!(job_id = %id.0, "job cancelled after Soft-AP join"); + let _ = radio.release().await; + rt.jobs + .transition(&id, JobState::Cancelled, None, None, Some("cancelled")); + return; + } + + // Drop the radio lock while the sync HTTP client talks to the device — + // Soft-AP stays associated; we re-acquire only to release. + drop(radio); + + let borrowed = owned.borrow(); + let mut sink = JobProgressSink { + jobs: &rt.jobs, + id: &id, + }; + let mut client = make_http_client(&net); + let transport = Transport::Http(&mut client); + let outcome = rt + .registry + .program(driver, transport, &borrowed, &mut sink) + .await; + + { + let mut radio = rt.radio.lock().await; + match radio.release().await { + Ok(()) => tracing::info!(job_id = %id.0, "radio released"), + Err(e) => tracing::warn!(job_id = %id.0, error = %e, "radio release failed"), + } + } + + if rt.jobs.is_cancelled(&id) { + tracing::info!(job_id = %id.0, "job cancelled after program"); + if rt + .jobs + .snapshot(&id) + .map(|s| !s.state.is_terminal()) + .unwrap_or(false) + { + rt.jobs + .transition(&id, JobState::Cancelled, None, None, Some("cancelled")); + } + return; + } + + match outcome { + Ok(o) => { + tracing::info!( + job_id = %id.0, + driver = %snap.driver, + key = %snap.key, + restarted = o.restarted, + "job finished successfully" + ); + let detail = if o.restarted { + Some("restarted") + } else { + None + }; + rt.jobs + .transition(&id, JobState::Done, Some("done"), Some(100), detail); + } + Err(e) => { + tracing::warn!( + job_id = %id.0, + driver = %snap.driver, + key = %snap.key, + error = %e, + "job failed" + ); + rt.jobs.transition( + &id, + JobState::Failed, + None, + None, + Some(&e.to_string()), + ); + } + } +} + +/// Helper used by tests / fake mode to wait briefly for frames. +pub fn sleep_ms(ms: u64) { + std::thread::sleep(Duration::from_millis(ms)); +} diff --git a/crates/wireless-programmer/tests/fake_mode_test.rs b/crates/wireless-programmer/tests/fake_mode_test.rs new file mode 100644 index 0000000..fae985d --- /dev/null +++ b/crates/wireless-programmer/tests/fake_mode_test.rs @@ -0,0 +1,172 @@ +//! End-to-end fake-mode tests: FakeRadio + Soft-AP HTTP mock + Runtime. + +use std::net::{Ipv4Addr, SocketAddr}; +use std::sync::Arc; +use std::time::Duration; + +use wp_fake::{CompositeFakeDevice, FakeRadio}; +use wp_proto::{ + ProgramRequestWire, RosterEntryWire, ThrottleServerWire, WifiCredentialsWire, +}; + +use wireless_programmer::config::Config; +use wireless_programmer::drivers::{Driver, DriverRegistry}; +use wireless_programmer::jobs::{JobRegistry, JobState}; +use wireless_programmer::runtime::Runtime; + +fn temp_socket() -> std::path::PathBuf { + let mut p = std::env::temp_dir(); + p.push(format!( + "wp-fake-test-{}-{}.sock", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + p +} + +fn setup_runtime() -> Arc { + let bind = SocketAddr::from((Ipv4Addr::LOCALHOST, 0)); + let bootstrap = tokio::runtime::Builder::new_multi_thread() + .enable_all() + .build() + .unwrap(); + let device = Arc::new(tokio::sync::Mutex::new(CompositeFakeDevice::all())); + let local = bootstrap.block_on(async { + let listener = tokio::net::TcpListener::bind(bind).await.unwrap(); + let local = listener.local_addr().unwrap(); + let device = Arc::clone(&device); + tokio::spawn(async move { + let _ = wp_fake::FakeHttpServer::serve(listener, device).await; + }); + local + }); + // Keep the accept loop alive for the duration of the test process. + std::mem::forget(bootstrap); + + let mut cfg = Config::default(); + cfg.socket = temp_socket(); + cfg.interface = Some("fake".into()); + cfg.require_auth = false; + cfg.finalize_auth(); + cfg.commissioning_net_override = Some(Config::localhost_commissioning(local.port())); + + let radio = Box::new(FakeRadio::one_per_driver()); + Runtime::new(cfg, DriverRegistry::new(), JobRegistry::new(), radio).expect("runtime") +} + +fn wifred_request() -> ProgramRequestWire { + ProgramRequestWire { + identity: "122145".into(), + wifi: WifiCredentialsWire { + ssid: "club-wifi".into(), + psk: Some("secret".into()), + }, + server: ThrottleServerWire { + host: "bigfred.local".into(), + port: 12090, + automatic: Some(false), + }, + roster: vec![RosterEntryWire { + address: Some(3), + long_address: Some(false), + mode: Some("128".into()), + direction: Some(0), + functions: Vec::new(), + }], + bigfred: None, + roster_mode: None, + } +} + +fn longfred_request() -> ProgramRequestWire { + ProgramRequestWire { + identity: "pilot1".into(), + wifi: WifiCredentialsWire { + ssid: "club-wifi".into(), + psk: Some("secret".into()), + }, + server: ThrottleServerWire { + host: "unused.local".into(), + port: 12090, + automatic: Some(false), + }, + roster: vec![RosterEntryWire { + address: Some(3), + long_address: Some(false), + mode: None, + direction: None, + functions: Vec::new(), + }], + bigfred: Some(wp_proto::BigfredCredsWire { + login: "ops".into(), + pin: "1234".into(), + }), + roster_mode: Some("static".into()), + } +} + +fn wait_terminal(rt: &Runtime, id: &wireless_programmer::jobs::JobId) -> JobState { + let deadline = std::time::Instant::now() + Duration::from_secs(10); + loop { + if let Some(s) = rt.jobs().snapshot(id) { + if s.state.is_terminal() { + return s.state; + } + } + if std::time::Instant::now() > deadline { + panic!("job did not reach terminal state"); + } + std::thread::sleep(Duration::from_millis(50)); + } +} + +#[test] +fn fake_scan_returns_one_candidate_per_driver() { + let rt = setup_runtime(); + let found = rt.scan().expect("scan"); + assert_eq!(found.len(), 2); + assert!(found.iter().any(|c| c.driver == "wifred")); + assert!(found.iter().any(|c| c.driver == "longfred")); +} + +#[test] +fn fake_program_wifred_reaches_done() { + let rt = setup_runtime(); + let found = rt.scan().expect("scan"); + let c = found.iter().find(|c| c.driver == "wifred").expect("wifred"); + let id = rt + .submit_program(Driver::WiFred, &c.key, wifred_request()) + .expect("submit"); + let state = wait_terminal(&rt, &id); + assert_eq!(state, JobState::Done, "detail={:?}", rt.jobs().snapshot(&id)); +} + +#[test] +fn fake_program_longfred_reaches_done() { + let rt = setup_runtime(); + let found = rt.scan().expect("scan"); + let c = found + .iter() + .find(|c| c.driver == "longfred") + .expect("longfred"); + let id = rt + .submit_program(Driver::LongFred, &c.key, longfred_request()) + .expect("submit"); + let state = wait_terminal(&rt, &id); + assert_eq!(state, JobState::Done, "detail={:?}", rt.jobs().snapshot(&id)); +} + +#[test] +fn fake_probe_wifred() { + let rt = setup_runtime(); + let found = rt.scan().expect("scan"); + let c = found.iter().find(|c| c.driver == "wifred").expect("wifred"); + let info = rt.probe(Driver::WiFred, &c.key).expect("probe"); + assert_eq!( + info.get("structureVersion").and_then(|v| v.as_str()), + Some("1") + ); +} diff --git a/crates/wp-core/src/driver.rs b/crates/wp-core/src/driver.rs index 777df76..6d87b7e 100644 --- a/crates/wp-core/src/driver.rs +++ b/crates/wp-core/src/driver.rs @@ -57,7 +57,7 @@ pub struct ScanFilters { } /// Sink for progress updates during a programming job. -pub trait ProgressSink { +pub trait ProgressSink: Send { /// Report a step transition. fn step(&mut self, step: &str); /// Report progress 0..=100, when meaningful. diff --git a/crates/wp-core/src/request.rs b/crates/wp-core/src/request.rs index a002a30..845e31a 100644 --- a/crates/wp-core/src/request.rs +++ b/crates/wp-core/src/request.rs @@ -11,9 +11,9 @@ pub struct WifiCredentials<'a> { /// wiThrottle server endpoint. #[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct ThrottleServer { +pub struct ThrottleServer<'a> { /// Hostname or IP. - pub host: &'static str, + pub host: &'a str, /// TCP port. pub port: u16, /// Discover via mDNS instead of a fixed host. @@ -62,7 +62,7 @@ pub struct ProgramRequest<'a> { /// WiFi network the device should join after programming. pub wifi: WifiCredentials<'a>, /// wiThrottle server the device should connect to. - pub server: ThrottleServer, + pub server: ThrottleServer<'a>, /// DCC vehicle list (capped by the driver's `max_roster_slots`). pub roster: Vec>, /// Optional BigFred login+PIN (LongFred and similar). diff --git a/crates/wp-core/src/transport.rs b/crates/wp-core/src/transport.rs index 7ca8d25..ab4795c 100644 --- a/crates/wp-core/src/transport.rs +++ b/crates/wp-core/src/transport.rs @@ -9,7 +9,7 @@ use std::io; /// /// Implementations are expected to be bounded: a deadline, a maximum response /// body size, and a bounded retry count. -pub trait HttpClient { +pub trait HttpClient: Send { /// Issue an HTTP request to `path` (path begins with `/`) and return the body. /// /// `body` is an optional `(content_type, bytes)` pair for methods that @@ -39,7 +39,7 @@ pub trait HttpClient { } /// A bidirectional byte stream for serial devices. -pub trait ByteStream { +pub trait ByteStream: Send { /// Read up to `buf.len()` bytes into `buf`. /// /// # Errors diff --git a/crates/wp-drivers/src/wifred/mod.rs b/crates/wp-drivers/src/wifred/mod.rs index 7f2c5bc..97014f5 100644 --- a/crates/wp-drivers/src/wifred/mod.rs +++ b/crates/wp-drivers/src/wifred/mod.rs @@ -26,7 +26,7 @@ pub use constants::{ Direction, FunctionInfo, CONFIG_AP_PORT, CONFIG_HOST, CONFIG_SOURCE_ADDR, MAX_FUNCTION, MAX_ROSTER_SLOTS, STRUCTURE_VERSION, WIFI_CONFIG_SSID_PREFIX, }; -pub use xml::{DeviceConfig, LocoConfig}; +pub use xml::{parse, DeviceConfig, FunctionEntry, LocoConfig, LocoServerConfig, NetworkConfig}; /// The WiFred driver. #[derive(Debug, Default)] diff --git a/crates/wp-fake/Cargo.toml b/crates/wp-fake/Cargo.toml new file mode 100644 index 0000000..0f5f5fe --- /dev/null +++ b/crates/wp-fake/Cargo.toml @@ -0,0 +1,29 @@ +[package] +name = "wp-fake" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true +repository.workspace = true +description = "Fake radio and Soft-AP HTTP device mocks for wireless-programmer" + +[lib] +name = "wp_fake" +path = "src/lib.rs" + +[dependencies] +wp-core = { path = "../wp-core" } +wp-link = { path = "../wp-link" } +wp-drivers = { path = "../wp-drivers" } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +tokio = { version = "1", features = ["net", "io-util", "sync", "macros", "rt", "time"] } +quick-xml = { version = "0.36", features = ["serialize"] } +log = "0.4" +parking_lot = "0.12" + +[dev-dependencies] +tokio = { version = "1", features = ["macros", "rt", "rt-multi-thread"] } + +[lints] +workspace = true diff --git a/crates/wp-fake/src/composite.rs b/crates/wp-fake/src/composite.rs new file mode 100644 index 0000000..a0118dc --- /dev/null +++ b/crates/wp-fake/src/composite.rs @@ -0,0 +1,47 @@ +//! Composite fake that multiplexes several [`FakeDevice`]s. + +use crate::device::{not_found, FakeDevice, FakeRequest, FakeResponse}; +use crate::longfred::LongFredFake; +use crate::wifred::WifredFake; + +/// Tries each inner device and returns the first non-404 response. +pub struct CompositeFakeDevice { + devices: Vec>, +} + +impl CompositeFakeDevice { + /// Build an empty composite. + #[must_use] + pub fn new(devices: Vec>) -> Self { + Self { devices } + } + + /// WiFred + LongFred mocks. + #[must_use] + pub fn all() -> Self { + Self::new(vec![ + Box::new(WifredFake::new()), + Box::new(LongFredFake::new()), + ]) + } +} + +impl FakeDevice for CompositeFakeDevice { + fn driver_id(&self) -> &'static str { + "composite" + } + + fn handle(&mut self, req: FakeRequest<'_>) -> FakeResponse { + for device in &mut self.devices { + let resp = device.handle(FakeRequest { + method: req.method, + path: req.path, + body: req.body, + }); + if resp.status != 404 { + return resp; + } + } + not_found() + } +} diff --git a/crates/wp-fake/src/device.rs b/crates/wp-fake/src/device.rs new file mode 100644 index 0000000..6b3d483 --- /dev/null +++ b/crates/wp-fake/src/device.rs @@ -0,0 +1,70 @@ +//! Minimal fake Soft-AP HTTP device contract. + +/// An inbound HTTP request presented to a [`FakeDevice`]. +pub struct FakeRequest<'a> { + /// HTTP method (e.g. `"GET"`). + pub method: &'a str, + /// Request path including query string (e.g. `/index.html?loco=1`). + pub path: &'a str, + /// Optional request body. + pub body: Option<&'a [u8]>, +} + +/// An outbound HTTP response from a [`FakeDevice`]. +pub struct FakeResponse { + /// HTTP status code. + pub status: u16, + /// `Content-Type` header value. + pub content_type: &'static str, + /// Response body bytes. + pub body: Vec, +} + +/// Build a `200` text/plain response. +#[must_use] +pub fn ok_text(body: impl Into) -> FakeResponse { + FakeResponse { + status: 200, + content_type: "text/plain", + body: body.into().into_bytes(), + } +} + +/// Build a `200` text/xml (or HTML-compatible) response. +#[must_use] +pub fn ok_xml(body: impl Into>) -> FakeResponse { + FakeResponse { + status: 200, + content_type: "text/html", + body: body.into(), + } +} + +/// Build a `200` application/json response. +#[must_use] +pub fn ok_json(body: impl Into>) -> FakeResponse { + FakeResponse { + status: 200, + content_type: "application/json", + body: body.into(), + } +} + +/// Build a `404` text/plain response. +#[must_use] +pub fn not_found() -> FakeResponse { + FakeResponse { + status: 404, + content_type: "text/plain", + body: b"not found".to_vec(), + } +} + +/// A mock Soft-AP HTTP device. +pub trait FakeDevice: Send { + /// Stable driver id string (`"wifred"`, `"longfred"`, …). + fn driver_id(&self) -> &'static str; + + /// Handle one HTTP request. + fn handle(&mut self, req: FakeRequest<'_>) -> FakeResponse; +} diff --git a/crates/wp-fake/src/lib.rs b/crates/wp-fake/src/lib.rs new file mode 100644 index 0000000..b81cc63 --- /dev/null +++ b/crates/wp-fake/src/lib.rs @@ -0,0 +1,17 @@ +//! Fake radio and Soft-AP HTTP device mocks for wireless-programmer tests. + +#![forbid(unsafe_code)] + +mod composite; +mod device; +mod longfred; +mod radio; +mod server; +mod wifred; + +pub use composite::CompositeFakeDevice; +pub use device::{not_found, ok_json, ok_text, ok_xml, FakeDevice, FakeRequest, FakeResponse}; +pub use longfred::LongFredFake; +pub use radio::FakeRadio; +pub use server::{bind_and_serve, FakeHttpServer}; +pub use wifred::WifredFake; diff --git a/crates/wp-fake/src/longfred.rs b/crates/wp-fake/src/longfred.rs new file mode 100644 index 0000000..cceb440 --- /dev/null +++ b/crates/wp-fake/src/longfred.rs @@ -0,0 +1,200 @@ +//! LongFred Soft-AP HTTP mock. + +use serde_json::{json, Value}; + +use crate::device::{not_found, ok_json, ok_text, FakeDevice, FakeRequest, FakeResponse}; + +/// Fake LongFred programming-mode HTTP device. +pub struct LongFredFake { + /// GET-shaped settings document. + pub settings: Value, + /// Whether programming mode is still active. + pub programming_mode: bool, +} + +impl LongFredFake { + /// Default factory settings in programming mode. + #[must_use] + pub fn new() -> Self { + Self { + settings: json!({ + "wifi": { "hostname": "", "networks": [] }, + "roster": { "mode": "static", "entries": [] }, + "bigfred": { "login": "", "pin_set": false }, + "programming_mode": true + }), + programming_mode: true, + } + } + + fn apply_put(&mut self, body: &Value) { + // wifi.ssid → push into wifi.networks; keep hostname from wifi.hostname + if let Some(wifi) = body.get("wifi") { + if let Some(hostname) = wifi.get("hostname").and_then(Value::as_str) { + if let Some(obj) = self.settings.get_mut("wifi").and_then(Value::as_object_mut) { + obj.insert("hostname".into(), json!(hostname)); + } + } + if let Some(ssid) = wifi.get("ssid").and_then(Value::as_str) { + let networks = self + .settings + .pointer_mut("/wifi/networks") + .and_then(Value::as_array_mut); + if let Some(arr) = networks { + if !arr.iter().any(|n| n.as_str() == Some(ssid)) { + arr.push(json!(ssid)); + } + } + } + } + + if let Some(login) = body.pointer("/bigfred/login").and_then(Value::as_str) { + if let Some(obj) = self.settings.get_mut("bigfred").and_then(Value::as_object_mut) { + obj.insert("login".into(), json!(login)); + obj.insert("pin_set".into(), json!(true)); + } + } + + if let Some(mode) = body.get("roster_mode").and_then(Value::as_str) { + if let Some(obj) = self.settings.get_mut("roster").and_then(Value::as_object_mut) { + obj.insert("mode".into(), json!(mode)); + } + } + + if let Some(roster) = body.get("roster").and_then(Value::as_array) { + if let Some(obj) = self.settings.get_mut("roster").and_then(Value::as_object_mut) { + obj.insert("entries".into(), Value::Array(roster.clone())); + } + } + } +} + +impl Default for LongFredFake { + fn default() -> Self { + Self::new() + } +} + +impl FakeDevice for LongFredFake { + fn driver_id(&self) -> &'static str { + "longfred" + } + + fn handle(&mut self, req: FakeRequest<'_>) -> FakeResponse { + let path = req.path.split('?').next().unwrap_or(req.path); + + match (req.method, path) { + ("GET", "/api/v1/settings") => { + let body = serde_json::to_vec(&self.settings).unwrap_or_default(); + ok_json(body) + } + ("PUT", "/api/v1/settings") => { + let raw = req.body.unwrap_or(b"{}"); + match serde_json::from_slice::(raw) { + Ok(body) => { + self.apply_put(&body); + ok_json(b"{}".to_vec()) + } + Err(_) => FakeResponse { + status: 400, + content_type: "text/plain", + body: b"bad json".to_vec(), + }, + } + } + ("POST", "/api/v1/programming-mode/off") => { + self.programming_mode = false; + if let Some(obj) = self.settings.as_object_mut() { + obj.insert("programming_mode".into(), json!(false)); + } + ok_text("ok") + } + _ => not_found(), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use wp_core::{BigfredCreds, ProgramRequest, RosterEntry, ThrottleServer, WifiCredentials}; + use wp_drivers::longfred::{build_settings_put, verify}; + + fn base_req<'a>() -> ProgramRequest<'a> { + ProgramRequest { + identity: "pilot1", + wifi: WifiCredentials { + ssid: "club-wifi", + psk: Some("secret"), + }, + server: ThrottleServer { + host: "unused.local", + port: 12090, + automatic: false, + }, + roster: vec![ + RosterEntry { + address: Some(3), + long_address: Some(false), + mode: None, + direction: None, + functions: Vec::new(), + }, + RosterEntry { + address: Some(128), + long_address: Some(true), + mode: None, + direction: None, + functions: Vec::new(), + }, + ], + bigfred: Some(BigfredCreds { + login: "ops", + pin: "1234", + }), + roster_mode: Some("static"), + } + } + + #[test] + fn put_to_get_round_trip_verifies() { + let mut fake = LongFredFake::new(); + let req = base_req(); + let put = build_settings_put(&req); + let body = serde_json::to_vec(&put).expect("serialize"); + + let resp = fake.handle(FakeRequest { + method: "PUT", + path: "/api/v1/settings", + body: Some(&body), + }); + assert_eq!(resp.status, 200); + + let get = fake.handle(FakeRequest { + method: "GET", + path: "/api/v1/settings", + body: None, + }); + assert_eq!(get.status, 200); + let settings: Value = serde_json::from_slice(&get.body).expect("json"); + let mismatches = verify(&settings, &req); + assert!( + mismatches.is_empty(), + "expected verify to pass, got {mismatches:?}; settings={settings}" + ); + } + + #[test] + fn programming_mode_off() { + let mut fake = LongFredFake::new(); + assert!(fake.programming_mode); + let resp = fake.handle(FakeRequest { + method: "POST", + path: "/api/v1/programming-mode/off", + body: None, + }); + assert_eq!(resp.status, 200); + assert!(!fake.programming_mode); + assert_eq!(fake.settings["programming_mode"], false); + } +} diff --git a/crates/wp-fake/src/radio.rs b/crates/wp-fake/src/radio.rs new file mode 100644 index 0000000..5950317 --- /dev/null +++ b/crates/wp-fake/src/radio.rs @@ -0,0 +1,85 @@ +//! In-memory [`wp_link::Radio`] for tests. + +use parking_lot::Mutex; +use wp_link::{Radio, RadioFut, ScanResult}; + +/// Fake radio that returns canned scan results and records calls. +pub struct FakeRadio { + scan_results: Vec, + calls: Mutex>, +} + +impl FakeRadio { + /// Construct with explicit scan results. + #[must_use] + pub fn new(results: Vec) -> Self { + Self { + scan_results: results, + calls: Mutex::new(Vec::new()), + } + } + + /// One Soft-AP scan hit per known driver prefix. + #[must_use] + pub fn one_per_driver() -> Self { + Self::new(vec![ + ScanResult { + ssid: Some(format!( + "{}deadbe", + wp_drivers::wifred::WIFI_CONFIG_SSID_PREFIX + )), + bssid: Some("de:ad:be:ef:00:01".into()), + rssi: Some(-42), + }, + ScanResult { + ssid: Some(format!( + "{}_deadbe", + wp_drivers::longfred::WIFI_CONFIG_SSID_PREFIX + )), + bssid: Some("de:ad:be:ef:00:02".into()), + rssi: Some(-42), + }, + ]) + } + + /// Recorded method names (`scan`, `connect_open`, …). + pub fn calls(&self) -> Vec { + self.calls.lock().clone() + } + + fn record(&self, name: &str) { + self.calls.lock().push(name.to_string()); + } +} + +impl Radio for FakeRadio { + fn scan(&mut self, max: usize) -> RadioFut<'_, Vec> { + self.record("scan"); + let results: Vec<_> = self.scan_results.iter().take(max).cloned().collect(); + Box::pin(async move { Ok(results) }) + } + + fn connect_open(&mut self, _ssid: &str, _bssid: Option<[u8; 6]>) -> RadioFut<'_, ()> { + self.record("connect_open"); + Box::pin(async move { Ok(()) }) + } + + fn set_address( + &mut self, + _addr: std::net::Ipv4Addr, + _prefix_len: u8, + ) -> RadioFut<'_, ()> { + self.record("set_address"); + Box::pin(async move { Ok(()) }) + } + + fn link_up(&mut self) -> RadioFut<'_, ()> { + self.record("link_up"); + Box::pin(async move { Ok(()) }) + } + + fn release(&mut self) -> RadioFut<'_, ()> { + self.record("release"); + Box::pin(async move { Ok(()) }) + } +} diff --git a/crates/wp-fake/src/server.rs b/crates/wp-fake/src/server.rs new file mode 100644 index 0000000..145727f --- /dev/null +++ b/crates/wp-fake/src/server.rs @@ -0,0 +1,156 @@ +//! Minimal HTTP/1.1 server for [`FakeDevice`](crate::FakeDevice) mocks. + +use std::io; +use std::net::SocketAddr; +use std::sync::Arc; + +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::net::{TcpListener, TcpStream}; +use tokio::sync::Mutex; + +use crate::device::{FakeDevice, FakeRequest}; + +/// Namespace for the fake Soft-AP HTTP server. +pub struct FakeHttpServer; + +impl FakeHttpServer { + /// Accept connections and serve `device` until the listener fails. + pub async fn serve( + listener: TcpListener, + device: Arc>, + ) -> io::Result<()> { + loop { + let (stream, _) = listener.accept().await?; + let device = Arc::clone(&device); + tokio::spawn(async move { + if let Err(e) = handle_connection(stream, device).await { + log::debug!("wp-fake connection error: {e}"); + } + }); + } + } +} + +/// Bind `addr` (port `0` allowed), log the local address, spawn the accept +/// loop, and return the bound address. +pub async fn bind_and_serve( + addr: SocketAddr, + device: Arc>, +) -> io::Result { + let listener = TcpListener::bind(addr).await?; + let local = listener.local_addr()?; + log::info!("wp-fake listening on {local}"); + let device_clone = Arc::clone(&device); + tokio::spawn(async move { + if let Err(e) = FakeHttpServer::serve(listener, device_clone).await { + log::error!("wp-fake server stopped: {e}"); + } + }); + Ok(local) +} + +async fn handle_connection( + mut stream: TcpStream, + device: Arc>, +) -> io::Result<()> { + let mut buf = Vec::with_capacity(1024); + let header_end = loop { + let mut chunk = [0u8; 512]; + let n = stream.read(&mut chunk).await?; + if n == 0 { + return Ok(()); + } + buf.extend_from_slice(&chunk[..n]); + if let Some(pos) = find_header_end(&buf) { + break pos; + } + if buf.len() > 64 * 1024 { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "HTTP headers too large", + )); + } + }; + + let header = std::str::from_utf8(&buf[..header_end]) + .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?; + let (method, path, content_length) = parse_request_line_and_headers(header)?; + + let body_start = header_end + 4; + while buf.len() < body_start + content_length { + let mut chunk = [0u8; 512]; + let n = stream.read(&mut chunk).await?; + if n == 0 { + break; + } + buf.extend_from_slice(&chunk[..n]); + } + + let body = if content_length > 0 { + Some(&buf[body_start..body_start + content_length.min(buf.len() - body_start)]) + } else { + None + }; + + let response = { + let mut guard = device.lock().await; + guard.handle(FakeRequest { + method: &method, + path: &path, + body, + }) + }; + + write_response(&mut stream, &response).await +} + +fn find_header_end(buf: &[u8]) -> Option { + buf.windows(4).position(|w| w == b"\r\n\r\n") +} + +fn parse_request_line_and_headers(header: &str) -> io::Result<(String, String, usize)> { + let mut lines = header.split("\r\n"); + let request_line = lines + .next() + .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "empty request"))?; + let mut parts = request_line.split_whitespace(); + let method = parts + .next() + .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "missing method"))? + .to_string(); + let path = parts + .next() + .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "missing path"))? + .to_string(); + // HTTP version ignored. + + let mut content_length = 0usize; + for line in lines { + let lower = line.to_ascii_lowercase(); + if let Some(rest) = lower.strip_prefix("content-length:") { + content_length = rest.trim().parse().unwrap_or(0); + } + } + Ok((method, path, content_length)) +} + +async fn write_response( + stream: &mut TcpStream, + response: &crate::device::FakeResponse, +) -> io::Result<()> { + let reason = match response.status { + 200 => "OK", + 404 => "Not Found", + _ => "Error", + }; + let head = format!( + "HTTP/1.1 {} {}\r\nContent-Type: {}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", + response.status, + reason, + response.content_type, + response.body.len() + ); + stream.write_all(head.as_bytes()).await?; + stream.write_all(&response.body).await?; + stream.flush().await +} diff --git a/crates/wp-fake/src/wifred.rs b/crates/wp-fake/src/wifred.rs new file mode 100644 index 0000000..096bae5 --- /dev/null +++ b/crates/wp-fake/src/wifred.rs @@ -0,0 +1,456 @@ +//! WiFred Soft-AP HTTP mock. + +use wp_drivers::wifred::{DeviceConfig, FunctionEntry, LocoConfig, NetworkConfig}; + +use crate::device::{not_found, ok_text, ok_xml, FakeDevice, FakeRequest, FakeResponse}; + +/// Fake WiFred config-mode HTTP device. +pub struct WifredFake { + /// Current device configuration (GET XML shape). + pub cfg: DeviceConfig, + /// Set when `/restart.html` is hit. + pub restarted: bool, + /// Active loco slot index (0-based) from `loco=N`. + pub active_loco: Option, +} + +impl WifredFake { + /// Default factory state: structure version 1, four empty loco slots. + #[must_use] + pub fn new() -> Self { + Self { + cfg: DeviceConfig { + structure_version: Some("1".into()), + throttle_name: None, + firmware_revision: None, + battery_mv: None, + locos: (0..4) + .map(|_| LocoConfig { + address: -1, + ..Default::default() + }) + .collect(), + networks: Vec::new(), + loco_server: None, + }, + restarted: false, + active_loco: None, + } + } + + /// Serialize `cfg` to WiFred-compatible XML. + #[must_use] + pub fn serialize_xml(&self) -> Vec { + serialize_xml(&self.cfg) + } + + fn apply_query(&mut self, query: &str) { + let mut pending_ssid: Option = None; + let mut pending_key: Option = None; + + for pair in query.split('&') { + if pair.is_empty() { + continue; + } + let (raw_key, raw_val) = match pair.split_once('=') { + Some((k, v)) => (k, v), + None => (pair, ""), + }; + let key = percent_decode(raw_key); + let value = percent_decode(raw_val); + + match key.as_str() { + "throttleName" => { + self.cfg.throttle_name = Some(value); + } + "loco" => { + if let Ok(n) = value.parse::() { + if n >= 1 { + let idx = n - 1; + while self.cfg.locos.len() <= idx { + self.cfg.locos.push(LocoConfig { + address: -1, + ..Default::default() + }); + } + self.active_loco = Some(idx); + } + } + } + "loco.address" => { + if let Some(loco) = self.active_loco_mut() { + loco.address = value.parse().unwrap_or(-1); + } + } + "loco.mode" => { + if let Some(loco) = self.active_loco_mut() { + loco.mode = Some(value); + } + } + "loco.direction" => { + if let Some(loco) = self.active_loco_mut() { + loco.direction = value.parse().ok(); + } + } + "loco.longAddress" => { + if value == "on" { + if let Some(loco) = self.active_loco_mut() { + loco.long_address = Some(true); + } + } + } + "loco.serverName" => { + self.cfg + .loco_server + .get_or_insert_with(Default::default) + .name = value; + } + "loco.serverPort" => { + let port = value.parse().unwrap_or(0); + self.cfg + .loco_server + .get_or_insert_with(Default::default) + .port = port; + } + "loco.automatic" => { + if value == "on" { + self.cfg + .loco_server + .get_or_insert_with(Default::default) + .automatic = true; + } + } + "remove" => { + self.cfg.networks.retain(|n| n.ssid != value); + } + "wifiSSID" => { + pending_ssid = Some(value); + } + "wifiKEY" => { + pending_key = Some(value); + } + other if is_function_key(other) => { + let index: u8 = other[1..].parse().unwrap_or(0); + let fval: u8 = value.parse().unwrap_or(0); + if let Some(loco) = self.active_loco_mut() { + if let Some(existing) = + loco.functions.iter_mut().find(|f| f.index == index) + { + existing.value = fval; + } else { + loco.functions.push(FunctionEntry { + index, + value: fval, + }); + } + } + } + _ => {} + } + } + + if let Some(ssid) = pending_ssid { + upsert_network(&mut self.cfg.networks, ssid, pending_key); + } + } + + fn active_loco_mut(&mut self) -> Option<&mut LocoConfig> { + let idx = self.active_loco?; + self.cfg.locos.get_mut(idx) + } +} + +impl Default for WifredFake { + fn default() -> Self { + Self::new() + } +} + +impl FakeDevice for WifredFake { + fn driver_id(&self) -> &'static str { + "wifred" + } + + fn handle(&mut self, req: FakeRequest<'_>) -> FakeResponse { + if req.method != "GET" { + return not_found(); + } + let path = req.path; + if path.starts_with("/api/getConfigXML") { + return ok_xml(self.serialize_xml()); + } + if path.starts_with("/restart.html") { + self.restarted = true; + return ok_text("ok"); + } + if path.starts_with("/flashred.html") { + return ok_text("flash"); + } + if path.starts_with("/index.html") { + if let Some(q) = path.split_once('?').map(|(_, q)| q) { + self.apply_query(q); + } + return ok_text("ok"); + } + not_found() + } +} + +fn is_function_key(key: &str) -> bool { + let mut chars = key.chars(); + if chars.next() != Some('f') { + return false; + } + let rest: String = chars.collect(); + !rest.is_empty() && rest.chars().all(|c| c.is_ascii_digit()) +} + +fn upsert_network(networks: &mut Vec, ssid: String, key: Option) { + if let Some(existing) = networks.iter_mut().find(|n| n.ssid == ssid) { + existing.enabled = true; + if key.is_some() { + existing.key = key; + } + } else { + networks.push(NetworkConfig { + ssid, + key, + enabled: true, + }); + } +} + +fn percent_decode(input: &str) -> String { + let bytes = input.as_bytes(); + let mut out = Vec::with_capacity(bytes.len()); + let mut i = 0; + while i < bytes.len() { + match bytes[i] { + b'+' => { + out.push(b' '); + i += 1; + } + b'%' if i + 2 < bytes.len() => { + let h1 = from_hex(bytes[i + 1]); + let h2 = from_hex(bytes[i + 2]); + if let (Some(a), Some(b)) = (h1, h2) { + out.push((a << 4) | b); + i += 3; + } else { + out.push(bytes[i]); + i += 1; + } + } + c => { + out.push(c); + i += 1; + } + } + } + String::from_utf8_lossy(&out).into_owned() +} + +fn from_hex(b: u8) -> Option { + match b { + b'0'..=b'9' => Some(b - b'0'), + b'a'..=b'f' => Some(b - b'a' + 10), + b'A'..=b'F' => Some(b - b'A' + 10), + _ => None, + } +} + +/// Emit XML tags that [`wp_drivers::wifred::parse`] understands. +pub fn serialize_xml(cfg: &DeviceConfig) -> Vec { + let mut s = String::from("\n\n"); + if let Some(v) = &cfg.structure_version { + push_empty(&mut s, "structurVersion", v); + } + if let Some(v) = &cfg.throttle_name { + push_empty(&mut s, "throttleName", v); + } + if let Some(v) = &cfg.firmware_revision { + push_empty(&mut s, "firmwareRevision", v); + } + if let Some(mv) = cfg.battery_mv { + push_empty(&mut s, "batteryVoltage", &mv.to_string()); + } + + s.push_str("\n"); + for (i, loco) in cfg.locos.iter().enumerate() { + let id = loco.id.unwrap_or((i + 1) as u8); + s.push_str(&format!(" \n")); + push_empty(&mut s, "DCCadress", &loco.address.to_string()); + if let Some(mode) = &loco.mode { + push_empty(&mut s, "Mode", mode); + } else { + push_empty(&mut s, "Mode", ""); + } + if let Some(dir) = loco.direction { + push_empty(&mut s, "Direction", &dir.to_string()); + } + if let Some(long) = loco.long_address { + push_empty(&mut s, "LongAdress", if long { "1" } else { "0" }); + } + s.push_str(" \n"); + for f in &loco.functions { + s.push_str(&format!( + " \n", + f.index, f.value + )); + } + s.push_str(" \n"); + s.push_str(" \n"); + } + s.push_str("\n"); + + s.push_str("\n"); + for net in &cfg.networks { + s.push_str(" \n"); + push_empty(&mut s, "SSID", &net.ssid); + if let Some(key) = &net.key { + push_empty(&mut s, "Key", key); + } + push_empty(&mut s, "Enabled", if net.enabled { "1" } else { "0" }); + s.push_str(" \n"); + } + s.push_str("\n"); + + if let Some(srv) = &cfg.loco_server { + s.push_str("\n"); + push_empty(&mut s, "ServerName", &srv.name); + push_empty(&mut s, "Port", &srv.port.to_string()); + push_empty(&mut s, "Automatic", if srv.automatic { "1" } else { "0" }); + s.push_str("\n"); + } + + s.push_str("\n"); + s.into_bytes() +} + +fn push_empty(out: &mut String, tag: &str, value: &str) { + out.push_str(&format!("<{tag} value=\"{}\"/>\n", xml_escape(value))); +} + +fn xml_escape(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + for c in s.chars() { + match c { + '&' => out.push_str("&"), + '<' => out.push_str("<"), + '>' => out.push_str(">"), + '"' => out.push_str("""), + '\'' => out.push_str("'"), + _ => out.push(c), + } + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + use wp_drivers::wifred::{parse, LocoServerConfig}; + + #[test] + fn serialize_round_trip() { + let mut fake = WifredFake::new(); + fake.cfg.throttle_name = Some("122145".into()); + fake.cfg.firmware_revision = Some("2022-10-16".into()); + fake.cfg.battery_mv = Some(3850); + fake.cfg.locos[0] = LocoConfig { + id: Some(1), + address: 3, + mode: Some("128".into()), + direction: Some(0), + long_address: Some(false), + functions: vec![ + FunctionEntry { index: 0, value: 0 }, + FunctionEntry { index: 1, value: 4 }, + ], + }; + fake.cfg.networks.push(NetworkConfig { + ssid: "bigfred2".into(), + key: Some("secret-pass".into()), + enabled: true, + }); + fake.cfg.loco_server = Some(LocoServerConfig { + name: "bigfred.local".into(), + port: 12090, + automatic: false, + }); + + let xml = fake.serialize_xml(); + let parsed = parse(&xml).expect("parse"); + assert_eq!(parsed.structure_version.as_deref(), Some("1")); + assert_eq!(parsed.throttle_name.as_deref(), Some("122145")); + assert_eq!(parsed.battery_mv, Some(3850)); + assert_eq!(parsed.locos[0].address, 3); + assert_eq!(parsed.locos[0].mode.as_deref(), Some("128")); + assert_eq!(parsed.locos[0].functions.len(), 2); + assert_eq!(parsed.networks[0].ssid, "bigfred2"); + assert_eq!(parsed.networks[0].key.as_deref(), Some("secret-pass")); + let srv = parsed.loco_server.expect("server"); + assert_eq!(srv.name, "bigfred.local"); + assert_eq!(srv.port, 12090); + } + + #[test] + fn program_sequence_mutations() { + let mut fake = WifredFake::new(); + let _ = fake.handle(FakeRequest { + method: "GET", + path: "/index.html?throttleName=pilot1", + body: None, + }); + let _ = fake.handle(FakeRequest { + method: "GET", + path: "/index.html?loco=1&loco.address=3&loco.mode=128&loco.direction=0&loco.longAddress=on", + body: None, + }); + let _ = fake.handle(FakeRequest { + method: "GET", + path: "/index.html?loco=1&f0=0&f1=4", + body: None, + }); + let _ = fake.handle(FakeRequest { + method: "GET", + path: "/index.html?loco.serverName=bigfred.local&loco.serverPort=12090", + body: None, + }); + let _ = fake.handle(FakeRequest { + method: "GET", + path: "/index.html?wifiSSID=club-wifi&wifiKEY=secret", + body: None, + }); + let _ = fake.handle(FakeRequest { + method: "GET", + path: "/restart.html", + body: None, + }); + + assert_eq!(fake.cfg.throttle_name.as_deref(), Some("pilot1")); + assert_eq!(fake.cfg.locos[0].address, 3); + assert_eq!(fake.cfg.locos[0].mode.as_deref(), Some("128")); + assert_eq!(fake.cfg.locos[0].long_address, Some(true)); + assert_eq!(fake.cfg.locos[0].functions.len(), 2); + assert_eq!( + fake.cfg.loco_server.as_ref().map(|s| s.name.as_str()), + Some("bigfred.local") + ); + assert_eq!(fake.cfg.networks.len(), 1); + assert_eq!(fake.cfg.networks[0].ssid, "club-wifi"); + assert!(fake.restarted); + + let xml = fake.serialize_xml(); + let parsed = parse(&xml).expect("parse"); + assert_eq!(parsed.throttle_name.as_deref(), Some("pilot1")); + assert_eq!(parsed.locos[0].address, 3); + assert!(parsed.networks.iter().any(|n| n.ssid == "club-wifi")); + } + + #[test] + fn percent_decode_plus_and_hex() { + assert_eq!(percent_decode("a+b%20c"), "a b c"); + assert_eq!(percent_decode("bigfred%2Elocal"), "bigfred.local"); + } +} diff --git a/crates/wp-link/src/lib.rs b/crates/wp-link/src/lib.rs index 6802cd3..4d00346 100644 --- a/crates/wp-link/src/lib.rs +++ b/crates/wp-link/src/lib.rs @@ -9,7 +9,7 @@ pub mod rfkill; pub use http::{percent_encode, BoundedHttpClient, MAX_BODY_BYTES}; pub use radio::{ - first_wireless_interface, is_wireless_interface, resolve_wireless_interface, Nl80211Radio, - Radio, ScanResult, + first_wireless_interface, is_wireless_interface, parse_bss_infos, parse_scan_attrs, + resolve_wireless_interface, Nl80211Radio, Radio, RadioFut, ScanResult, }; pub use rfkill::{aggregate_state, RfkillState}; diff --git a/crates/wp-link/src/radio.rs b/crates/wp-link/src/radio.rs index 5ca669d..013bf3c 100644 --- a/crates/wp-link/src/radio.rs +++ b/crates/wp-link/src/radio.rs @@ -6,7 +6,9 @@ //! [`wp_core::HttpClient`] to the driver. On every exit path the radio is //! released: disconnect and address removal. +use std::future::Future; use std::path::Path; +use std::pin::Pin; use wp_core::DriverError; @@ -21,33 +23,33 @@ pub struct ScanResult { pub rssi: Option, } +/// Boxed future returned by [`Radio`] methods (dyn-compatible). +pub type RadioFut<'a, T> = + Pin> + Send + 'a>>; + /// The async radio contract. Implementations use nl80211 + rtnetlink. -pub trait Radio { +/// +/// Methods return boxed futures so the trait is dyn-compatible +/// (`Box` in the daemon runtime). +pub trait Radio: Send { /// Trigger a scan and return up to `max` results. - fn scan( - &mut self, - max: usize, - ) -> impl std::future::Future, DriverError>>; + fn scan(&mut self, max: usize) -> RadioFut<'_, Vec>; /// Associate to an open AP identified by SSID (and optional BSSID hint). - fn connect_open( - &mut self, - ssid: &str, - bssid: Option<[u8; 6]>, - ) -> impl std::future::Future>; + fn connect_open(&mut self, ssid: &str, bssid: Option<[u8; 6]>) -> RadioFut<'_, ()>; /// Assign `addr/prefix_len` to the wireless interface (on-link route only). fn set_address( &mut self, addr: std::net::Ipv4Addr, prefix_len: u8, - ) -> impl std::future::Future>; + ) -> RadioFut<'_, ()>; /// Bring the link up. - fn link_up(&mut self) -> impl std::future::Future>; + fn link_up(&mut self) -> RadioFut<'_, ()>; /// Disconnect and remove the assigned address, releasing the radio. - fn release(&mut self) -> impl std::future::Future>; + fn release(&mut self) -> RadioFut<'_, ()>; } /// Select the first wireless interface by scanning `/sys/class/net/*/wireless`. @@ -130,15 +132,83 @@ fn interface_index(name: &str) -> Result { .map_err(|e| DriverError::Other(format!("bad ifindex for {name}: {e}"))) } +/// Format a MAC as lowercase colon-separated hex. +fn format_bssid(mac: &[u8; 6]) -> String { + format!( + "{:02x}:{:02x}:{:02x}:{:02x}:{:02x}:{:02x}", + mac[0], mac[1], mac[2], mac[3], mac[4], mac[5] + ) +} + +/// Extract an SSID from raw 802.11 information elements (TLV: id, len, data). +fn ssid_from_ies(ies: &[u8]) -> Option { + let mut i = 0; + while i + 1 < ies.len() { + let id = ies[i]; + let len = usize::from(ies[i + 1]); + if i + 2 + len > ies.len() { + break; + } + if id == 0 { + let bytes = &ies[i + 2..i + 2 + len]; + if bytes.is_empty() { + return None; + } + return Some(String::from_utf8_lossy(bytes).into_owned()); + } + i += 2 + len; + } + None +} + +/// Parse one BSS info vector into a [`ScanResult`]. +pub fn parse_bss_infos(bss: &[wl_nl80211::Nl80211BssInfo]) -> Option { + use wl_nl80211::Nl80211BssInfo; + + let mut ssid = None; + let mut bssid = None; + let mut rssi = None; + + for info in bss { + match info { + Nl80211BssInfo::Bssid(mac) => { + bssid = Some(format_bssid(mac)); + } + Nl80211BssInfo::SignalMbm(mbm) => { + rssi = Some(mbm / 100); + } + Nl80211BssInfo::RawInformationElements(ies) + | Nl80211BssInfo::RawBeaconInformationElements(ies) + | Nl80211BssInfo::RawProbeResponseInformationElements(ies) + if ssid.is_none() => + { + ssid = ssid_from_ies(ies); + } + _ => {} + } + } + + if ssid.is_none() && bssid.is_none() { + return None; + } + Some(ScanResult { ssid, bssid, rssi }) +} + +/// Parse a dump message's attributes into a [`ScanResult`]. +pub fn parse_scan_attrs(attrs: &[wl_nl80211::Nl80211Attr]) -> Option { + for attr in attrs { + if let wl_nl80211::Nl80211Attr::Bss(bss) = attr { + return parse_bss_infos(bss); + } + } + None +} + /// `wl-nl80211` + `rtnetlink` backed radio. /// /// Construct with [`Nl80211Radio::new`] or [`Nl80211Radio::with_interface`]; /// requires `CAP_NET_ADMIN` and `CAP_NET_RAW`. All operations are async and /// run on a tokio runtime. -/// -/// The nl80211 scan/connect and rtnetlink addressing paths require a real -/// wireless adapter to exercise end-to-end; they are kept minimal here and -/// documented for hardware validation. pub struct Nl80211Radio { iface: String, if_index: u32, @@ -182,123 +252,139 @@ impl Nl80211Radio { } impl Radio for Nl80211Radio { - async fn scan(&mut self, max: usize) -> Result, DriverError> { - use futures::stream::TryStreamExt; - use wl_nl80211::Nl80211Scan; - - let (connection, handle, _) = wl_nl80211::new_connection() - .map_err(|e| DriverError::Other(format!("nl80211 connection: {e}")))?; - tokio::spawn(connection); - - // Trigger a passive scan, then dump the cached results. - let attrs = Nl80211Scan::new(self.if_index).passive(true).build(); - let mut trigger = handle.scan().trigger(attrs).execute().await; - while trigger.try_next().await.is_ok() { - // drain acks - } - // Give the kernel a moment to populate the cache. - tokio::time::sleep(std::time::Duration::from_millis(500)).await; - - let mut dump = handle.scan().dump(self.if_index).execute().await; - let results = Vec::new(); - while let Ok(_msg) = dump.try_next().await { - if results.len() >= max { - break; + fn scan(&mut self, max: usize) -> RadioFut<'_, Vec> { + let if_index = self.if_index; + Box::pin(async move { + use futures::stream::TryStreamExt; + use wl_nl80211::Nl80211Scan; + + let (connection, handle, _) = wl_nl80211::new_connection() + .map_err(|e| DriverError::Other(format!("nl80211 connection: {e}")))?; + tokio::spawn(connection); + + // Trigger a passive scan, then dump the cached results. + let attrs = Nl80211Scan::new(if_index).passive(true).build(); + let mut trigger = handle.scan().trigger(attrs).execute().await; + while trigger.try_next().await.is_ok() { + // drain acks } - } - Ok(results) + // Give the kernel a moment to populate the cache. + tokio::time::sleep(std::time::Duration::from_millis(500)).await; + + let mut dump = handle.scan().dump(if_index).execute().await; + let mut results = Vec::new(); + while let Ok(Some(msg)) = dump.try_next().await { + if results.len() >= max { + break; + } + if let Some(r) = parse_scan_attrs(&msg.payload.attributes) { + results.push(r); + } + } + Ok(results) + }) } - async fn connect_open( - &mut self, - ssid: &str, - bssid: Option<[u8; 6]>, - ) -> Result<(), DriverError> { - use futures::stream::TryStreamExt; - use wl_nl80211::{Nl80211AuthType, Nl80211Connect}; - - let (connection, handle, _) = wl_nl80211::new_connection() - .map_err(|e| DriverError::Other(format!("nl80211 connection: {e}")))?; - tokio::spawn(connection); - - let mut builder = Nl80211Connect::new(self.if_index) - .ssid(ssid) - .auth_type(Nl80211AuthType::OpenSystem) - .privacy(false); - if let Some(mac) = bssid { - builder = builder.mac(mac); - } - let attrs = builder.build(); + fn connect_open(&mut self, ssid: &str, bssid: Option<[u8; 6]>) -> RadioFut<'_, ()> { + let if_index = self.if_index; + let ssid = ssid.to_string(); + Box::pin(async move { + use futures::stream::TryStreamExt; + use wl_nl80211::{Nl80211AuthType, Nl80211Connect}; - let mut stream = handle.connection().connect(attrs).execute().await; - while stream.try_next().await.is_ok() { - // drain acks - } - Ok(()) + let (connection, handle, _) = wl_nl80211::new_connection() + .map_err(|e| DriverError::Other(format!("nl80211 connection: {e}")))?; + tokio::spawn(connection); + + let mut builder = Nl80211Connect::new(if_index) + .ssid(&ssid) + .auth_type(Nl80211AuthType::OpenSystem) + .privacy(false); + if let Some(mac) = bssid { + builder = builder.mac(mac); + } + let attrs = builder.build(); + + let mut stream = handle.connection().connect(attrs).execute().await; + while stream.try_next().await.is_ok() { + // drain acks + } + Ok(()) + }) } - async fn set_address( + fn set_address( &mut self, addr: std::net::Ipv4Addr, prefix_len: u8, - ) -> Result<(), DriverError> { - use rtnetlink::new_connection; - - let (connection, handle, _) = new_connection() - .map_err(|e| DriverError::Other(format!("rtnetlink connection: {e}")))?; - tokio::spawn(connection); - - handle - .address() - .add(self.if_index, std::net::IpAddr::V4(addr), prefix_len) - .execute() - .await - .map_err(|e| DriverError::Other(format!("address add: {e}"))) - } + ) -> RadioFut<'_, ()> { + let if_index = self.if_index; + Box::pin(async move { + use rtnetlink::new_connection; - async fn link_up(&mut self) -> Result<(), DriverError> { - use rtnetlink::{new_connection, LinkUnspec}; - - let (connection, handle, _) = new_connection() - .map_err(|e| DriverError::Other(format!("rtnetlink connection: {e}")))?; - tokio::spawn(connection); - let msg = LinkUnspec::new_with_index(self.if_index).up().build(); - handle - .link() - .set(msg) - .execute() - .await - .map_err(|e| DriverError::Other(format!("link up: {e}"))) + let (connection, handle, _) = new_connection() + .map_err(|e| DriverError::Other(format!("rtnetlink connection: {e}")))?; + tokio::spawn(connection); + + handle + .address() + .add(if_index, std::net::IpAddr::V4(addr), prefix_len) + .execute() + .await + .map_err(|e| DriverError::Other(format!("address add: {e}"))) + }) } - async fn release(&mut self) -> Result<(), DriverError> { - use futures::stream::TryStreamExt; - use wl_nl80211::Nl80211Disconnect; + fn link_up(&mut self) -> RadioFut<'_, ()> { + let if_index = self.if_index; + Box::pin(async move { + use rtnetlink::{new_connection, LinkUnspec}; - // Best-effort disconnect; report only hard failures. - if let Ok((connection, handle, _)) = wl_nl80211::new_connection() { + let (connection, handle, _) = new_connection() + .map_err(|e| DriverError::Other(format!("rtnetlink connection: {e}")))?; tokio::spawn(connection); - let attrs = Nl80211Disconnect::new(self.if_index).build(); - let mut stream = handle.connection().disconnect(attrs).execute().await; - let _ = stream.try_next().await; - } + let msg = LinkUnspec::new_with_index(if_index).up().build(); + handle + .link() + .set(msg) + .execute() + .await + .map_err(|e| DriverError::Other(format!("link up: {e}"))) + }) + } - // Best-effort link down; the interface staying up is harmless (no - // default route, no address left after the kernel clears it on - // disconnect), but bringing it down is tidy. - use rtnetlink::{new_connection, LinkUnspec}; - if let Ok((connection, handle, _)) = new_connection() { - tokio::spawn(connection); - let msg = LinkUnspec::new_with_index(self.if_index).down().build(); - let _ = handle.link().set(msg).execute().await; - } - Ok(()) + fn release(&mut self) -> RadioFut<'_, ()> { + let if_index = self.if_index; + Box::pin(async move { + use futures::stream::TryStreamExt; + use wl_nl80211::Nl80211Disconnect; + + // Best-effort disconnect; report only hard failures. + if let Ok((connection, handle, _)) = wl_nl80211::new_connection() { + tokio::spawn(connection); + let attrs = Nl80211Disconnect::new(if_index).build(); + let mut stream = handle.connection().disconnect(attrs).execute().await; + let _ = stream.try_next().await; + } + + // Best-effort link down; the interface staying up is harmless (no + // default route, no address left after the kernel clears it on + // disconnect), but bringing it down is tidy. + use rtnetlink::{new_connection, LinkUnspec}; + if let Ok((connection, handle, _)) = new_connection() { + tokio::spawn(connection); + let msg = LinkUnspec::new_with_index(if_index).down().build(); + let _ = handle.link().set(msg).execute().await; + } + Ok(()) + }) } } #[cfg(test)] mod tests { use super::*; + use wl_nl80211::Nl80211BssInfo; #[test] fn resolve_rejects_empty_preferred() { @@ -346,4 +432,43 @@ mod tests { assert_eq!(resolved, first); assert!(is_wireless_interface(&first)); } + + #[test] + fn ssid_from_ies_reads_test_wifi() { + // IE: id=0, len=9, "Test-WIFI" + let ies = [ + 0u8, 9, b'T', b'e', b's', b't', b'-', b'W', b'I', b'F', b'I', 1, 8, 130, 132, 139, + 150, 12, 18, 24, 36, + ]; + assert_eq!(ssid_from_ies(&ies).as_deref(), Some("Test-WIFI")); + } + + #[test] + fn parse_bss_infos_from_fixture() { + let bss = vec![ + Nl80211BssInfo::Bssid([214, 178, 106, 168, 188, 177]), + Nl80211BssInfo::RawInformationElements(vec![ + 0, 9, 84, 101, 115, 116, 45, 87, 73, 70, 73, 1, 8, 130, 132, 139, 150, 12, 18, 24, + 36, + ]), + Nl80211BssInfo::SignalMbm(-3000), + ]; + let r = parse_bss_infos(&bss).expect("parsed"); + assert_eq!(r.ssid.as_deref(), Some("Test-WIFI")); + assert_eq!(r.bssid.as_deref(), Some("d6:b2:6a:a8:bc:b1")); + assert_eq!(r.rssi, Some(-30)); + } + + #[test] + fn parse_scan_attrs_finds_bss() { + let attrs = vec![wl_nl80211::Nl80211Attr::Bss(vec![ + Nl80211BssInfo::Bssid([0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0xff]), + Nl80211BssInfo::RawInformationElements(vec![0, 4, b't', b'e', b's', b't']), + Nl80211BssInfo::SignalMbm(-5500), + ])]; + let r = parse_scan_attrs(&attrs).expect("parsed"); + assert_eq!(r.ssid.as_deref(), Some("test")); + assert_eq!(r.bssid.as_deref(), Some("aa:bb:cc:dd:ee:ff")); + assert_eq!(r.rssi, Some(-55)); + } } diff --git a/docs/api.md b/docs/api.md index b6cb389..0e2aad1 100644 --- a/docs/api.md +++ b/docs/api.md @@ -125,22 +125,27 @@ throttle. For WiFred this maps to `GET /flashred.html?count=N`. ## Permissions -The socket is `0660` and peer credentials are checked via `SO_PEERCRED` -against an allowlist (default `bigfred`, `bigfred-wizard`). Override the -allowlist with `WIRELESS_PROGRAMMER_ALLOW_USERS` (comma-separated login -names, replaces the default). Only those users may issue commands. - -The allowlist is only reachable if the socket has a group the peers belong -to: with `0660` and no group owner, a non-root client is refused with -`EACCES` at `connect(2)`, before the daemon can inspect its credentials. So -after binding, the daemon chowns the socket to the primary group of the first -allowlist entry — on BigFred OS that makes it `root:bigfred 0660`, which the -`bigfred` service can open. `WIRELESS_PROGRAMMER_SOCKET_GROUP_USER` selects a -different login name whose primary group should own it. When the user cannot -be resolved, or the daemon lacks the privilege to chown, it warns and leaves -the socket owner-only rather than refusing to start; this keeps a -non-privileged development run usable, and the warning is the signal that -peers will not get in. +Peer authentication is **off by default**. The socket is then `0666` and any +local process may connect — convenient for development (`make dev`). + +Enable authentication with `--require-auth` or +`WIRELESS_PROGRAMMER_REQUIRE_AUTH=1`. Then the socket is `0660` and peer +credentials are checked via `SO_PEERCRED` against an allowlist (default +`bigfred`, `bigfred-wizard`). Override the allowlist with `--allow-users` +or `WIRELESS_PROGRAMMER_ALLOW_USERS` (comma-separated login names). Only +those users may issue commands. + +With auth on, the allowlist is only reachable if the socket has a group the +peers belong to: with `0660` and no group owner, a non-root client is refused +with `EACCES` at `connect(2)`, before the daemon can inspect its credentials. +So after binding, the daemon chowns the socket to the primary group of the +first allowlist entry — on BigFred OS that makes it `root:bigfred 0660`, which +the `bigfred` service can open. `WIRELESS_PROGRAMMER_SOCKET_GROUP_USER` +selects a different login name whose primary group should own it. When the +user cannot be resolved, or the daemon lacks the privilege to chown, it +warns and leaves the socket owner-only rather than refusing to start; this +keeps a non-privileged development run usable, and the warning is the signal +that peers will not get in. ## CLI diff --git a/docs/cli.md b/docs/cli.md index 8743f41..cb0e5cd 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -16,15 +16,32 @@ Commands: link-status Report radio/link state hello Exchange version + driver capabilities job Inspect or control a running job + fake Standalone Soft-AP HTTP mock for one driver (no daemon) Options: --socket Override the daemon socket path (every subcommand) - -i, --interface Wireless interface for the daemon (e.g. wlan0) + -i, --interface Wireless interface for the daemon (e.g. wlan0); + use `fake` for in-process FakeRadio + Soft-AP mock + --require-auth Enforce SO_PEERCRED allowlist (daemon only; off by default) + --allow-users Comma-separated allowlist (implies --require-auth) -v, --verbose Verbose logging (daemon only) -h, --help Print help -V, --version Print version ``` +### `daemon --interface fake` + +Runs the full IPC daemon with `FakeRadio` (scan returns one WiFred and one +LongFred candidate) and an in-process Soft-AP HTTP mock on +`127.0.0.1:` (default port 8070; override with +`--fake-webserver-port` / `WIRELESS_PROGRAMMER_FAKE_WEB_PORT`). Peer auth is +forced off. Useful for developing `bigfred-wizard` without WiFi hardware. + +### `fake --driver wifred|longfred` + +Starts **only** the Soft-AP HTTP mock for the chosen driver (no radio, no +IPC). Default bind `127.0.0.1:8070`. + ## Socket resolution Client subcommands connect to the daemon socket, resolved in this order: @@ -34,18 +51,21 @@ Client subcommands connect to the daemon socket, resolved in this order: 3. `$DATA_DIR/run/wireless-programmer/wireless-programmer.sock`; 4. `/data/run/wireless-programmer/wireless-programmer.sock`. -The daemon creates the parent directory and binds the socket with mode -`0660`. Peers are checked via `SO_PEERCRED` against an allowlist (default -`bigfred`, `bigfred-wizard`); override it with -`WIRELESS_PROGRAMMER_ALLOW_USERS=alice,bob` (comma-separated login names). - -Because the mode is `0660`, the socket also needs a group owner, or a -non-root client is refused by the filesystem before `SO_PEERCRED` is ever -consulted. On startup the daemon chowns the socket to the primary group of -the first allowlist entry (so `bigfred` by default); set +The daemon creates the parent directory and binds the socket. **Peer +authentication is off by default**: the socket is `0666` and any local +process may connect. Enable auth with `--require-auth` or +`WIRELESS_PROGRAMMER_REQUIRE_AUTH=1`; then the socket is `0660` and peers +are checked via `SO_PEERCRED` against an allowlist (default `bigfred`, +`bigfred-wizard`, override with `--allow-users` / +`WIRELESS_PROGRAMMER_ALLOW_USERS`). + +When auth is on, the socket also needs a group owner, or a non-root client +is refused by the filesystem before `SO_PEERCRED` is ever consulted. On +startup the daemon chowns the socket to the primary group of the first +allowlist entry (so `bigfred` by default); set `WIRELESS_PROGRAMMER_SOCKET_GROUP_USER` to choose a different login name whose primary group should own it. If that user does not exist, or the -daemon is not privileged enough to chown, it logs a warning and leaves the +daemon is not privileged enough to chown, it warns and leaves the socket owner-only — useful on a development machine, fatal for peers. ## Wireless interface @@ -256,8 +276,9 @@ remain machine-parseable. |----------|---------| | `BIGFRED_DATA_DIR` | Data root (default `/data`); socket is `/run/wireless-programmer/wireless-programmer.sock` | | `DATA_DIR` | Fallback data root | -| `WIRELESS_PROGRAMMER_ALLOW_USERS` | Comma-separated peer allowlist (daemon only) | -| `WIRELESS_PROGRAMMER_SOCKET_GROUP_USER` | Login name whose primary group owns the socket (daemon only; defaults to the first allowlist entry) | +| `WIRELESS_PROGRAMMER_REQUIRE_AUTH` | Enable peer auth (`1`/`true`/`yes`/`on`); default off | +| `WIRELESS_PROGRAMMER_ALLOW_USERS` | Comma-separated peer allowlist (used when auth is on; default `bigfred,bigfred-wizard`) | +| `WIRELESS_PROGRAMMER_SOCKET_GROUP_USER` | Login name whose primary group owns the socket (daemon only; defaults to the first allowlist entry when auth is on) | | `WIRELESS_PROGRAMMER_INTERFACE` | Wireless interface name for the daemon (e.g. `wlan0`); overridden by `--interface` | | `WIRELESS_PROGRAMMER_GIT_COMMIT` | Git commit baked into the `hello` response (build-time) | | `WIRELESS_PROGRAMMER_BUILD_TIME` | UTC build timestamp baked into version metadata (build-time, optional) | diff --git a/docs/go-client.md b/docs/go-client.md index 739493c..d51c29c 100644 --- a/docs/go-client.md +++ b/docs/go-client.md @@ -46,12 +46,13 @@ c := &client.Client{ `Socket` defaults to `DefaultSocket` when empty; `Timeout` defaults to 10s when zero. Override `Dial` in tests to point at an in-memory listener. -The socket is mode `0660`, owned by the primary group of the daemon's first -allowlisted user (`bigfred` by default), so the calling process must be that -user or in that group. A `permission denied` from `Dial` means the caller is -outside the group — the daemon's `SO_PEERCRED` allowlist never gets a chance to -run, so widening it does not help. See the permissions section of -[`api.md`](api.md). +The socket is mode `0666` when peer auth is off (the default). With +`--require-auth` it is `0660`, owned by the primary group of the daemon's +first allowlisted user (`bigfred` by default), so the calling process must +be that user or in that group. A `permission denied` from `Dial` means the +caller is outside the group — the daemon's `SO_PEERCRED` allowlist never +gets a chance to run, so widening it does not help. See the permissions +section of [`api.md`](api.md). ## Methods