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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions Cargo.lock

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

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ members = [
"crates/wp-link",
"crates/wp-drivers",
"crates/wp-client",
"crates/wp-fake",
"crates/wireless-programmer",
]

Expand Down
12 changes: 11 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
31 changes: 25 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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

Expand Down
5 changes: 5 additions & 0 deletions crates/wireless-programmer/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"] }
Expand Down
1 change: 1 addition & 0 deletions crates/wireless-programmer/src/cli/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ pub fn run(command: Command, socket_override: Option<PathBuf>) -> 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,
Expand Down
165 changes: 154 additions & 11 deletions crates/wireless-programmer/src/cli/daemon.rs
Original file line number Diff line number Diff line change
@@ -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)]
Expand All @@ -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<String>,

/// 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<String>,

/// 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<u16>,
}

/// Run the IPC daemon until shutdown.
Expand All @@ -38,7 +63,6 @@ pub fn run_daemon(args: DaemonArgs, socket_override: Option<PathBuf>) -> 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() {
Expand All @@ -47,32 +71,151 @@ pub fn run_daemon(args: DaemonArgs, socket_override: Option<PathBuf>) -> 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<dyn Radio> = 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}");
ExitCode::FAILURE
}
}
}

fn spawn_fake_from_std_listener(
runtime: &Arc<Runtime>,
std_listener: std::net::TcpListener,
) -> Result<(), String> {
let device: Arc<tokio::sync::Mutex<dyn wp_fake::FakeDevice>> =
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>(())
})
}
Loading
Loading