diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 2b5c023..345a15a 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -109,7 +109,7 @@ spawn the inotify reloader, bind `:8091`, serve until SIGINT/SIGTERM. ``` bigfred-wizard/ -├── Cargo.toml # workspace: binary + crates/z21-lan + crates/bigfred-client +├── Cargo.toml # workspace: binary + crates/z21-lan ├── Makefile # web-build, host, musl, test, dev-* ├── README.md # end-user description ├── ARCHITECTURE.md # this file @@ -119,7 +119,6 @@ bigfred-wizard/ ├── docs/screenshot-main.png ├── .github/workflows/{ci,release}.yml ├── crates/z21-lan/ # Z21 LAN UDP CV / POM packets -├── crates/bigfred-client/ # OAuth drop-in, HTTP proxy, dcc-bus WS, apis (no axum) ├── src/ # Axum daemon │ ├── main.rs # listen, router, SPA fallback │ ├── config.rs / config_watch.rs @@ -137,7 +136,8 @@ bigfred-wizard/ └── scripts/check-offline-bundle.mjs ``` -One binary crate plus `z21-lan` and `bigfred-client`, with an npm frontend compiled into that binary. +One binary crate plus `z21-lan`, with an npm frontend compiled into that binary. +`bigfred-client` comes from [dcc-bigfred/sdk](https://github.com/dcc-bigfred/sdk) (`rust/crates/bigfred-client`, git `main`). --- @@ -147,7 +147,7 @@ One binary crate plus `z21-lan` and `bigfred-client`, with an npm frontend compi |---|---|---| | **config** | `bigfred-wizard.json` + `.example` seed, `PublicConfig` (no PSK / no OAuth secret), builtin redirect URIs, `BigFredConfig` snapshot | filesystem | | **config_watch** | inotify on the config directory, 300 ms debounce, ignore `.example` / editor junk | inotify thread | -| **bigfred-client** | OAuth drop-in + token exchange, HTTP forward, dcc-bus WS (programming + drive), `apis::verify_pin`. Owns wire types (`Ack`, `CvEntry`, `Status`) | HTTP / WS / fs | +| **bigfred-client** | Git dep on [dcc-bigfred/sdk](https://github.com/dcc-bigfred/sdk) (`rust/crates/bigfred-client`). OAuth drop-in + token exchange, HTTP forward, dcc-bus WS (programming + drive), `apis::verify_pin`. Owns wire types (`Ack`, `CvEntry`, `Status`) | HTTP / WS / fs | | **bigfred/** | Axum wrappers: `oauth::token`, `pin::verify_pin`, `proxy::proxy`; `From for ApiError` | via bigfred-client | | **loco_programming** | `LocoProgrammer` trait; `DccBusProgrammer` and `Z21Programmer`; `Hub::select` from live `locoProgramming.mode` | WS or UDP | | **programming_api** | HTTP face of CV/address/F2 pulse; SPA never speaks WS | via loco_programming / bigfred-client | diff --git a/Cargo.lock b/Cargo.lock index 0e12b70..6f26029 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -142,6 +142,7 @@ checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" [[package]] name = "bigfred-client" version = "0.1.0" +source = "git+https://github.com/dcc-bigfred/sdk.git?branch=main#1339035e79bb6aaff86461985cac2b6597c769e8" dependencies = [ "futures", "rand", diff --git a/Cargo.toml b/Cargo.toml index 1c187a6..e71eee0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,7 +7,7 @@ license = "MIT" publish = false [workspace] -members = [".", "crates/z21-lan", "crates/bigfred-client"] +members = [".", "crates/z21-lan"] [[bin]] name = "bigfred-wizard" @@ -36,7 +36,7 @@ qrcode = { version = "0.14.1", default-features = false, features = ["svg"] } # Wire types for the wireless-programmer Unix socket (sibling repo). wp-proto = { path = "../wireless-programmer/crates/wp-proto" } z21-lan = { path = "crates/z21-lan" } -bigfred-client = { path = "crates/bigfred-client" } +bigfred-client = { git = "https://github.com/dcc-bigfred/sdk.git", branch = "main" } [profile.release] lto = true diff --git a/crates/bigfred-client/Cargo.toml b/crates/bigfred-client/Cargo.toml deleted file mode 100644 index 039b2bc..0000000 --- a/crates/bigfred-client/Cargo.toml +++ /dev/null @@ -1,26 +0,0 @@ -[package] -name = "bigfred-client" -version = "0.1.0" -edition = "2021" -description = "HTTP, OAuth drop-in, and dcc-bus WebSocket client for BigFred" -license = "MIT" -publish = false - -[lib] -name = "bigfred_client" -path = "src/lib.rs" - -[dependencies] -reqwest = { version = "0.12", default-features = false, features = ["json", "charset", "http2"] } -tokio = { version = "1", features = ["net", "time", "sync", "rt", "macros"] } -tokio-tungstenite = "0.24" -futures = "0.3" -serde = { version = "1", features = ["derive"] } -serde_json = "1" -uuid = { version = "1", features = ["v4"] } -rand = "0.8" -thiserror = "1" -tracing = "0.1" - -[dev-dependencies] -tokio = { version = "1", features = ["net", "time", "sync", "rt", "macros", "rt-multi-thread"] } diff --git a/crates/bigfred-client/src/apis/mod.rs b/crates/bigfred-client/src/apis/mod.rs deleted file mode 100644 index dba884b..0000000 --- a/crates/bigfred-client/src/apis/mod.rs +++ /dev/null @@ -1,5 +0,0 @@ -//! Typed BigFred HTTP API calls (no axum). - -mod pin; - -pub use pin::verify_pin; diff --git a/crates/bigfred-client/src/apis/pin.rs b/crates/bigfred-client/src/apis/pin.rs deleted file mode 100644 index 7bfc898..0000000 --- a/crates/bigfred-client/src/apis/pin.rs +++ /dev/null @@ -1,65 +0,0 @@ -//! `POST /api/v1/auth/login` — verify a participant PIN and drop the JWT. - -use serde::Serialize; -use serde_json::Value; - -use crate::config::BigFredConfig; -use crate::error::{Error, Result}; - -#[derive(Serialize)] -#[serde(rename_all = "camelCase")] -struct LoginRequest<'a> { - login: &'a str, - pin: &'a str, - layout_id: u64, -} - -/// Checks `{ login, pin, layoutId }` against BigFred. On success the minted -/// session body is discarded so a kiosk never keeps a driver JWT. -pub async fn verify_pin( - http: &reqwest::Client, - cfg: &BigFredConfig, - login: &str, - pin: &str, - layout_id: u64, -) -> Result<()> { - let url = format!("{}/api/v1/auth/login", cfg.api_base); - let res = http - .post(&url) - .json(&LoginRequest { - login, - pin, - layout_id, - }) - .send() - .await - .map_err(|err| Error::OauthUnreachable(err.to_string()))?; - - if res.status().is_success() { - // Drop the minted session — the caller must not keep a driver JWT. - let _ = res.bytes().await; - return Ok(()); - } - - let status = res.status().as_u16(); - let text = res.text().await.unwrap_or_default(); - let parsed: Option = serde_json::from_str(&text).ok(); - let code = parsed - .as_ref() - .and_then(|v| v.get("error").and_then(|e| e.as_str()).map(str::to_string)) - .unwrap_or_else(|| { - if status == 401 { - "invalid_credentials".to_string() - } else { - format!("http_{status}") - } - }); - let detail = parsed - .as_ref() - .and_then(|v| v.get("detail").and_then(|d| d.as_str()).map(str::to_string)); - Err(Error::BadStatus { - status, - code, - detail, - }) -} diff --git a/crates/bigfred-client/src/config.rs b/crates/bigfred-client/src/config.rs deleted file mode 100644 index 474450d..0000000 --- a/crates/bigfred-client/src/config.rs +++ /dev/null @@ -1,47 +0,0 @@ -//! Snapshot of BigFred connectivity settings. Callers own hot-reload. - -use std::path::PathBuf; - -/// Values the client needs to talk to BigFred. Built by the host from its -/// own config; this crate does not read wizard JSON or `$DATA_DIR`. -#[derive(Debug, Clone)] -pub struct BigFredConfig { - /// Loopback HTTP origin, no trailing slash (`http://127.0.0.1:8080`). - pub api_base: String, - /// Same origin with `ws`/`wss`, no trailing slash. - pub ws_base: String, - /// OAuth client id written into the drop-in filename. - pub sso_client_id: String, - /// Redirect URIs already merged with the host's builtins. - pub redirect_uris: Vec, - /// `$DATA_DIR/etc/bigfred/oauth-clients`. - pub oauth_dropin_dir: PathBuf, - /// `displayName` written into a newly seeded drop-in. - pub oauth_display_name: String, - /// Skip catalogue autodetection when both ids are set. - pub fixed_dcc_bus: Option<(u64, u64)>, -} - -impl BigFredConfig { - /// Drop-in path for [`Self::sso_client_id`]. - pub fn oauth_client_path(&self) -> PathBuf { - self.oauth_dropin_dir - .join(format!("{}.json", self.sso_client_id)) - } -} - -#[cfg(test)] -pub(crate) fn test_config(dropin_dir: PathBuf) -> BigFredConfig { - BigFredConfig { - api_base: "http://127.0.0.1:8080".into(), - ws_base: "ws://127.0.0.1:8080".into(), - sso_client_id: "bigfred-wizard".into(), - redirect_uris: vec![ - "http://bigfred.local:8091/auth/callback".into(), - "http://localhost:8091/auth/callback".into(), - ], - oauth_dropin_dir: dropin_dir, - oauth_display_name: "BigFred Wizard".into(), - fixed_dcc_bus: None, - } -} diff --git a/crates/bigfred-client/src/dccbus.rs b/crates/bigfred-client/src/dccbus.rs deleted file mode 100644 index 1a63fd1..0000000 --- a/crates/bigfred-client/src/dccbus.rs +++ /dev/null @@ -1,656 +0,0 @@ -//! Resilient dcc-bus WebSocket client. -//! -//! Two long-lived sockets are kept (each with its own keep-alive pings): -//! -//! * **programming** — organizer JWT, used for CV / address frames -//! (`loco.cvRead`, `loco.cvWrite`, `loco.addrGet`, `loco.addrSet`). -//! * **drive** — organizer JWT + `X-BigFred-Impersonate-As` for the -//! currently selected participant; used for ops-track pulses (F2). -//! Replaced when the wizard picks a different user. -//! -//! Both are warmed eagerly (`ensure_connected` after login, -//! `ensure_drive` when a participant is selected) and re-dialled with -//! exponential backoff if they die. -//! -//! The daemon that actually drives the command station is spawned by -//! BigFred when a layout session selects the station. If no daemon is -//! listening the proxy answers `503`, which surfaces here as -//! [`Error::DccBusUnreachable`] — the organizer has to open the layout -//! in BigFred once so the station comes up. - -use std::collections::HashMap; -use std::sync::atomic::{AtomicBool, Ordering}; -use std::sync::{Arc, Mutex as StdMutex}; -use std::time::Duration; - -use futures::{SinkExt, StreamExt}; -use rand::Rng; -use tokio::sync::{mpsc, oneshot, Mutex, RwLock}; -use tokio::task::JoinHandle; -use tokio_tungstenite::tungstenite::client::IntoClientRequest; -use tokio_tungstenite::tungstenite::http::header::HeaderName; -use tokio_tungstenite::tungstenite::Message; - -use crate::config::BigFredConfig; -use crate::error::{Error, Result}; -use crate::wire::{Ack, CommandStation, Envelope, Status, FRAME_SET_FUNCTION, IMPERSONATE_HEADER}; - -const ACK_TIMEOUT: Duration = Duration::from_secs(30); -/// Keep-alive for both permanent sockets. Must stay below dcc-bus deadman -/// (default 6s; BigFred heartbeat is 2s). -const PING_INTERVAL: Duration = Duration::from_secs(2); -const CONNECT_ATTEMPTS: u32 = 3; -const BACKOFF_BASE_MS: u64 = 250; -const BACKOFF_MAX_MS: u64 = 4_000; -/// Best-effort timeout for the trailing `OFF` of a function pulse. Shorter -/// than [`ACK_TIMEOUT`] so a pulse never blocks the organizer for half a -/// minute; the ON ack still uses [`ACK_TIMEOUT`] because a stuck command -/// station is worth surfacing verbatim. -const PULSE_OFF_TIMEOUT: Duration = Duration::from_secs(5); - -type Pending = Arc>>>; - -/// A live socket plus the tasks that keep it readable and warm. -struct Session { - tx: mpsc::UnboundedSender, - pending: Pending, - alive: Arc, - command_station_id: u64, - tasks: Vec>, -} - -impl Session { - fn is_alive(&self) -> bool { - self.alive.load(Ordering::Relaxed) && !self.tx.is_closed() - } -} - -impl Drop for Session { - fn drop(&mut self) { - self.alive.store(false, Ordering::Relaxed); - for task in self.tasks.drain(..) { - task.abort(); - } - } -} - -/// Impersonated drive socket keyed by participant login. -struct DriveSession { - login: String, - session: Session, -} - -/// Owns two dcc-bus sockets: organizer programming + participant drive. -pub struct DccBusClient { - cfg: Arc>, - http: reqwest::Client, - programming: Mutex>, - drive: Mutex>, - status: StdMutex, -} - -impl DccBusClient { - pub fn new(cfg: Arc>, http: reqwest::Client) -> Self { - Self { - cfg, - http, - programming: Mutex::new(None), - drive: Mutex::new(None), - status: StdMutex::new(Status::default()), - } - } - - fn lock_status(&self) -> std::sync::MutexGuard<'_, Status> { - self.status.lock().unwrap_or_else(|poisoned| { - tracing::warn!("status mutex poisoned — recovering inner guard"); - poisoned.into_inner() - }) - } - - pub fn status(&self) -> Status { - self.lock_status().clone() - } - - fn refresh_drive_status(&self, drive: &Option) { - let mut status = self.lock_status(); - match drive { - Some(d) if d.session.is_alive() => { - status.drive_connected = true; - status.drive_as = Some(d.login.clone()); - } - _ => { - status.drive_connected = false; - status.drive_as = None; - } - } - } - - /// Opens (or reuses) the organizer programming WebSocket. No-op when a - /// live session already exists — used to warm the link right after login. - pub async fn ensure_connected(&self, token: &str) -> Result { - let mut guard = self.programming.lock().await; - if guard.as_ref().is_some_and(|s| !s.is_alive()) { - *guard = None; - } - if guard.is_none() { - *guard = Some(self.connect_with_backoff_as(token, None).await?); - } - drop(guard); - Ok(self.status()) - } - - /// Opens (or switches) the impersonated drive WebSocket for `as_login`. - /// Reuses the existing socket when it is alive and already that user. - pub async fn ensure_drive(&self, token: &str, as_login: &str) -> Result { - let login = as_login.trim(); - if login.is_empty() { - return Err(Error::ImpersonateRequired); - } - let mut guard = self.drive.lock().await; - let reuse = guard - .as_ref() - .is_some_and(|d| d.login == login && d.session.is_alive()); - if !reuse { - if let Some(prev) = guard.take() { - tracing::info!( - previous = %prev.login, - next = %login, - "dcc-bus drive session switching user" - ); - } - let session = self.connect_with_backoff_as(token, Some(login)).await?; - *guard = Some(DriveSession { - login: login.to_string(), - session, - }); - } - self.refresh_drive_status(&guard); - drop(guard); - Ok(self.status()) - } - - /// Sends one programming frame and waits for the matching `ack`. - /// Reconnects once if the cached programming socket turned out to be dead. - pub async fn request( - &self, - token: &str, - frame: &str, - payload: serde_json::Value, - ) -> Result { - let mut guard = self.programming.lock().await; - if guard.as_ref().is_some_and(|s| !s.is_alive()) { - *guard = None; - } - if guard.is_none() { - *guard = Some(self.connect_with_backoff_as(token, None).await?); - } - - match send_and_wait( - guard.as_ref().ok_or(Error::DccBusSessionLost)?, - frame, - payload.clone(), - ) - .await - { - Ok(ack) => Ok(ack), - Err(err) if err.is_dcc_bus_unavailable() => { - *guard = None; - *guard = Some(self.connect_with_backoff_as(token, None).await?); - send_and_wait( - guard.as_ref().ok_or(Error::DccBusSessionLost)?, - frame, - payload, - ) - .await - } - Err(err) => Err(err), - } - } - - /// Impersonated on→wait→off for one function on the cached drive socket. - /// - /// # Rollback - /// - /// If the `OFF` frame fails or the caller drops this future mid-pulse, - /// a best-effort fire-and-forget `OFF` is still emitted so the function - /// does not stay latched on the ops track. The `OFF` ack waits at most - /// [`PULSE_OFF_TIMEOUT`]. - pub async fn pulse_function( - &self, - token: &str, - as_login: &str, - address: u16, - function: u8, - duration_ms: u64, - ) -> Result { - let login = as_login.trim(); - if login.is_empty() { - return Err(Error::ImpersonateRequired); - } - - let mut guard = self.drive.lock().await; - let reuse = guard - .as_ref() - .is_some_and(|d| d.login == login && d.session.is_alive()); - if !reuse { - let _ = guard.take(); - let session = self.connect_with_backoff_as(token, Some(login)).await?; - *guard = Some(DriveSession { - login: login.to_string(), - session, - }); - self.refresh_drive_status(&guard); - } - - let session = &guard.as_ref().ok_or(Error::DccBusDriveSessionLost)?.session; - - send_and_wait( - session, - FRAME_SET_FUNCTION, - serde_json::json!({ - "address": address, - "function": function, - "on": true, - }), - ) - .await?; - - let pulse_guard = PulseOffGuard { - tx: session.tx.clone(), - address, - function, - armed: true, - }; - - tokio::time::sleep(Duration::from_millis(duration_ms)).await; - - pulse_guard.disarm_and_send_off(session).await - } - - /// Picks the programming-capable command station with the lowest id. - /// When `fixed_dcc_bus` is set, those values skip catalogue autodetection. - pub async fn pick_station(&self, token: &str) -> Result { - let fixed = self.cfg.read().await.fixed_dcc_bus; - if let Some((cs_id, _)) = fixed { - return Ok(CommandStation { - id: cs_id, - name: format!("dcc-bus #{cs_id}"), - programming: true, - ..CommandStation::default() - }); - } - let api_base = self.cfg.read().await.api_base.clone(); - let url = format!("{api_base}/api/v1/command-stations/catalogue"); - let res = self - .http - .get(&url) - .header("authorization", format!("Bearer {token}")) - .send() - .await - .map_err(|err| Error::OauthUnreachable(err.to_string()))?; - let status = res.status(); - let bytes = res.bytes().await.unwrap_or_default(); - if status.as_u16() == 401 || status.as_u16() == 403 { - return Err(Error::Unauthorized); - } - if !status.is_success() { - return Err(Error::CatalogueUnavailable( - String::from_utf8_lossy(&bytes).to_string(), - )); - } - let mut stations: Vec = serde_json::from_slice(&bytes) - .map_err(|err| Error::CatalogueBadResponse(err.to_string()))?; - stations.sort_by_key(|s| s.id); - stations - .into_iter() - .find(|s| s.programming) - .ok_or(Error::NoProgrammingStation) - } - - async fn connect_with_backoff_as( - &self, - token: &str, - as_login: Option<&str>, - ) -> Result { - let mut last: Option = None; - for attempt in 0..CONNECT_ATTEMPTS { - if attempt > 0 { - tokio::time::sleep(backoff_delay(attempt)).await; - } - match self.connect(token, as_login).await { - Ok(session) => { - if as_login.is_none() { - let mut status = self.lock_status(); - status.connected = true; - status.last_error = None; - if attempt > 0 { - status.reconnects += 1; - } - } - return Ok(session); - } - Err(err) => { - tracing::warn!( - attempt, - error = %err.code(), - as_login = as_login.unwrap_or(""), - "dcc-bus connect failed" - ); - if as_login.is_none() { - let mut status = self.lock_status(); - status.connected = false; - status.last_error = Some(err.code()); - } - last = Some(err); - } - } - } - Err(last.unwrap_or_else(|| Error::DccBusUnreachable(String::new()))) - } - - async fn connect(&self, token: &str, as_login: Option<&str>) -> Result { - let station = self.pick_station(token).await?; - if as_login.is_none() { - let mut status = self.lock_status(); - status.command_station_id = Some(station.id); - status.command_station_name = Some(station.name.clone()); - status.default_programming_track_output = - Some(station.default_programming_track_output.clone()); - } - - let url = format!( - "{}/api/v1/dcc-bus/{}/ws?token={}", - self.cfg.read().await.ws_base, - station.id, - urlencode(token) - ); - let mut req = url - .into_client_request() - .map_err(|err| Error::DccBusBadUrl(err.to_string()))?; - if let Some(login) = as_login { - let name = HeaderName::from_static(IMPERSONATE_HEADER); - let value = login.parse().map_err(|_| Error::InvalidImpersonateLogin)?; - req.headers_mut().insert(name, value); - } - let (stream, _) = tokio_tungstenite::connect_async(req) - .await - .map_err(|err| Error::DccBusUnreachable(err.to_string()))?; - - let (mut sink, mut source) = stream.split(); - let (tx, mut rx) = mpsc::unbounded_channel::(); - let pending: Pending = Arc::new(StdMutex::new(HashMap::new())); - let alive = Arc::new(AtomicBool::new(true)); - - let writer = tokio::spawn(async move { - while let Some(msg) = rx.recv().await { - if sink.send(msg).await.is_err() { - break; - } - } - let _ = sink.close().await; - }); - - let reader_pending = Arc::clone(&pending); - let reader_alive = Arc::clone(&alive); - let reader = tokio::spawn(async move { - while let Some(Ok(msg)) = source.next().await { - let text = match msg { - Message::Text(text) => text, - Message::Binary(bin) => match String::from_utf8(bin) { - Ok(text) => text, - Err(_) => continue, - }, - Message::Close(_) => break, - _ => continue, - }; - let Ok(env) = serde_json::from_str::(&text) else { - continue; - }; - if env.kind != "ack" { - continue; - } - let Some(id) = env.id else { continue }; - let waiter = reader_pending - .lock() - .unwrap_or_else(|p| p.into_inner()) - .remove(&id); - if let Some(waiter) = waiter { - let ack = env - .payload - .and_then(|p| serde_json::from_value::(p).ok()) - .unwrap_or_default(); - let _ = waiter.send(ack); - } - } - reader_alive.store(false, Ordering::Relaxed); - reader_pending - .lock() - .unwrap_or_else(|p| p.into_inner()) - .clear(); - }); - - let ping_tx = tx.clone(); - let pinger = tokio::spawn(async move { - let mut ticker = tokio::time::interval(PING_INTERVAL); - ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); - loop { - ticker.tick().await; - let Ok(frame) = serde_json::to_string(&Envelope { - kind: "ping".to_string(), - id: None, - payload: Some(serde_json::json!({})), - }) else { - break; - }; - if ping_tx.send(Message::Text(frame)).is_err() { - break; - } - } - }); - - tracing::info!( - command_station = station.id, - name = %station.name, - as_login = as_login.unwrap_or(""), - role = if as_login.is_some() { - "drive" - } else { - "programming" - }, - "dcc-bus connected" - ); - Ok(Session { - tx, - pending, - alive, - command_station_id: station.id, - tasks: vec![writer, reader, pinger], - }) - } -} - -async fn send_and_wait(session: &Session, frame: &str, payload: serde_json::Value) -> Result { - let id = uuid::Uuid::new_v4().to_string(); - let (tx, rx) = oneshot::channel(); - { - let mut pending = session - .pending - .lock() - .map_err(|_| Error::DccBusPendingPoisoned)?; - pending.insert(id.clone(), tx); - } - - let envelope = serde_json::to_string(&Envelope { - kind: frame.to_string(), - id: Some(id.clone()), - payload: Some(payload), - }) - .map_err(|err| Error::FrameEncodeFailed(err.to_string()))?; - - if session.tx.send(Message::Text(envelope)).is_err() { - if let Ok(mut pending) = session.pending.lock() { - pending.remove(&id); - } - return Err(Error::DccBusUnreachable(String::new())); - } - - match tokio::time::timeout(ACK_TIMEOUT, rx).await { - Ok(Ok(ack)) if ack.ok => Ok(ack), - Ok(Ok(ack)) => Err(Error::BadStatus { - status: 502, - code: ack - .error - .unwrap_or_else(|| "programming_failed".to_string()), - detail: Some(format!("command station {}", session.command_station_id)), - }), - Ok(Err(_)) => Err(Error::DccBusUnreachable(String::new())), - Err(_) => { - if let Ok(mut pending) = session.pending.lock() { - pending.remove(&id); - } - Err(Error::ProgrammingTimeout) - } - } -} - -struct PulseOffGuard { - tx: mpsc::UnboundedSender, - address: u16, - function: u8, - armed: bool, -} - -impl PulseOffGuard { - async fn disarm_and_send_off(mut self, session: &Session) -> Result { - self.armed = false; - let (tx, rx) = oneshot::channel(); - let id = uuid::Uuid::new_v4().to_string(); - { - let mut pending = session - .pending - .lock() - .map_err(|_| Error::DccBusPendingPoisoned)?; - pending.insert(id.clone(), tx); - } - let envelope = serde_json::to_string(&Envelope { - kind: FRAME_SET_FUNCTION.to_string(), - id: Some(id.clone()), - payload: Some(serde_json::json!({ - "address": self.address, - "function": self.function, - "on": false, - })), - }) - .map_err(|err| Error::FrameEncodeFailed(err.to_string()))?; - - if self.tx.send(Message::Text(envelope)).is_err() { - if let Ok(mut pending) = session.pending.lock() { - pending.remove(&id); - } - return Err(Error::DccBusUnreachable(String::new())); - } - - match tokio::time::timeout(PULSE_OFF_TIMEOUT, rx).await { - Ok(Ok(ack)) if ack.ok => Ok(ack), - Ok(Ok(ack)) => Err(Error::BadStatus { - status: 502, - code: ack - .error - .unwrap_or_else(|| "function_off_failed".to_string()), - detail: Some(format!("command station {}", session.command_station_id)), - }), - Ok(Err(_)) => Err(Error::DccBusUnreachable(String::new())), - Err(_) => { - if let Ok(mut pending) = session.pending.lock() { - pending.remove(&id); - } - Err(Error::FunctionOffTimeout) - } - } - } -} - -impl Drop for PulseOffGuard { - fn drop(&mut self) { - if !self.armed { - return; - } - let id = uuid::Uuid::new_v4().to_string(); - let Ok(frame) = serde_json::to_string(&Envelope { - kind: FRAME_SET_FUNCTION.to_string(), - id: Some(id), - payload: Some(serde_json::json!({ - "address": self.address, - "function": self.function, - "on": false, - })), - }) else { - return; - }; - let _ = self.tx.send(Message::Text(frame)); - } -} - -fn backoff_delay(attempt: u32) -> Duration { - let exp = BACKOFF_BASE_MS.saturating_mul(1u64 << attempt.min(6)); - let capped = exp.min(BACKOFF_MAX_MS); - let jitter = rand::thread_rng().gen_range(0..=capped / 2); - Duration::from_millis(capped / 2 + jitter) -} - -fn urlencode(value: &str) -> String { - let mut out = String::with_capacity(value.len()); - for byte in value.bytes() { - match byte { - b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => { - out.push(byte as char) - } - _ => out.push_str(&format!("%{byte:02X}")), - } - } - out -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::config::test_config; - - #[test] - fn backoff_grows_and_is_capped() { - for attempt in 0..8 { - let delay = backoff_delay(attempt).as_millis() as u64; - assert!(delay <= BACKOFF_MAX_MS, "attempt {attempt} → {delay}ms"); - } - assert!(backoff_delay(0).as_millis() >= (BACKOFF_BASE_MS / 2) as u128); - } - - #[test] - fn urlencode_keeps_jwt_alphabet() { - assert_eq!(urlencode("abcABC123-_.~"), "abcABC123-_.~"); - assert_eq!(urlencode("a+b/c=d"), "a%2Bb%2Fc%3Dd"); - } - - #[test] - fn ack_parses_cv_results() { - let ack: Ack = serde_json::from_str( - r#"{"ok":true,"cvs":[{"cv":1,"value":3}],"locoAddress":3,"longAddress":false}"#, - ) - .unwrap(); - assert!(ack.ok); - assert_eq!(ack.cvs.unwrap()[0].cv, 1); - assert_eq!(ack.loco_address, Some(3)); - } - - #[tokio::test] - async fn pick_station_uses_fixed_dcc_bus() { - let tmp = std::env::temp_dir().join(format!("bf-dcc-{}", uuid::Uuid::new_v4())); - let mut cfg = test_config(tmp); - cfg.fixed_dcc_bus = Some((7, 1)); - let client = DccBusClient::new(Arc::new(RwLock::new(cfg)), reqwest::Client::new()); - let station = client.pick_station("token").await.expect("fixed"); - assert_eq!(station.id, 7); - assert!(station.programming); - assert_eq!(station.name, "dcc-bus #7"); - } -} diff --git a/crates/bigfred-client/src/error.rs b/crates/bigfred-client/src/error.rs deleted file mode 100644 index 0229aed..0000000 --- a/crates/bigfred-client/src/error.rs +++ /dev/null @@ -1,114 +0,0 @@ -//! Typed errors with stable string codes the host maps onto HTTP. - -use std::path::PathBuf; - -/// Failures talking to BigFred or maintaining the OAuth drop-in. -#[derive(Debug, thiserror::Error)] -pub enum Error { - #[error("oauth unreachable: {0}")] - OauthUnreachable(String), - #[error("proxy unreachable: {0}")] - ProxyUnreachable(String), - #[error("proxy read failed: {0}")] - ProxyReadFailed(String), - #[error("dcc-bus unreachable: {0}")] - DccBusUnreachable(String), - #[error("unauthorized")] - Unauthorized, - #[error("programming timeout")] - ProgrammingTimeout, - #[error("function off timeout")] - FunctionOffTimeout, - #[error("{code}")] - BadStatus { - status: u16, - code: String, - detail: Option, - }, - #[error("oauth bad response: {0}")] - OauthBadResponse(String), - #[error("catalogue unavailable: {0}")] - CatalogueUnavailable(String), - #[error("catalogue bad response: {0}")] - CatalogueBadResponse(String), - #[error("oauth client ensure failed: {0}")] - OauthClientEnsureFailed(String), - #[error("oauth client unreadable: {0}")] - OauthClientUnreadable(String), - #[error("oauth client missing")] - OauthClientMissing, - #[error("no programming station")] - NoProgrammingStation, - #[error("dcc-bus session lost")] - DccBusSessionLost, - #[error("dcc-bus drive session lost")] - DccBusDriveSessionLost, - #[error("dcc-bus pending poisoned")] - DccBusPendingPoisoned, - #[error("dcc-bus bad url: {0}")] - DccBusBadUrl(String), - #[error("frame encode failed: {0}")] - FrameEncodeFailed(String), - #[error("impersonate required")] - ImpersonateRequired, - #[error("invalid impersonate login")] - InvalidImpersonateLogin, - #[error("io {path}: {source}")] - Io { - path: PathBuf, - #[source] - source: std::io::Error, - }, - #[error("parse {path}: {source}")] - Parse { - path: PathBuf, - #[source] - source: serde_json::Error, - }, - #[error("serialize {path}: {source}")] - Serialize { - path: PathBuf, - #[source] - source: serde_json::Error, - }, -} - -impl Error { - /// Stable machine code used by the host HTTP envelope and status logs. - pub fn code(&self) -> String { - match self { - Self::OauthUnreachable(_) | Self::ProxyUnreachable(_) => { - "bigfred_unreachable".to_string() - } - Self::ProxyReadFailed(_) => "bigfred_read_failed".to_string(), - Self::DccBusUnreachable(_) => "dcc_bus_unavailable".to_string(), - Self::Unauthorized => "unauthorized".to_string(), - Self::ProgrammingTimeout => "programming_timeout".to_string(), - Self::FunctionOffTimeout => "function_off_timeout".to_string(), - Self::BadStatus { code, .. } => code.clone(), - Self::OauthBadResponse(_) => "oauth_bad_response".to_string(), - Self::CatalogueUnavailable(_) => "catalogue_unavailable".to_string(), - Self::CatalogueBadResponse(_) => "catalogue_bad_response".to_string(), - Self::OauthClientEnsureFailed(_) => "oauth_client_ensure_failed".to_string(), - Self::OauthClientUnreadable(_) => "oauth_client_unreadable".to_string(), - Self::OauthClientMissing => "oauth_client_missing".to_string(), - Self::NoProgrammingStation => "no_programming_station".to_string(), - Self::DccBusSessionLost => "dcc_bus_session_lost".to_string(), - Self::DccBusDriveSessionLost => "dcc_bus_drive_session_lost".to_string(), - Self::DccBusPendingPoisoned => "dcc_bus_pending_poisoned".to_string(), - Self::DccBusBadUrl(_) => "dcc_bus_bad_url".to_string(), - Self::FrameEncodeFailed(_) => "frame_encode_failed".to_string(), - Self::ImpersonateRequired => "impersonate_required".to_string(), - Self::InvalidImpersonateLogin => "invalid_impersonate_login".to_string(), - Self::Io { .. } | Self::Parse { .. } | Self::Serialize { .. } => { - "oauth_client_unreadable".to_string() - } - } - } - - pub fn is_dcc_bus_unavailable(&self) -> bool { - matches!(self, Self::DccBusUnreachable(_)) - } -} - -pub type Result = std::result::Result; diff --git a/crates/bigfred-client/src/lib.rs b/crates/bigfred-client/src/lib.rs deleted file mode 100644 index 0555269..0000000 --- a/crates/bigfred-client/src/lib.rs +++ /dev/null @@ -1,21 +0,0 @@ -//! HTTP, OAuth drop-in, and dcc-bus WebSocket client for BigFred. -//! -//! No axum: the host owns HTTP handlers and maps [`Error`] onto its -//! envelope. Config is a snapshot ([`BigFredConfig`]); the host refreshes -//! it on reload. - -pub mod apis; -mod config; -mod dccbus; -mod error; -pub mod oauth; -pub mod proxy; -mod wire; - -pub use config::BigFredConfig; -pub use dccbus::DccBusClient; -pub use error::{Error, Result}; -pub use wire::{ - Ack, CommandStation, CvEntry, Envelope, Status, TokenResponse, FRAME_SET_FUNCTION, - IMPERSONATE_HEADER, -}; diff --git a/crates/bigfred-client/src/oauth.rs b/crates/bigfred-client/src/oauth.rs deleted file mode 100644 index 5c064fb..0000000 --- a/crates/bigfred-client/src/oauth.rs +++ /dev/null @@ -1,379 +0,0 @@ -//! OAuth confidential-client drop-in and authorization-code exchange. - -use std::io::Write; -use std::path::{Path, PathBuf}; - -use rand::RngCore; -use serde::{Deserialize, Serialize}; - -use crate::config::BigFredConfig; -use crate::error::{Error, Result}; -use crate::wire::TokenResponse; - -/// One drop-in registration, mirroring `cmd.OAuthClient` on the Go side. -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct OAuthClientFile { - pub client_id: String, - pub client_secret: String, - #[serde(default)] - pub display_name: String, - #[serde(default)] - pub redirect_uris: Vec, - #[serde(default)] - pub cors_enabled: bool, - #[serde(default)] - pub cors_origins: Vec, - #[serde(default)] - pub enabled: bool, - #[serde(default)] - pub share_session: bool, -} - -#[derive(Debug, Serialize)] -#[serde(rename_all = "camelCase")] -struct UpstreamRequest<'a> { - grant_type: &'a str, - code: &'a str, - client_id: &'a str, - client_secret: &'a str, - redirect_uri: &'a str, -} - -/// Creates the drop-in when missing. An existing file keeps its secret, but -/// redirect URIs from `cfg` are always merged in. Directory and file modes -/// are re-asserted so BigFred (`bigfred`) can read. -pub fn ensure_dropin(cfg: &BigFredConfig) -> Result { - let dir = &cfg.oauth_dropin_dir; - std::fs::create_dir_all(dir).map_err(|source| Error::Io { - path: dir.clone(), - source, - })?; - harden_dropin_dir(dir); - - let path = cfg.oauth_client_path(); - if path.exists() { - let changed = sync_redirect_uris(&path, cfg)?; - harden_dropin_file(&path); - if changed { - touch_for_reload(&path); - } - tracing::info!(path = %path.display(), "oauth client drop-in present"); - return Ok(path); - } - - let file = OAuthClientFile { - client_id: cfg.sso_client_id.clone(), - client_secret: random_secret(), - display_name: cfg.oauth_display_name.clone(), - redirect_uris: cfg.redirect_uris.clone(), - cors_enabled: false, - cors_origins: Vec::new(), - enabled: true, - share_session: false, - }; - write_private( - &path, - &serde_json::to_vec_pretty(&file).map_err(|source| Error::Serialize { - path: path.clone(), - source, - })?, - )?; - harden_dropin_file(&path); - tracing::info!(path = %path.display(), "seeded oauth client drop-in"); - Ok(path) -} - -/// Reads the client secret back for the token exchange. -pub fn load_secret(cfg: &BigFredConfig) -> Result> { - let path = cfg.oauth_client_path(); - let raw = match std::fs::read(&path) { - Ok(raw) => raw, - Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(None), - Err(source) => return Err(Error::Io { path, source }), - }; - let file: OAuthClientFile = - serde_json::from_slice(&raw).map_err(|source| Error::Parse { path, source })?; - Ok(Some(file.client_secret)) -} - -/// Adds the confidential `clientSecret` and exchanges `code` with BigFred. -pub async fn exchange_token( - http: &reqwest::Client, - cfg: &BigFredConfig, - code: &str, - redirect_uri: &str, -) -> Result { - let secret = match load_secret(cfg) { - Ok(Some(secret)) => secret, - Ok(None) => { - tracing::info!("oauth drop-in missing on token exchange — seeding"); - ensure_dropin(cfg).map_err(|err| Error::OauthClientEnsureFailed(err.to_string()))?; - load_secret(cfg) - .map_err(|err| Error::OauthClientUnreadable(err.to_string()))? - .ok_or(Error::OauthClientMissing)? - } - Err(err) => { - tracing::warn!(error = %err, "oauth drop-in unreadable — re-seeding"); - ensure_dropin(cfg).map_err(|err| Error::OauthClientEnsureFailed(err.to_string()))?; - load_secret(cfg) - .map_err(|err| Error::OauthClientUnreadable(err.to_string()))? - .ok_or(Error::OauthClientMissing)? - } - }; - - let url = format!("{}/api/v1/auth/oauth/token", cfg.api_base); - let res = http - .post(&url) - .json(&UpstreamRequest { - grant_type: "authorization_code", - code, - client_id: &cfg.sso_client_id, - client_secret: &secret, - redirect_uri, - }) - .send() - .await - .map_err(|err| Error::OauthUnreachable(err.to_string()))?; - - let status = res.status(); - let payload = res.bytes().await.unwrap_or_default(); - if !status.is_success() { - let code = serde_json::from_slice::(&payload) - .ok() - .and_then(|v| v.get("error").and_then(|e| e.as_str()).map(str::to_string)) - .unwrap_or_else(|| "oauth_exchange_failed".to_string()); - return Err(Error::BadStatus { - status: status.as_u16(), - code, - detail: None, - }); - } - - serde_json::from_slice(&payload).map_err(|err| Error::OauthBadResponse(err.to_string())) -} - -fn sync_redirect_uris(path: &Path, cfg: &BigFredConfig) -> Result { - let raw = std::fs::read(path).map_err(|source| Error::Io { - path: path.to_path_buf(), - source, - })?; - let mut file: OAuthClientFile = - serde_json::from_slice(&raw).map_err(|source| Error::Parse { - path: path.to_path_buf(), - source, - })?; - - let before = file.redirect_uris.clone(); - for uri in &cfg.redirect_uris { - let trimmed = uri.trim(); - if trimmed.is_empty() { - continue; - } - if !file.redirect_uris.iter().any(|u| u.trim() == trimmed) { - file.redirect_uris.push(trimmed.to_string()); - } - } - if file.redirect_uris == before { - return Ok(false); - } - - let mut data = serde_json::to_vec_pretty(&file).map_err(|source| Error::Serialize { - path: path.to_path_buf(), - source, - })?; - data.push(b'\n'); - std::fs::write(path, data).map_err(|source| Error::Io { - path: path.to_path_buf(), - source, - })?; - tracing::info!(path = %path.display(), "merged redirect URIs into oauth client drop-in"); - Ok(true) -} - -fn random_secret() -> String { - let mut bytes = [0u8; 32]; - rand::thread_rng().fill_bytes(&mut bytes); - bytes.iter().map(|b| format!("{b:02x}")).collect() -} - -fn write_private(path: &Path, data: &[u8]) -> Result<()> { - let io = |source| Error::Io { - path: path.to_path_buf(), - source, - }; - let mut opts = std::fs::OpenOptions::new(); - opts.write(true).create_new(true); - #[cfg(unix)] - { - use std::os::unix::fs::OpenOptionsExt; - opts.mode(0o640); - } - let mut f = opts.open(path).map_err(io)?; - f.write_all(data).map_err(io)?; - f.write_all(b"\n").map_err(io)?; - f.sync_all().map_err(io)?; - Ok(()) -} - -fn harden_dropin_dir(dir: &Path) { - #[cfg(unix)] - { - use std::os::unix::fs::{chown, PermissionsExt}; - if let Err(err) = std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o750)) { - tracing::warn!(path = %dir.display(), error = %err, "could not set drop-in dir mode 0750"); - } - if let Some(gid) = bigfred_gid() { - if let Err(err) = chown(dir, None, Some(gid)) { - tracing::warn!(path = %dir.display(), error = %err, "could not chown drop-in dir to bigfred — BigFred may not read it"); - } - } else { - tracing::warn!(path = %dir.display(), "bigfred group not found — drop-in dir stays root-owned; BigFred may return invalid_client"); - } - if let Some(parent) = dir.parent() { - if let Err(err) = - std::fs::set_permissions(parent, std::fs::Permissions::from_mode(0o750)) - { - tracing::warn!(path = %parent.display(), error = %err, "could not set drop-in parent dir mode 0750"); - } - if let Some(gid) = bigfred_gid() { - if let Err(err) = chown(parent, None, Some(gid)) { - tracing::warn!(path = %parent.display(), error = %err, "could not chown drop-in parent dir to bigfred"); - } - } - } - } - #[cfg(not(unix))] - { - let _ = dir; - } -} - -fn harden_dropin_file(path: &Path) { - #[cfg(unix)] - { - use std::os::unix::fs::{chown, PermissionsExt}; - if let Err(err) = std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o640)) { - tracing::warn!(path = %path.display(), error = %err, "could not set drop-in mode 0640"); - } - if let Some(gid) = bigfred_gid() { - if let Err(err) = chown(path, None, Some(gid)) { - tracing::warn!(path = %path.display(), error = %err, "could not chown drop-in to bigfred — BigFred may return invalid_client"); - } - } else { - tracing::warn!(path = %path.display(), "bigfred group not found — drop-in stays root-owned; BigFred may return invalid_client"); - } - } - #[cfg(not(unix))] - { - let _ = path; - } -} - -fn bigfred_gid() -> Option { - use std::sync::OnceLock; - static GID: OnceLock> = OnceLock::new(); - *GID.get_or_init(|| { - let Ok(text) = std::fs::read_to_string("/etc/group") else { - tracing::warn!("could not read /etc/group — cannot resolve bigfred gid"); - return None; - }; - for line in text.lines() { - let mut parts = line.split(':'); - if parts.next() != Some("bigfred") { - continue; - } - let _passwd = parts.next(); - let Some(gid_str) = parts.next() else { - tracing::warn!( - line, - "malformed bigfred entry in /etc/group — missing gid field" - ); - continue; - }; - match gid_str.parse::() { - Ok(gid) => return Some(gid), - Err(err) => { - tracing::warn!(line, error = %err, "malformed bigfred gid in /etc/group"); - continue; - } - } - } - tracing::warn!("bigfred group not found in /etc/group"); - None - }) -} - -fn touch_for_reload(path: &Path) { - if let Ok(f) = std::fs::OpenOptions::new().write(true).open(path) { - let _ = f.set_modified(std::time::SystemTime::now()); - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::config::test_config; - - #[test] - fn secret_is_64_hex_chars() { - let s = random_secret(); - assert_eq!(s.len(), 64); - assert!(s.chars().all(|c| c.is_ascii_hexdigit())); - assert_ne!(s, random_secret()); - } - - #[test] - fn ensure_writes_once_and_reads_back() { - let tmp = std::env::temp_dir().join(format!("bf-oauth-{}", uuid::Uuid::new_v4())); - let cfg = test_config(tmp.join("oauth-clients")); - - let path = ensure_dropin(&cfg).expect("seed"); - let first = load_secret(&cfg).expect("read").expect("some"); - ensure_dropin(&cfg).expect("idempotent"); - let second = load_secret(&cfg).expect("read").expect("some"); - - assert_eq!(first, second); - assert!(path.ends_with("bigfred-wizard.json")); - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777; - assert_eq!(mode, 0o640); - } - let _ = std::fs::remove_dir_all(&tmp); - } - - #[test] - fn sync_redirect_uris_preserves_share_session() { - let tmp = std::env::temp_dir().join(format!("bf-oauth-{}", uuid::Uuid::new_v4())); - let cfg = test_config(tmp.join("oauth-clients")); - let path = cfg.oauth_client_path(); - std::fs::create_dir_all(path.parent().unwrap()).unwrap(); - let file = OAuthClientFile { - client_id: cfg.sso_client_id.clone(), - client_secret: "aabbccdd".repeat(8), - display_name: "BigFred Wizard".to_string(), - redirect_uris: vec!["http://example.test/cb".to_string()], - cors_enabled: false, - cors_origins: Vec::new(), - enabled: true, - share_session: true, - }; - std::fs::write(&path, serde_json::to_vec_pretty(&file).unwrap()).unwrap(); - - ensure_dropin(&cfg).expect("ensure"); - let raw = std::fs::read(&path).unwrap(); - let parsed: OAuthClientFile = serde_json::from_slice(&raw).unwrap(); - assert!( - parsed.share_session, - "shareSession must survive redirect URI merge" - ); - assert!(parsed - .redirect_uris - .iter() - .any(|u| u == "http://example.test/cb")); - - let _ = std::fs::remove_dir_all(&tmp); - } -} diff --git a/crates/bigfred-client/src/proxy.rs b/crates/bigfred-client/src/proxy.rs deleted file mode 100644 index 8285e96..0000000 --- a/crates/bigfred-client/src/proxy.rs +++ /dev/null @@ -1,78 +0,0 @@ -//! Same-origin HTTP reverse proxy to BigFred (no WebSocket upgrades). - -use crate::error::{Error, Result}; -use crate::wire::IMPERSONATE_HEADER; - -/// Request headers forwarded upstream. Everything else (cookies, host, -/// connection controls) is dropped on purpose. -const FORWARDED_REQUEST_HEADERS: [&str; 3] = ["authorization", "content-type", "accept"]; - -/// Response headers copied back to the caller. -const FORWARDED_RESPONSE_HEADERS: [&str; 2] = ["content-type", "cache-control"]; - -/// Headers the host should copy from the inbound HTTP request. -pub fn forwarded_request_header_names() -> impl Iterator { - FORWARDED_REQUEST_HEADERS - .iter() - .copied() - .chain(std::iter::once(IMPERSONATE_HEADER)) -} - -/// One proxied response. The host converts this into its HTTP framework type. -#[derive(Debug)] -pub struct ForwardResponse { - pub status: u16, - pub headers: Vec<(String, Vec)>, - pub body: Vec, -} - -/// Forwards `method path?query` to `api_base`. `headers` must already be -/// filtered to the allowlist (see [`forwarded_request_header_names`]). -pub async fn forward( - client: &reqwest::Client, - api_base: &str, - method: &str, - path: &str, - query: Option<&str>, - headers: &[(&str, &[u8])], - body: &[u8], -) -> Result { - let mut url = format!("{api_base}{path}"); - if let Some(query) = query { - url.push('?'); - url.push_str(query); - } - - let method = reqwest::Method::from_bytes(method.as_bytes()).unwrap_or(reqwest::Method::GET); - let mut out = client.request(method, &url); - for (name, value) in headers { - out = out.header(*name, *value); - } - if !body.is_empty() { - out = out.body(body.to_vec()); - } - - let res = out - .send() - .await - .map_err(|err| Error::ProxyUnreachable(err.to_string()))?; - - let status = res.status().as_u16(); - let mut headers = Vec::new(); - for name in FORWARDED_RESPONSE_HEADERS { - if let Some(value) = res.headers().get(name) { - headers.push((name.to_string(), value.as_bytes().to_vec())); - } - } - let body = res - .bytes() - .await - .map_err(|err| Error::ProxyReadFailed(err.to_string()))? - .to_vec(); - - Ok(ForwardResponse { - status, - headers, - body, - }) -} diff --git a/crates/bigfred-client/src/wire.rs b/crates/bigfred-client/src/wire.rs deleted file mode 100644 index d1688d2..0000000 --- a/crates/bigfred-client/src/wire.rs +++ /dev/null @@ -1,90 +0,0 @@ -//! Wire types shared with BigFred (OAuth, catalogue, dcc-bus acks). - -use serde::{Deserialize, Serialize}; - -/// Impersonation header understood by BigFred's `MaybeImpersonate`. -pub const IMPERSONATE_HEADER: &str = "x-bigfred-impersonate-as"; - -/// dcc-bus frame type used for ops-track function pulses. -pub const FRAME_SET_FUNCTION: &str = "loco.setFunction"; - -/// `contract.EnvelopeWire` on the wire. -#[derive(Debug, Serialize, Deserialize)] -pub struct Envelope { - #[serde(rename = "type")] - pub kind: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub id: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub payload: Option, -} - -/// One configuration variable, mirroring `protocol.CVEntry`. -#[derive(Debug, Clone, Copy, Serialize, Deserialize)] -pub struct CvEntry { - pub cv: u16, - pub value: u8, -} - -/// `protocol.AckPayload` (only the fields the wizard reads back). -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct Ack { - #[serde(default)] - pub ok: bool, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub error: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub cvs: Option>, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub loco_address: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub long_address: Option, -} - -/// What programming status reports (socket + station). -#[derive(Debug, Clone, Default, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct Status { - pub connected: bool, - #[serde(skip_serializing_if = "Option::is_none")] - pub command_station_id: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub command_station_name: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub default_programming_track_output: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub last_error: Option, - pub reconnects: u64, - /// Impersonated drive socket is up (F2 / ops track). - pub drive_connected: bool, - /// Participant the drive socket is impersonating, when connected. - #[serde(skip_serializing_if = "Option::is_none")] - pub drive_as: Option, -} - -/// One row of `GET /api/v1/command-stations/catalogue`. -#[derive(Debug, Clone, Default, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct CommandStation { - pub id: u64, - #[serde(default)] - pub name: String, - #[serde(default)] - pub kind: String, - #[serde(default)] - pub programming: bool, - #[serde(default)] - pub hide_in_throttle: bool, - #[serde(default)] - pub default_programming_track_output: String, -} - -/// Successful `POST /api/v1/auth/oauth/token` body. -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct TokenResponse { - pub access_token: String, - pub token_type: String, - pub expires_at: String, -}