From bc3237287f43776e74744d56795f6452f866feab Mon Sep 17 00:00:00 2001 From: naps62-yolo Date: Tue, 26 May 2026 18:23:14 +0000 Subject: [PATCH] feat(maestro): maestro-web standalone binary + reverse-proxy (NAP-241) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New crate crates/maestro-web/: axum server that * embeds the web/dist SPA bundle via include_dir! (graceful placeholder when pnpm build hasn't run yet — keeps fresh checkouts compiling) * reverse-proxies /api/* HTTP to a co-located daemon (default http://127.0.0.1:7070) via reqwest, stripping hop-by-hop headers * reverse-proxies /api/attach/:id and /api/events WebSockets via tokio_tungstenite::connect_async + a tokio task pumping both directions * reads bind_addr / daemon_url / dev_proxy_target from the new [web] config section; env-var overrides MAESTRO_WEB_BIND_ADDR / _DAEMON_URL / _DEV_PROXY_TARGET Integration tests spin up a mock daemon + maestro-web pair on random ports and verify HTTP forward, SPA fallback, WS echo, and 502 on unreachable daemon. Adds a top-level Justfile (`just web` = pnpm install + pnpm build + cargo build), crates/maestro-web/README.md (config + trust model + dev workflow), and a docs/INSTALL.md section on optional web UI install. Co-Authored-By: Claude Opus 4.7 (1M context) --- Justfile | 29 +++ crates/maestro-web/Cargo.toml | 36 +++ crates/maestro-web/README.md | 83 +++++++ crates/maestro-web/src/config.rs | 185 +++++++++++++++ crates/maestro-web/src/lib.rs | 15 ++ crates/maestro-web/src/main.rs | 70 ++++++ crates/maestro-web/src/proxy.rs | 286 ++++++++++++++++++++++++ crates/maestro-web/src/server.rs | 48 ++++ crates/maestro-web/src/static_bundle.rs | 105 +++++++++ crates/maestro-web/tests/proxy.rs | 151 +++++++++++++ docs/INSTALL.md | 24 ++ 11 files changed, 1032 insertions(+) create mode 100644 Justfile create mode 100644 crates/maestro-web/Cargo.toml create mode 100644 crates/maestro-web/README.md create mode 100644 crates/maestro-web/src/config.rs create mode 100644 crates/maestro-web/src/lib.rs create mode 100644 crates/maestro-web/src/main.rs create mode 100644 crates/maestro-web/src/proxy.rs create mode 100644 crates/maestro-web/src/server.rs create mode 100644 crates/maestro-web/src/static_bundle.rs create mode 100644 crates/maestro-web/tests/proxy.rs diff --git a/Justfile b/Justfile new file mode 100644 index 0000000..473bc89 --- /dev/null +++ b/Justfile @@ -0,0 +1,29 @@ +# Common dev / build entry points. Run `just` with no args to list. + +# Build the maestro-web binary together with the SPA bundle it embeds. +web: + pnpm -C web install --frozen-lockfile + pnpm -C web build + cargo build -p maestro-web --release + +# Run the web bundle in dev mode against a local daemon — Vite serves the +# frontend with HMR, and `/api/*` is proxied directly to the daemon at +# 127.0.0.1:7070 (or wherever $MAESTRO_REGISTRY points; see vite.config.ts). +web-dev: + pnpm -C web install + pnpm -C web dev + +# Typecheck the frontend without building. +web-typecheck: + pnpm -C web install --frozen-lockfile + pnpm -C web typecheck + +# Full workspace build + tests. +test: + cargo fmt --all -- --check + cargo clippy --all-targets -- -D warnings + cargo test --all + +# Format every Rust source. +fmt: + cargo fmt --all diff --git a/crates/maestro-web/Cargo.toml b/crates/maestro-web/Cargo.toml new file mode 100644 index 0000000..50d3787 --- /dev/null +++ b/crates/maestro-web/Cargo.toml @@ -0,0 +1,36 @@ +[package] +name = "maestro-web" +version.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true +publish.workspace = true +description = "Standalone Maestro web UI: serves the SPA bundle and reverse-proxies to a co-located maestrod" + +[lib] +path = "src/lib.rs" + +[[bin]] +name = "maestro-web" +path = "src/main.rs" + +[dependencies] +axum.workspace = true +bytes.workspace = true +clap.workspace = true +dirs.workspace = true +futures-util.workspace = true +include_dir.workspace = true +maestro-config.workspace = true +mime_guess.workspace = true +reqwest.workspace = true +thiserror.workspace = true +tokio.workspace = true +tokio-tungstenite = { workspace = true, features = ["rustls-tls-webpki-roots"] } +tracing.workspace = true +tracing-subscriber.workspace = true + +[dev-dependencies] +reqwest.workspace = true +serde_json.workspace = true +tempfile.workspace = true diff --git a/crates/maestro-web/README.md b/crates/maestro-web/README.md new file mode 100644 index 0000000..99edd2c --- /dev/null +++ b/crates/maestro-web/README.md @@ -0,0 +1,83 @@ +# maestro-web + +Standalone HTTP server that hosts the Maestro browser UI and reverse-proxies +to a co-located `maestrod`. + +``` +browser ──HTTP──▶ maestro-web (:8080) ──HTTP/WS──▶ maestrod (:7070) + │ + └── / serves the embedded SPA (web/dist) + /api/... proxied unmodified + /api/attach/:id, /api/events proxied as WebSocket +``` + +## Trust model + +`maestro-web` does not authenticate anyone. Whoever can reach the bind address +can use it. Pick the bind accordingly: + +| Bind | Reach | Use when | +| --------------------- | ---------------------------------- | --------------------------- | +| `127.0.0.1:8080` | Local users on this host | Default. Same as tmux. | +| `:8080` | Devices on your WireGuard mesh | Phone/tablet over VPN. | +| `:8080` | Devices on your trusted LAN | Home / lab networks. | +| Public IP | Don't | — add real auth first. | + +## Config + +The binary reads `~/.config/maestro/config.toml`. The `[web]` section: + +```toml +[web] +bind_addr = "127.0.0.1:8080" # default +daemon_url = "http://127.0.0.1:7070" # default +# dev_proxy_target = "http://127.0.0.1:5173" # only when developing the frontend +``` + +Env-var overrides (all optional): + +- `MAESTRO_WEB_BIND_ADDR` +- `MAESTRO_WEB_DAEMON_URL` +- `MAESTRO_WEB_DEV_PROXY_TARGET` +- `MAESTRO_CONFIG` — override the unified config-file path. + +## Build + +The SPA bundle lives in `../../web` and is embedded into the binary at compile +time via `include_dir!`. Build the frontend first: + +```sh +pnpm -C web install +pnpm -C web build +cargo build -p maestro-web --release +``` + +Or, from the repo root: + +```sh +just web +``` + +A fresh checkout compiles even without `pnpm build` (an empty `web/dist` +exists in-tree); the binary just serves a placeholder until the bundle is +built. + +## Run + +```sh +target/release/maestro-web +``` + +Then point a browser at `http://127.0.0.1:8080/`. + +## Development workflow for the frontend + +Use Vite directly so you get HMR. From `web/`: + +```sh +pnpm dev +``` + +Vite serves on `:5173` and proxies `/api/*` straight to `maestrod` at +`127.0.0.1:7070` (override with `MAESTRO_REGISTRY=...`). You don't need to +rebuild the Rust binary while iterating on the frontend. diff --git a/crates/maestro-web/src/config.rs b/crates/maestro-web/src/config.rs new file mode 100644 index 0000000..4cc11b1 --- /dev/null +++ b/crates/maestro-web/src/config.rs @@ -0,0 +1,185 @@ +//! `maestro-web` view over the unified Maestro config. + +use std::net::SocketAddr; +use std::path::{Path, PathBuf}; + +use maestro_config::MaestroConfigFile; + +pub use maestro_config::{default_config_path, resolve_config_path, WebSection, CONFIG_PATH_ENV}; + +/// Default bind address when neither env nor file specify one. +pub const DEFAULT_BIND_ADDR: &str = "127.0.0.1:8080"; +/// Default daemon URL `maestro-web` reverse-proxies to. +pub const DEFAULT_DAEMON_URL: &str = "http://127.0.0.1:7070"; + +pub const ENV_WEB_BIND_ADDR: &str = "MAESTRO_WEB_BIND_ADDR"; +pub const ENV_WEB_DAEMON_URL: &str = "MAESTRO_WEB_DAEMON_URL"; +pub const ENV_WEB_DEV_PROXY_TARGET: &str = "MAESTRO_WEB_DEV_PROXY_TARGET"; + +fn default_bind_addr() -> SocketAddr { + DEFAULT_BIND_ADDR.parse().expect("valid default bind_addr") +} + +#[derive(Debug, thiserror::Error)] +pub enum WebConfigError { + #[error(transparent)] + Inner(#[from] maestro_config::ConfigError), + #[error("invalid {env} ({input:?}): {source}")] + InvalidBindAddr { + env: &'static str, + input: String, + source: std::net::AddrParseError, + }, +} + +/// Resolved runtime configuration for the `maestro-web` binary. +#[derive(Debug, Clone)] +pub struct WebConfig { + pub bind_addr: SocketAddr, + /// Daemon HTTP base URL — e.g. `http://127.0.0.1:7070`. Always a base + /// without trailing slash and without a path. + pub daemon_url: String, + /// Optional dev override: when set, `/` (and only `/`) is proxied here + /// instead of being served from the embedded bundle. Set to the Vite dev + /// server URL during frontend development. + pub dev_proxy_target: Option, +} + +#[derive(Debug, Clone, Default)] +pub struct EnvOverrides { + pub bind_addr: Option, + pub daemon_url: Option, + pub dev_proxy_target: Option, +} + +impl EnvOverrides { + pub fn from_env() -> Self { + Self { + bind_addr: std::env::var(ENV_WEB_BIND_ADDR).ok(), + daemon_url: std::env::var(ENV_WEB_DAEMON_URL).ok(), + dev_proxy_target: std::env::var(ENV_WEB_DEV_PROXY_TARGET).ok(), + } + } +} + +/// Resolve the runtime configuration. Missing-file is fine: every field has +/// a default and the binary is happy to run with no config.toml at all. +pub fn load_config(path: &Path, env: EnvOverrides) -> Result { + let file = if path.exists() { + MaestroConfigFile::load(path)? + } else { + MaestroConfigFile::default() + }; + resolve_with(file.web, env) +} + +fn resolve_with(web: WebSection, env: EnvOverrides) -> Result { + let bind_addr = match env.bind_addr { + Some(raw) => raw + .parse() + .map_err(|source| WebConfigError::InvalidBindAddr { + env: ENV_WEB_BIND_ADDR, + input: raw, + source, + })?, + None => web.bind_addr.unwrap_or_else(default_bind_addr), + }; + + let daemon_url = env + .daemon_url + .or(web.daemon_url) + .unwrap_or_else(|| DEFAULT_DAEMON_URL.to_string()) + .trim_end_matches('/') + .to_string(); + + let dev_proxy_target = env + .dev_proxy_target + .or(web.dev_proxy_target) + .map(|s| s.trim_end_matches('/').to_string()); + + Ok(WebConfig { + bind_addr, + daemon_url, + dev_proxy_target, + }) +} + +/// Standard state dir (mostly so callers have a place to put logs — the +/// binary itself is stateless). +pub fn default_state_dir() -> Result { + let base = dirs::state_dir() + .or_else(dirs::data_local_dir) + .ok_or(maestro_config::ConfigError::NoConfigDir)?; + Ok(base.join("maestro-web")) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn defaults_when_file_missing() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("does-not-exist.toml"); + let cfg = load_config(&path, EnvOverrides::default()).unwrap(); + assert_eq!(cfg.bind_addr, default_bind_addr()); + assert_eq!(cfg.daemon_url, "http://127.0.0.1:7070"); + assert!(cfg.dev_proxy_target.is_none()); + } + + #[test] + fn reads_web_section_from_file() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("config.toml"); + std::fs::write( + &path, + r#" +[web] +bind_addr = "127.0.0.1:9090" +daemon_url = "http://127.0.0.1:9070" +dev_proxy_target = "http://127.0.0.1:5173" +"#, + ) + .unwrap(); + let cfg = load_config(&path, EnvOverrides::default()).unwrap(); + assert_eq!(cfg.bind_addr, "127.0.0.1:9090".parse().unwrap()); + assert_eq!(cfg.daemon_url, "http://127.0.0.1:9070"); + assert_eq!( + cfg.dev_proxy_target.as_deref(), + Some("http://127.0.0.1:5173") + ); + } + + #[test] + fn env_beats_file() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("config.toml"); + std::fs::write( + &path, + r#" +[web] +bind_addr = "127.0.0.1:9090" +daemon_url = "http://127.0.0.1:9070" +"#, + ) + .unwrap(); + let env = EnvOverrides { + bind_addr: Some("0.0.0.0:8080".into()), + daemon_url: Some("http://daemon:7070".into()), + dev_proxy_target: None, + }; + let cfg = load_config(&path, env).unwrap(); + assert_eq!(cfg.bind_addr, "0.0.0.0:8080".parse().unwrap()); + assert_eq!(cfg.daemon_url, "http://daemon:7070"); + } + + #[test] + fn daemon_url_strips_trailing_slash() { + let env = EnvOverrides { + daemon_url: Some("http://daemon:7070/".into()), + ..Default::default() + }; + let cfg = resolve_with(WebSection::default(), env).unwrap(); + assert_eq!(cfg.daemon_url, "http://daemon:7070"); + } +} diff --git a/crates/maestro-web/src/lib.rs b/crates/maestro-web/src/lib.rs new file mode 100644 index 0000000..837423b --- /dev/null +++ b/crates/maestro-web/src/lib.rs @@ -0,0 +1,15 @@ +//! `maestro-web` — standalone HTTP server that hosts the Maestro browser UI +//! and reverse-proxies API calls to a co-located `maestrod`. +//! +//! See `docs/superpowers/specs/2026-05-26-connection-orchestration.md` §UI +//! hosting for the architectural decisions. + +#![deny(missing_debug_implementations)] + +pub mod config; +pub mod proxy; +pub mod server; +pub mod static_bundle; + +pub use config::{load_config, EnvOverrides, WebConfig, WebConfigError}; +pub use server::router; diff --git a/crates/maestro-web/src/main.rs b/crates/maestro-web/src/main.rs new file mode 100644 index 0000000..9e42a72 --- /dev/null +++ b/crates/maestro-web/src/main.rs @@ -0,0 +1,70 @@ +//! `maestro-web` binary entry point. See `lib.rs` for the architecture. + +use std::error::Error; +use std::path::PathBuf; +use std::process::ExitCode; + +use clap::Parser; +use maestro_web::config::{default_config_path, EnvOverrides}; +use maestro_web::{load_config, router}; +use tokio::net::TcpListener; +use tracing_subscriber::EnvFilter; + +#[derive(Debug, Parser)] +#[command( + name = "maestro-web", + version, + about = "Serves the Maestro web UI and reverse-proxies to a co-located maestrod" +)] +struct Cli { + /// Override the unified Maestro config-file path. + #[arg(long, env = "MAESTRO_CONFIG")] + config: Option, +} + +#[tokio::main] +async fn main() -> ExitCode { + let cli = Cli::parse(); + let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info")); + tracing_subscriber::fmt().with_env_filter(filter).init(); + + match run(cli.config).await { + Ok(()) => ExitCode::SUCCESS, + Err(e) => { + tracing::error!(error = %e, "maestro-web failed"); + eprintln!("maestro-web: {e}"); + ExitCode::FAILURE + } + } +} + +async fn run(config_override: Option) -> Result<(), Box> { + let path = match config_override { + Some(p) => p, + None => default_config_path()?, + }; + let cfg = load_config(&path, EnvOverrides::from_env())?; + tracing::info!( + bind = %cfg.bind_addr, + daemon = %cfg.daemon_url, + dev_proxy = ?cfg.dev_proxy_target, + "loaded config", + ); + if cfg.dev_proxy_target.is_some() { + tracing::warn!( + "dev_proxy_target is set in config but not yet honored by the server \ + (NAP-241 v1 ships only the embedded-bundle path)" + ); + } + + let router = router(&cfg); + let listener = TcpListener::bind(cfg.bind_addr).await?; + tracing::info!(addr = %listener.local_addr()?, "serving"); + axum::serve(listener, router) + .with_graceful_shutdown(async { + let _ = tokio::signal::ctrl_c().await; + tracing::info!("ctrl-c received — shutting down"); + }) + .await?; + Ok(()) +} diff --git a/crates/maestro-web/src/proxy.rs b/crates/maestro-web/src/proxy.rs new file mode 100644 index 0000000..508eca3 --- /dev/null +++ b/crates/maestro-web/src/proxy.rs @@ -0,0 +1,286 @@ +//! Reverse-proxy primitives: small hand-written hyper-client handler for +//! `/api/...` HTTP, and an axum WebSocket pump for `/api/attach/:id` + +//! `/api/events`. +//! +//! Keeps dependencies minimal — reqwest for HTTP, tokio_tungstenite for the +//! upstream WS leg. ~150 LOC total. + +use std::sync::Arc; + +use axum::body::Body; +use axum::extract::ws::{Message as AxMessage, WebSocket}; +use axum::extract::{Request, State}; +use axum::http::{header, HeaderMap, HeaderValue, StatusCode, Uri}; +use axum::response::{IntoResponse, Response}; +use futures_util::{SinkExt, StreamExt}; +use tokio_tungstenite::tungstenite::Message as WsMessage; + +#[derive(Debug, Clone)] +pub struct UpstreamConfig { + /// HTTP base of the daemon — `http://127.0.0.1:7070`. + pub daemon_http_base: Arc, + /// WS base of the daemon — derived from `daemon_http_base` by replacing + /// the scheme. Cached so we don't redo it per request. + pub daemon_ws_base: Arc, +} + +impl UpstreamConfig { + pub fn from_daemon_url(daemon_url: &str) -> Self { + let http_base = daemon_url.trim_end_matches('/').to_string(); + let ws_base = if let Some(rest) = http_base.strip_prefix("https://") { + format!("wss://{rest}") + } else if let Some(rest) = http_base.strip_prefix("http://") { + format!("ws://{rest}") + } else { + // Caller passed something already scheme-less or pre-wrapped; trust + // it. Validation happens at config-load time. + http_base.clone() + }; + Self { + daemon_http_base: Arc::new(http_base), + daemon_ws_base: Arc::new(ws_base), + } + } +} + +/// Shared state plumbed into the proxy handlers. +#[derive(Debug, Clone)] +pub struct ProxyState { + pub upstream: UpstreamConfig, + pub client: reqwest::Client, +} + +impl ProxyState { + pub fn new(upstream: UpstreamConfig) -> Self { + Self { + upstream, + client: reqwest::Client::builder() + .redirect(reqwest::redirect::Policy::none()) + .build() + .expect("reqwest client"), + } + } +} + +// --------------------------------------------------------------------------- +// HTTP proxy +// --------------------------------------------------------------------------- + +/// Forward an HTTP request to the upstream daemon and stream the response back. +pub async fn proxy_http(State(state): State, req: Request) -> Response { + let (parts, body) = req.into_parts(); + let upstream_uri = build_uri(&state.upstream.daemon_http_base, &parts.uri); + + let body_bytes = match axum::body::to_bytes(body, usize::MAX).await { + Ok(b) => b, + Err(e) => return bad_gateway(&format!("read body: {e}")), + }; + + let mut upstream_req = state + .client + .request(parts.method.clone(), upstream_uri.as_str()) + .body(body_bytes.to_vec()); + + for (name, value) in filter_request_headers(&parts.headers).iter() { + upstream_req = upstream_req.header(name, value); + } + + let upstream_resp = match upstream_req.send().await { + Ok(r) => r, + Err(e) => return bad_gateway(&format!("upstream send: {e}")), + }; + + let status = upstream_resp.status(); + let headers = filter_response_headers(upstream_resp.headers()); + let body = match upstream_resp.bytes().await { + Ok(b) => b, + Err(e) => return bad_gateway(&format!("upstream body: {e}")), + }; + + let mut resp = Response::builder().status(status); + for (name, value) in headers.iter() { + resp = resp.header(name, value); + } + resp.body(Body::from(body)) + .unwrap_or_else(|e| bad_gateway(&format!("build response: {e}"))) +} + +fn build_uri(base: &str, original: &Uri) -> String { + let mut out = base.to_string(); + out.push_str(original.path()); + if let Some(q) = original.query() { + out.push('?'); + out.push_str(q); + } + out +} + +/// Headers we strip before forwarding upstream / downstream. `host` must be +/// dropped (reqwest will set it correctly for the upstream); `content-length` +/// is regenerated by the client; hop-by-hop headers per RFC 7230 §6.1 should +/// not be relayed. +const HOP_BY_HOP: &[&str] = &[ + "connection", + "keep-alive", + "proxy-authenticate", + "proxy-authorization", + "te", + "trailers", + "transfer-encoding", + "upgrade", +]; + +fn filter_request_headers(headers: &HeaderMap) -> HeaderMap { + let mut out = HeaderMap::new(); + for (name, value) in headers { + let n = name.as_str(); + if n.eq_ignore_ascii_case("host") || n.eq_ignore_ascii_case("content-length") { + continue; + } + if HOP_BY_HOP.iter().any(|h| n.eq_ignore_ascii_case(h)) { + continue; + } + out.append(name.clone(), value.clone()); + } + out +} + +fn filter_response_headers(headers: &reqwest::header::HeaderMap) -> HeaderMap { + let mut out = HeaderMap::new(); + for (name, value) in headers { + let n = name.as_str(); + if HOP_BY_HOP.iter().any(|h| n.eq_ignore_ascii_case(h)) { + continue; + } + if let (Ok(n), Ok(v)) = ( + axum::http::HeaderName::from_bytes(name.as_str().as_bytes()), + HeaderValue::from_bytes(value.as_bytes()), + ) { + out.append(n, v); + } + } + out +} + +fn bad_gateway(msg: &str) -> Response { + tracing::warn!(error = msg, "proxy: bad gateway"); + // Plain JSON without a serde_json dep — the message is short and we control + // every byte we write, so manual escaping of `"` + `\` is enough here. + let escaped = msg.replace('\\', "\\\\").replace('"', "\\\""); + let body = format!(r#"{{"error":{{"code":"proxy_failed","message":"{escaped}"}}}}"#); + ( + StatusCode::BAD_GATEWAY, + [(header::CONTENT_TYPE, "application/json")], + body, + ) + .into_response() +} + +// --------------------------------------------------------------------------- +// WebSocket proxy +// --------------------------------------------------------------------------- + +/// Open an upstream WS connection at `daemon_ws_base + path?query` and pump +/// frames in both directions until either side closes. Used for +/// `/api/attach/:id` and `/api/events`. +pub async fn pump_ws(client: WebSocket, upstream_url: String) { + let (upstream, _) = match tokio_tungstenite::connect_async(&upstream_url).await { + Ok(pair) => pair, + Err(e) => { + tracing::warn!(url = %upstream_url, error = %e, "proxy: upstream WS connect failed"); + // Drop the client socket — there's nothing useful to send it. + return; + } + }; + let (mut client_sink, mut client_stream) = client.split(); + let (mut up_sink, mut up_stream) = upstream.split(); + + let client_to_up = async { + while let Some(msg) = client_stream.next().await { + let Ok(msg) = msg else { break }; + let frame = match msg { + AxMessage::Text(t) => WsMessage::Text(t), + AxMessage::Binary(b) => WsMessage::Binary(b), + AxMessage::Ping(p) => WsMessage::Ping(p), + AxMessage::Pong(p) => WsMessage::Pong(p), + AxMessage::Close(c) => { + let _ = up_sink + .send(WsMessage::Close(c.map(|cf| { + tokio_tungstenite::tungstenite::protocol::CloseFrame { + code: cf.code.into(), + reason: cf.reason, + } + }))) + .await; + break; + } + }; + if up_sink.send(frame).await.is_err() { + break; + } + } + }; + + let up_to_client = async { + while let Some(msg) = up_stream.next().await { + let Ok(msg) = msg else { break }; + let frame = match msg { + WsMessage::Text(t) => AxMessage::Text(t), + WsMessage::Binary(b) => AxMessage::Binary(b), + WsMessage::Ping(p) => AxMessage::Ping(p), + WsMessage::Pong(p) => AxMessage::Pong(p), + WsMessage::Close(c) => { + let _ = client_sink + .send(AxMessage::Close(c.map(|cf| { + axum::extract::ws::CloseFrame { + code: cf.code.into(), + reason: cf.reason.to_string().into(), + } + }))) + .await; + break; + } + WsMessage::Frame(_) => continue, + }; + if client_sink.send(frame).await.is_err() { + break; + } + } + }; + + // Stop as soon as either side hangs up. + tokio::select! { + _ = client_to_up => {} + _ = up_to_client => {} + } +} + +/// Compose the upstream WS URL from the base + path + query of the inbound +/// request. +pub fn upstream_ws_url(state: &ProxyState, uri: &Uri) -> String { + let mut out = state.upstream.daemon_ws_base.as_str().to_string(); + out.push_str(uri.path()); + if let Some(q) = uri.query() { + out.push('?'); + out.push_str(q); + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn ws_base_swaps_http_scheme() { + let u = UpstreamConfig::from_daemon_url("http://127.0.0.1:7070/"); + assert_eq!(&*u.daemon_http_base, "http://127.0.0.1:7070"); + assert_eq!(&*u.daemon_ws_base, "ws://127.0.0.1:7070"); + } + + #[test] + fn ws_base_swaps_https_scheme() { + let u = UpstreamConfig::from_daemon_url("https://daemon.example/"); + assert_eq!(&*u.daemon_ws_base, "wss://daemon.example"); + } +} diff --git a/crates/maestro-web/src/server.rs b/crates/maestro-web/src/server.rs new file mode 100644 index 0000000..74f6832 --- /dev/null +++ b/crates/maestro-web/src/server.rs @@ -0,0 +1,48 @@ +//! HTTP server wiring — composes the static bundle, the HTTP proxy, and the +//! WS proxy into a single axum router. + +use axum::extract::{Request, State, WebSocketUpgrade}; +use axum::response::{IntoResponse, Response}; +use axum::routing::{any, get}; +use axum::Router; + +use crate::config::WebConfig; +use crate::proxy::{pump_ws, upstream_ws_url, ProxyState, UpstreamConfig}; +use crate::static_bundle; + +/// Build the public router. The returned router is ready to hand to +/// `axum::serve`. +pub fn router(config: &WebConfig) -> Router { + let upstream = UpstreamConfig::from_daemon_url(&config.daemon_url); + let proxy_state = ProxyState::new(upstream); + + // Mount each route at its real path (not under `nest`, which would strip + // the `/api` prefix before the proxy could forward it). WS routes go + // first so the HTTP fallback doesn't shadow them. + Router::new() + .route("/api/attach/:id", any(ws_proxy)) + .route("/api/events", any(ws_proxy)) + .route("/", get(serve_index)) + .fallback(catch_all) + .with_state(proxy_state) +} + +/// Default handler: `/api/*` goes to the daemon, anything else falls through +/// to the embedded SPA bundle (with SPA-style fallback to `index.html`). +async fn catch_all(State(state): State, req: Request) -> Response { + if req.uri().path().starts_with("/api/") { + return crate::proxy::proxy_http(State(state), req).await; + } + static_bundle::serve_path(req.uri().path()) +} + +async fn ws_proxy(State(state): State, ws: WebSocketUpgrade, req: Request) -> Response { + let upstream_url = upstream_ws_url(&state, req.uri()); + ws.on_upgrade(move |socket| async move { + pump_ws(socket, upstream_url).await; + }) +} + +async fn serve_index() -> Response { + static_bundle::serve_index().into_response() +} diff --git a/crates/maestro-web/src/static_bundle.rs b/crates/maestro-web/src/static_bundle.rs new file mode 100644 index 0000000..5ac0fce --- /dev/null +++ b/crates/maestro-web/src/static_bundle.rs @@ -0,0 +1,105 @@ +//! Embedded `web/dist` SPA bundle. +//! +//! The build of `web/` is consumed at compile time via `include_dir!`. The +//! macro requires the directory to exist; an empty `web/dist/.gitkeep` is +//! checked in so a fresh checkout compiles without first running `pnpm build`. +//! The resulting binary just serves a 404 for `/` until the operator runs the +//! frontend build (see top-level `Justfile`'s `web` target). + +use axum::body::Body; +use axum::http::{header, StatusCode}; +use axum::response::{IntoResponse, Response}; +use include_dir::{include_dir, Dir}; + +static BUNDLE: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/../../web/dist"); + +/// Serve `/index.html`, falling back to a tiny placeholder when the bundle +/// hasn't been built yet. Used both for `GET /` and SPA-route fallthrough +/// (so refreshing on `#/sessions/foo` doesn't 404). +pub fn serve_index() -> Response { + match BUNDLE.get_file("index.html") { + Some(f) => html_response(f.contents()), + None => placeholder(), + } +} + +/// Serve any path beneath the bundle (e.g. `assets/index-deadbeef.js`). +pub fn serve_path(path: &str) -> Response { + let trimmed = path.trim_start_matches('/'); + if trimmed.is_empty() { + return serve_index(); + } + match BUNDLE.get_file(trimmed) { + Some(f) => { + let mime = mime_guess::from_path(trimmed).first_or_octet_stream(); + ( + [(header::CONTENT_TYPE, mime.as_ref())], + f.contents().to_vec(), + ) + .into_response() + } + // SPA fallback: any unknown path falls through to index.html so the + // client-side router can resolve the route. + None => serve_index(), + } +} + +fn html_response(bytes: &[u8]) -> Response { + ( + [(header::CONTENT_TYPE, "text/html; charset=utf-8")], + bytes.to_vec(), + ) + .into_response() +} + +fn placeholder() -> Response { + ( + StatusCode::OK, + [(header::CONTENT_TYPE, "text/html; charset=utf-8")], + Body::from(PLACEHOLDER_HTML), + ) + .into_response() +} + +const PLACEHOLDER_HTML: &str = r#" + +maestro-web — bundle not built + +

maestro-web is running

+

but the frontend bundle hasn't been built yet. Run:

+
pnpm -C web install
+pnpm -C web build
+cargo build -p maestro-web --release
+

or, from the repo root: just web.

+"#; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn placeholder_is_html() { + let resp = serve_index(); + // Either the real index or the placeholder — both must be HTML. + let ct = resp + .headers() + .get(header::CONTENT_TYPE) + .and_then(|v| v.to_str().ok()) + .unwrap_or(""); + assert!(ct.starts_with("text/html"), "ct={ct}"); + } + + #[test] + fn unknown_path_falls_back_to_index() { + let resp = serve_path("/some/missing/route"); + let ct = resp + .headers() + .get(header::CONTENT_TYPE) + .and_then(|v| v.to_str().ok()) + .unwrap_or(""); + assert!(ct.starts_with("text/html"), "ct={ct}"); + } +} diff --git a/crates/maestro-web/tests/proxy.rs b/crates/maestro-web/tests/proxy.rs new file mode 100644 index 0000000..04cad09 --- /dev/null +++ b/crates/maestro-web/tests/proxy.rs @@ -0,0 +1,151 @@ +//! Integration tests for the maestro-web reverse-proxy. +//! +//! Spins up a tiny axum "daemon" on a random port, points maestro-web at it, +//! and exercises both the HTTP and WS proxy paths end-to-end. + +use std::net::{Ipv4Addr, SocketAddr}; +use std::time::Duration; + +use axum::extract::ws::{Message as AxMessage, WebSocket, WebSocketUpgrade}; +use axum::response::IntoResponse; +use axum::routing::{any, get}; +use axum::{Json, Router}; +use futures_util::{SinkExt, StreamExt}; +use maestro_web::config::WebConfig; +use tokio::net::TcpListener; +use tokio::sync::oneshot; +use tokio_tungstenite::tungstenite::Message as WsMessage; + +async fn spawn_mock_daemon() -> (SocketAddr, oneshot::Sender<()>) { + async fn list_sessions() -> Json { + Json(serde_json::json!([{"id": "session-1"}])) + } + async fn web_info() -> Json { + Json(serde_json::json!({"daemon_id": "mock", "hostname": "mock-host"})) + } + async fn echo_ws(ws: WebSocketUpgrade) -> impl IntoResponse { + ws.on_upgrade(echo_handler) + } + async fn echo_handler(mut socket: WebSocket) { + while let Some(Ok(msg)) = socket.next().await { + match msg { + AxMessage::Text(t) => { + let _ = socket.send(AxMessage::Text(format!("echo:{t}"))).await; + } + AxMessage::Binary(b) => { + let _ = socket.send(AxMessage::Binary(b)).await; + } + AxMessage::Close(_) => break, + _ => {} + } + } + } + + let app = Router::new() + .route("/api/sessions", get(list_sessions)) + .route("/api/web/info", get(web_info)) + .route("/api/attach/:id", any(echo_ws)); + let listener = TcpListener::bind(SocketAddr::from((Ipv4Addr::LOCALHOST, 0))) + .await + .unwrap(); + let addr = listener.local_addr().unwrap(); + let (tx, rx) = oneshot::channel::<()>(); + tokio::spawn(async move { + let _ = axum::serve(listener, app) + .with_graceful_shutdown(async move { + let _ = rx.await; + }) + .await; + }); + (addr, tx) +} + +async fn spawn_web(daemon_url: String) -> (SocketAddr, oneshot::Sender<()>) { + let cfg = WebConfig { + bind_addr: SocketAddr::from((Ipv4Addr::LOCALHOST, 0)), + daemon_url, + dev_proxy_target: None, + }; + let router = maestro_web::router(&cfg); + let listener = TcpListener::bind(cfg.bind_addr).await.unwrap(); + let addr = listener.local_addr().unwrap(); + let (tx, rx) = oneshot::channel::<()>(); + tokio::spawn(async move { + let _ = axum::serve(listener, router) + .with_graceful_shutdown(async move { + let _ = rx.await; + }) + .await; + }); + (addr, tx) +} + +#[tokio::test] +async fn http_get_api_path_forwards_to_daemon() { + let (daemon_addr, _d) = spawn_mock_daemon().await; + let (web_addr, _w) = spawn_web(format!("http://{daemon_addr}")).await; + + let body: serde_json::Value = reqwest::get(format!("http://{web_addr}/api/sessions")) + .await + .unwrap() + .json() + .await + .unwrap(); + assert_eq!(body[0]["id"], "session-1"); +} + +#[tokio::test] +async fn http_get_unrelated_path_falls_back_to_spa_index() { + let (daemon_addr, _d) = spawn_mock_daemon().await; + let (web_addr, _w) = spawn_web(format!("http://{daemon_addr}")).await; + + // The bundle is empty in test builds — the placeholder index.html serves. + let resp = reqwest::get(format!("http://{web_addr}/some/route")) + .await + .unwrap(); + assert_eq!(resp.status(), reqwest::StatusCode::OK); + let ct = resp + .headers() + .get(reqwest::header::CONTENT_TYPE) + .and_then(|v| v.to_str().ok()) + .unwrap_or(""); + assert!(ct.starts_with("text/html"), "ct={ct}"); +} + +#[tokio::test] +async fn ws_api_attach_forwards_to_daemon_and_echoes() { + let (daemon_addr, _d) = spawn_mock_daemon().await; + let (web_addr, _w) = spawn_web(format!("http://{daemon_addr}")).await; + + let url = format!("ws://{web_addr}/api/attach/abc"); + let (mut ws, _) = tokio_tungstenite::connect_async(url).await.unwrap(); + ws.send(WsMessage::Text("hello".into())).await.unwrap(); + + let echoed = tokio::time::timeout(Duration::from_secs(1), ws.next()) + .await + .unwrap(); + match echoed { + Some(Ok(WsMessage::Text(t))) => assert_eq!(t, "echo:hello"), + other => panic!("unexpected frame: {other:?}"), + } +} + +#[tokio::test] +async fn proxy_returns_502_when_daemon_unreachable() { + // Bind a port, immediately drop the listener, hand the address to the + // proxy. Connections to it will be refused. + let listener = TcpListener::bind(SocketAddr::from((Ipv4Addr::LOCALHOST, 0))) + .await + .unwrap(); + let dead_addr = listener.local_addr().unwrap(); + drop(listener); + + let (web_addr, _w) = spawn_web(format!("http://{dead_addr}")).await; + + let resp = reqwest::get(format!("http://{web_addr}/api/sessions")) + .await + .unwrap(); + assert_eq!(resp.status(), reqwest::StatusCode::BAD_GATEWAY); + let body: serde_json::Value = resp.json().await.unwrap(); + assert_eq!(body["error"]["code"], "proxy_failed"); +} diff --git a/docs/INSTALL.md b/docs/INSTALL.md index cc5482c..5058a31 100644 --- a/docs/INSTALL.md +++ b/docs/INSTALL.md @@ -48,6 +48,30 @@ The wizard: Both binaries land in `~/.cargo/bin` — make sure that's on your `$PATH`. +## Adding the browser UI (optional) + +`maestro-web` is a separate binary that serves the SPA bundle and reverse- +proxies API calls to a co-located `maestrod`. Install it where you want a +browser UI: + +```sh +pnpm -C web install --frozen-lockfile +pnpm -C web build +cargo install --git https://git.naps.pt/naps62/maestro --locked maestro-web +``` + +…or, from a checkout, `just web` then `cargo install --path crates/maestro-web --locked`. + +It defaults to binding `127.0.0.1:8080` and proxying to `http://127.0.0.1:7070` +(the daemon's default `bind_addr`). The `[web]` section of +`~/.config/maestro/config.toml` overrides both — see +`crates/maestro-web/README.md`. **Trust model:** anyone who can reach the bind +address can use it; pick `127.0.0.1` for local-only, a WireGuard/LAN address +for one-segment access, never a public IP without adding auth first. + +Headless hosts (servers, dev VMs) install only `maestrod`. Hosts where you +want browser access install both. + ## Flags | Flag | Purpose |