diff --git a/CLAUDE.md b/CLAUDE.md index 152ebbd..ae38fa4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -23,6 +23,7 @@ crates/ ├── maestro-registry/ # always-on phone book + HTTP API (NAP-213, NAP-215) ├── maestro-cli/ # `maestro list/spawn/attach/kill` (NAP-216) ├── maestro-installer/ # `maestro-install` wizard: cargo install + systemd (NAP-225) +├── maestro-web/ # local web-UI front-end: assets + /api proxy (NAP-231) └── maestro-e2e/ # cross-process integration tests (NAP-217) web/ # browser client (NAP-218, plan 2) ``` diff --git a/Cargo.lock b/Cargo.lock index 0e23b15..b0444ef 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -454,6 +454,23 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "futures-sink" version = "0.3.32" @@ -473,8 +490,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" dependencies = [ "futures-core", + "futures-io", + "futures-macro", "futures-sink", "futures-task", + "memchr", "pin-project-lite", "slab", ] @@ -1013,6 +1033,21 @@ dependencies = [ "uuid", ] +[[package]] +name = "maestro-web" +version = "0.1.0" +dependencies = [ + "axum", + "futures-util", + "reqwest", + "rust-embed", + "thiserror 1.0.69", + "tokio", + "tokio-tungstenite 0.21.0", + "tracing", + "tracing-subscriber", +] + [[package]] name = "matchers" version = "0.2.0" @@ -1350,6 +1385,7 @@ dependencies = [ "cookie", "cookie_store", "futures-core", + "futures-util", "http", "http-body", "http-body-util", @@ -1369,12 +1405,14 @@ dependencies = [ "sync_wrapper", "tokio", "tokio-rustls 0.26.4", + "tokio-util", "tower", "tower-http", "tower-service", "url", "wasm-bindgen", "wasm-bindgen-futures", + "wasm-streams", "web-sys", "webpki-roots 1.0.7", ] @@ -1920,6 +1958,19 @@ dependencies = [ "tungstenite 0.24.0", ] +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + [[package]] name = "toml" version = "0.8.23" @@ -2312,6 +2363,19 @@ dependencies = [ "wasmparser", ] +[[package]] +name = "wasm-streams" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + [[package]] name = "wasmparser" version = "0.244.0" diff --git a/crates/maestro-installer/src/main.rs b/crates/maestro-installer/src/main.rs index f7fbcd2..e0ad999 100644 --- a/crates/maestro-installer/src/main.rs +++ b/crates/maestro-installer/src/main.rs @@ -24,7 +24,7 @@ use maestro_installer::{ const DEFAULT_GIT_URL: &str = "https://git.naps.pt/naps62/maestro"; const DAEMON_CRATE: &str = "maestro-daemon"; const CLI_CRATE: &str = "maestro-cli"; -const REGISTRY_CRATE: &str = "maestro-registry"; +const WEB_CRATE: &str = "maestro-web"; const SYSTEMD_PROBE: &str = "/run/systemd/system"; #[derive(Debug, Parser)] @@ -63,8 +63,8 @@ struct Args { #[arg(long)] no_configure: bool, - /// Skip the optional local web-UI systemd unit (no - /// `maestro-registry` install, no `maestro-web.service`). + /// Skip the optional local web-UI systemd unit (no `maestro-web` + /// install, no `maestro-web.service`). #[arg(long)] no_web: bool, @@ -74,13 +74,19 @@ struct Args { exec_start: Option, /// Override the ExecStart path for `maestro-web.service`. - /// Default: `~/.cargo/bin/maestro-registry`. + /// Default: `~/.cargo/bin/maestro-web`. #[arg(long, value_name = "PATH")] web_exec_start: Option, /// Loopback `host:port` the local web UI binds to. #[arg(long, value_name = "ADDR", default_value = DEFAULT_WEB_BIND_ADDR)] web_bind: String, + + /// Upstream registry URL the web UI proxies `/api/*` to. Defaults to + /// the daemon's `registry_url` from `~/.config/maestrod/config.toml` + /// when present; otherwise must be passed in non-interactive mode. + #[arg(long, value_name = "URL")] + web_registry: Option, } fn main() -> ExitCode { @@ -333,11 +339,71 @@ fn default_exec_start() -> Result> { } fn default_web_exec_start() -> Result> { - maestro_installer::systemd::default_maestro_registry_path() + maestro_installer::systemd::default_maestro_web_path() .ok_or_else(|| "could not resolve $HOME (needed for web ExecStart)".into()) } -/// Drop the web-UI unit (and install `maestro-registry` if the user opts in). +/// Decide which registry URL to bake into `Environment=MAESTRO_REGISTRY=`. +/// +/// Precedence: +/// 1. `--web-registry` flag if explicitly set. +/// 2. The `registry_url` already in `~/.config/maestrod/config.toml`. +/// 3. Interactive prompt (only when running with a TTY). +/// 4. Error — non-interactive callers without (1) or (2) must be explicit. +fn resolve_web_registry( + args: &Args, + interactive: bool, +) -> Result> { + if let Some(url) = &args.web_registry { + return Ok(url.trim().to_string()); + } + if let Some(from_daemon) = read_registry_from_daemon_config() { + eprintln!("Using registry URL from ~/.config/maestrod/config.toml: {from_daemon}"); + return Ok(from_daemon); + } + if !interactive { + return Err( + "no upstream registry URL — pass --web-registry or run `maestrod configure` first" + .into(), + ); + } + let url = prompt("Upstream registry URL (e.g. https://maestro-registry.example.com)")?; + if url.trim().is_empty() { + return Err("upstream registry URL is required for maestro-web".into()); + } + Ok(url.trim().to_string()) +} + +/// Best-effort: peek at the daemon's TOML config and extract `registry_url`. +/// `None` if the file is missing, unreadable, or the field isn't there. +/// We deliberately avoid pulling in a toml dep just for this — a one-line +/// regex-free pluck is enough since the file is laid out by `maestrod +/// configure`. +fn read_registry_from_daemon_config() -> Option { + let path = maestrod_config_path()?; + let body = std::fs::read_to_string(path).ok()?; + for line in body.lines() { + let trimmed = line.trim(); + if let Some(rest) = trimmed.strip_prefix("registry_url") { + let rest = rest.trim_start_matches(|c: char| c == '=' || c.is_whitespace()); + let url = rest.trim().trim_matches('"').trim_matches('\''); + if !url.is_empty() { + return Some(url.to_string()); + } + } + } + None +} + +fn prompt(message: &str) -> io::Result { + print!("{message}: "); + io::stdout().flush()?; + let mut line = String::new(); + io::stdin().lock().read_line(&mut line)?; + Ok(line.trim().to_string()) +} + +/// Drop the web-UI unit (and install `maestro-web` if the user opts in). /// Returns `true` if the unit ended up on disk after this call. fn handle_web_unit( source: &CargoSource, @@ -354,12 +420,12 @@ fn handle_web_unit( unit_path.display() ); } else { - eprintln!("Local web UI: an optional `maestro-web.service` runs `maestro-registry`"); + eprintln!("Local web UI: an optional `maestro-web.service` serves the web bundle on"); eprintln!( - "on {bind} so you can open it in a browser. Useful for single-machine setups;", + "{bind} and reverse-proxies /api to your registry. Useful if you want the UI", bind = args.web_bind ); - eprintln!("skip if you already have a remote registry deployed."); + eprintln!("on localhost instead of opening the registry's hostname directly."); } let want_unit = if already_installed || !interactive { @@ -367,7 +433,7 @@ fn handle_web_unit( } else { confirm( &format!( - "Install local web UI unit at {} (also runs cargo install maestro-registry)?", + "Install local web UI unit at {} (also runs cargo install maestro-web)?", unit_path.display() ), false, @@ -377,10 +443,13 @@ fn handle_web_unit( return Ok(false); } - // Install (or update) the registry binary first — the unit's ExecStart - // points at it. + // Resolve the upstream registry URL early so we don't burn install time + // on cargo just to fail at unit-rendering. + let upstream = resolve_web_registry(args, interactive)?; + + // Install (or update) the web front-end binary. let locked = !args.no_locked; - install_crate(source, REGISTRY_CRATE, locked)?; + install_crate(source, WEB_CRATE, locked)?; let exec_start = match &args.web_exec_start { Some(p) => p.clone(), @@ -389,7 +458,7 @@ fn handle_web_unit( if let Some(parent) = unit_path.parent() { std::fs::create_dir_all(parent)?; } - let body = render_web_user_unit(&exec_start, &args.web_bind); + let body = render_web_user_unit(&exec_start, &args.web_bind, &upstream); std::fs::write(&unit_path, body)?; eprintln!("wrote {}", unit_path.display()); diff --git a/crates/maestro-installer/src/systemd.rs b/crates/maestro-installer/src/systemd.rs index 8b82b6d..0389998 100644 --- a/crates/maestro-installer/src/systemd.rs +++ b/crates/maestro-installer/src/systemd.rs @@ -76,24 +76,25 @@ pub fn web_user_unit_path() -> Option { } /// Render the web-UI unit body. `exec_start` is the absolute path to -/// `maestro-registry`; `bind_addr` is the `host:port` the registry listens -/// on (set via the `MAESTRO_REGISTRY_BIND_ADDR` env var). +/// `maestro-web`; `bind_addr` is the local `host:port` the front-end binds +/// to; `upstream` is the URL of the registry the proxy forwards `/api` to. /// -/// `Wants=` rather than `Requires=`: the registry binary doesn't actually -/// need maestrod to start — they're decoupled at the process level. But the -/// web UI has nothing to show without a daemon connected, so we order this -/// after maestrod and politely express the dependency. -pub fn render_web_user_unit(exec_start: &Path, bind_addr: &str) -> String { +/// `Wants=` rather than `Requires=`: `maestro-web` is just a proxy to the +/// upstream registry; locally it doesn't depend on `maestrod` to start. +/// But the web UI has nothing to show without a daemon connected to the +/// registry, so we order after maestrod and politely express the dep. +pub fn render_web_user_unit(exec_start: &Path, bind_addr: &str, upstream: &str) -> String { format!( "[Unit]\n\ - Description=Maestro local web UI (maestro-registry on {bind})\n\ + Description=Maestro local web UI (maestro-web on {bind}, proxying {upstream})\n\ Documentation=https://git.naps.pt/naps62/maestro\n\ After=network-online.target maestrod.service\n\ Wants=maestrod.service\n\ \n\ [Service]\n\ Type=simple\n\ - Environment=MAESTRO_REGISTRY_BIND_ADDR={bind}\n\ + Environment=MAESTRO_WEB_BIND_ADDR={bind}\n\ + Environment=MAESTRO_REGISTRY={upstream}\n\ ExecStart={exec}\n\ Restart=on-failure\n\ RestartSec=5\n\ @@ -102,16 +103,17 @@ pub fn render_web_user_unit(exec_start: &Path, bind_addr: &str) -> String { WantedBy=default.target\n", exec = exec_start.display(), bind = bind_addr, + upstream = upstream, ) } -/// Default `ExecStart` for the web unit: `~/.cargo/bin/maestro-registry`. -pub fn default_maestro_registry_path() -> Option { +/// Default `ExecStart` for the web unit: `~/.cargo/bin/maestro-web`. +pub fn default_maestro_web_path() -> Option { Some( dirs::home_dir()? .join(".cargo") .join("bin") - .join("maestro-registry"), + .join("maestro-web"), ) } @@ -158,13 +160,15 @@ mod tests { } #[test] - fn web_unit_renders_with_bind_and_exec() { + fn web_unit_renders_with_bind_exec_and_upstream() { let body = render_web_user_unit( - Path::new("/home/alice/.cargo/bin/maestro-registry"), + Path::new("/home/alice/.cargo/bin/maestro-web"), "127.0.0.1:8888", + "https://maestro-registry.example.com", ); - assert!(body.contains("ExecStart=/home/alice/.cargo/bin/maestro-registry")); - assert!(body.contains("Environment=MAESTRO_REGISTRY_BIND_ADDR=127.0.0.1:8888")); + assert!(body.contains("ExecStart=/home/alice/.cargo/bin/maestro-web")); + assert!(body.contains("Environment=MAESTRO_WEB_BIND_ADDR=127.0.0.1:8888")); + assert!(body.contains("Environment=MAESTRO_REGISTRY=https://maestro-registry.example.com")); assert!(body.contains("After=network-online.target maestrod.service")); assert!(body.contains("Wants=maestrod.service")); assert!(body.contains("WantedBy=default.target")); diff --git a/crates/maestro-web/Cargo.toml b/crates/maestro-web/Cargo.toml new file mode 100644 index 0000000..53aa805 --- /dev/null +++ b/crates/maestro-web/Cargo.toml @@ -0,0 +1,29 @@ +[package] +name = "maestro-web" +version.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true +publish.workspace = true +description = "Thin web-UI front-end: serves the embedded web bundle and reverse-proxies /api to an upstream registry" + +[lib] +path = "src/lib.rs" + +[[bin]] +name = "maestro-web" +path = "src/main.rs" + +[dependencies] +axum.workspace = true +futures-util.workspace = true +reqwest = { workspace = true, default-features = false, features = ["rustls-tls", "stream"] } +rust-embed.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] +tokio = { workspace = true, features = ["macros", "rt-multi-thread", "test-util"] } diff --git a/crates/maestro-web/src/lib.rs b/crates/maestro-web/src/lib.rs new file mode 100644 index 0000000..abed839 --- /dev/null +++ b/crates/maestro-web/src/lib.rs @@ -0,0 +1,15 @@ +//! Local web-UI front-end. +//! +//! Serves the same embedded web bundle the registry serves at `/`, and +//! reverse-proxies everything under `/api` (HTTP + WS) to an upstream +//! registry. Lets you run the web UI on `127.0.0.1` while continuing to +//! talk to whatever registry the daemon is registered with — local or +//! remote. + +#![deny(missing_debug_implementations)] + +pub mod proxy; +pub mod server; +pub mod web_assets; + +pub use server::{router, Config}; diff --git a/crates/maestro-web/src/main.rs b/crates/maestro-web/src/main.rs new file mode 100644 index 0000000..e47e9aa --- /dev/null +++ b/crates/maestro-web/src/main.rs @@ -0,0 +1,107 @@ +//! `maestro-web` — local web-UI front-end. + +use std::error::Error; +use std::net::SocketAddr; +use std::process::ExitCode; +use std::sync::Arc; + +use maestro_web::proxy::UpstreamConfig; +use maestro_web::router; +use tokio::net::TcpListener; +use tracing_subscriber::EnvFilter; + +const DEFAULT_BIND_ADDR: &str = "127.0.0.1:8888"; + +#[tokio::main] +async fn main() -> ExitCode { + let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info")); + tracing_subscriber::fmt().with_env_filter(filter).init(); + + match run().await { + Ok(()) => ExitCode::SUCCESS, + Err(e) => { + tracing::error!(error = %e, "maestro-web failed"); + eprintln!("maestro-web: {e}"); + ExitCode::FAILURE + } + } +} + +async fn run() -> Result<(), Box> { + let upstream_raw = std::env::var("MAESTRO_REGISTRY") + .map_err(|_| "MAESTRO_REGISTRY must be set (e.g. https://maestro-registry.example.com)")?; + // Accept the daemon-facing URL form too (wss://.../daemon) — the proxy + // wants the base URL. + let upstream = normalize_upstream(&upstream_raw); + let upstream = UpstreamConfig::new(&upstream)?; + let upstream = Arc::new(upstream); + + let bind_raw = + std::env::var("MAESTRO_WEB_BIND_ADDR").unwrap_or_else(|_| DEFAULT_BIND_ADDR.to_string()); + let bind: SocketAddr = bind_raw + .parse() + .map_err(|e| format!("invalid MAESTRO_WEB_BIND_ADDR ({bind_raw:?}): {e}"))?; + + tracing::info!( + upstream = %upstream.http_base, + bind = %bind, + "maestro-web starting" + ); + + let app = router(upstream); + let listener = TcpListener::bind(bind).await?; + let bound = listener.local_addr()?; + tracing::info!(addr = %bound, "listening"); + + let shutdown = async { + let _ = tokio::signal::ctrl_c().await; + tracing::info!("ctrl-c received — shutting down"); + }; + + axum::serve(listener, app) + .with_graceful_shutdown(shutdown) + .await?; + Ok(()) +} + +/// Turn a daemon-facing URL (`wss://x/daemon`) into the base URL the proxy +/// needs (`https://x`). Bare HTTPS/HTTP URLs pass through with any trailing +/// `/daemon` stripped, so users can paste either form. +fn normalize_upstream(raw: &str) -> String { + let s = raw + .strip_prefix("wss://") + .map(|rest| format!("https://{rest}")) + .or_else(|| { + raw.strip_prefix("ws://") + .map(|rest| format!("http://{rest}")) + }) + .unwrap_or_else(|| raw.to_string()); + let trimmed = s.trim_end_matches('/'); + let without_daemon = trimmed.strip_suffix("/daemon").unwrap_or(trimmed); + without_daemon.to_string() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn normalize_strips_ws_scheme_and_daemon_suffix() { + assert_eq!( + normalize_upstream("wss://maestro.example.com/daemon"), + "https://maestro.example.com" + ); + assert_eq!( + normalize_upstream("ws://127.0.0.1:8080/daemon/"), + "http://127.0.0.1:8080" + ); + } + + #[test] + fn normalize_passes_through_https() { + assert_eq!( + normalize_upstream("https://example.com/"), + "https://example.com" + ); + } +} diff --git a/crates/maestro-web/src/proxy.rs b/crates/maestro-web/src/proxy.rs new file mode 100644 index 0000000..c88df96 --- /dev/null +++ b/crates/maestro-web/src/proxy.rs @@ -0,0 +1,280 @@ +//! Reverse proxy for `/api/*` — both HTTP and WebSocket flavours. +//! +//! Cookies, headers, and the request body all pass through. The upstream +//! base URL (e.g. `https://maestro-registry.example.com`) lives in +//! [`UpstreamConfig`]; everything past `/api/...` in the request URI is +//! appended verbatim. +//! +//! The WS proxy upgrades both ends and shuttles frames between them with +//! `tokio::select!`. We deliberately don't try to interpret the payloads — +//! the registry is the source of truth for event shape. + +use std::sync::Arc; + +use axum::body::Body; +use axum::extract::ws::{Message as AxumMsg, WebSocket, WebSocketUpgrade}; +use axum::extract::{OriginalUri, Request, State}; +use axum::http::{header, HeaderMap, HeaderName, HeaderValue, Method, StatusCode, Uri}; +use axum::response::{IntoResponse, Response}; +use futures_util::{SinkExt, StreamExt}; +use reqwest::Client as ReqClient; +use tokio_tungstenite::tungstenite::client::IntoClientRequest; +use tokio_tungstenite::tungstenite::Message as TungMsg; + +/// Headers that must not be forwarded — they describe the hop-by-hop +/// connection, not the underlying request/response, and copying them +/// breaks things (chunked encoding, keep-alive, etc.). +const HOP_BY_HOP: &[HeaderName] = &[ + header::CONNECTION, + header::PROXY_AUTHENTICATE, + header::PROXY_AUTHORIZATION, + header::TE, + header::TRAILER, + header::TRANSFER_ENCODING, + header::UPGRADE, + header::HOST, +]; + +/// Where to forward `/api/*` requests. +#[derive(Debug, Clone)] +pub struct UpstreamConfig { + /// Base URL ending without a trailing slash, e.g. + /// `https://maestro-registry.example.com` or `http://127.0.0.1:8080`. + pub http_base: String, + /// WebSocket base, derived from `http_base` — `http://` → `ws://`, + /// `https://` → `wss://`. Stored explicitly so we don't redo string + /// surgery per-request. + pub ws_base: String, + pub client: ReqClient, +} + +impl UpstreamConfig { + pub fn new(http_base: &str) -> Result { + let http_base = http_base.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 { + return Err(ProxyError::BadUpstream { + input: http_base, + reason: "must start with http:// or https://", + }); + }; + let client = ReqClient::builder() + .redirect(reqwest::redirect::Policy::none()) + .build() + .map_err(ProxyError::Client)?; + Ok(Self { + http_base, + ws_base, + client, + }) + } +} + +#[derive(Debug, thiserror::Error)] +pub enum ProxyError { + #[error("invalid upstream URL ({input}): {reason}")] + BadUpstream { input: String, reason: &'static str }, + #[error("upstream client build: {0}")] + Client(reqwest::Error), +} + +/// Axum handler: dispatch to HTTP proxy or WS proxy based on the inbound +/// request. Mounted at any `/api/*` route. +pub async fn proxy_api( + State(upstream): State>, + OriginalUri(uri): OriginalUri, + ws: Option, + req: Request, +) -> Response { + if let Some(ws) = ws { + return proxy_ws(upstream, uri, ws, req.headers().clone()).await; + } + proxy_http(upstream, uri, req).await +} + +async fn proxy_http(upstream: Arc, uri: Uri, req: Request) -> Response { + let method = req.method().clone(); + let headers = req.headers().clone(); + let body_bytes = match axum::body::to_bytes(req.into_body(), 16 * 1024 * 1024).await { + Ok(b) => b, + Err(e) => return error_response(StatusCode::BAD_REQUEST, &format!("read body: {e}")), + }; + + let target = build_upstream_url(&upstream.http_base, &uri); + let mut builder = upstream + .client + .request(reqwest_method(&method), target) + .body(body_bytes.to_vec()); + for (name, value) in headers.iter() { + if HOP_BY_HOP.contains(name) { + continue; + } + builder = builder.header(name, value); + } + + let resp = match builder.send().await { + Ok(r) => r, + Err(e) => return error_response(StatusCode::BAD_GATEWAY, &format!("upstream: {e}")), + }; + + let status = + StatusCode::from_u16(resp.status().as_u16()).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR); + let mut out_headers = HeaderMap::new(); + for (name, value) in resp.headers().iter() { + if HOP_BY_HOP.contains(name) { + continue; + } + out_headers.append(name, value.clone()); + } + let body = match resp.bytes().await { + Ok(b) => Body::from(b.to_vec()), + Err(e) => return error_response(StatusCode::BAD_GATEWAY, &format!("upstream body: {e}")), + }; + + (status, out_headers, body).into_response() +} + +async fn proxy_ws( + upstream: Arc, + uri: Uri, + ws: WebSocketUpgrade, + incoming_headers: HeaderMap, +) -> Response { + let url = build_upstream_url(&upstream.ws_base, &uri); + ws.on_upgrade(move |socket| async move { + if let Err(e) = drive_ws(socket, url, incoming_headers).await { + tracing::warn!(error = %e, "ws proxy ended with error"); + } + }) +} + +async fn drive_ws( + client_ws: WebSocket, + upstream_url: String, + incoming_headers: HeaderMap, +) -> Result<(), Box> { + let mut req = upstream_url.into_client_request()?; + // Forward Cookie + Authorization so the upstream sees the browser's + // session. Skip hop-by-hop and the headers tungstenite sets itself. + for (name, value) in incoming_headers.iter() { + if HOP_BY_HOP.contains(name) { + continue; + } + let lower = name.as_str().to_ascii_lowercase(); + if matches!( + lower.as_str(), + "sec-websocket-key" + | "sec-websocket-version" + | "sec-websocket-protocol" + | "sec-websocket-extensions" + ) { + continue; + } + req.headers_mut().insert(name.clone(), value.clone()); + } + + let (upstream_ws, _resp) = tokio_tungstenite::connect_async(req).await?; + let (mut up_sink, mut up_stream) = upstream_ws.split(); + let (mut client_sink, mut client_stream) = client_ws.split(); + + let to_upstream = async { + while let Some(msg) = client_stream.next().await { + let msg = msg?; + let tung = match msg { + AxumMsg::Text(t) => TungMsg::Text(t), + AxumMsg::Binary(b) => TungMsg::Binary(b), + AxumMsg::Ping(b) => TungMsg::Ping(b), + AxumMsg::Pong(b) => TungMsg::Pong(b), + AxumMsg::Close(frame) => TungMsg::Close(frame.map(|f| { + tokio_tungstenite::tungstenite::protocol::CloseFrame { + code: f.code.into(), + reason: f.reason, + } + })), + }; + up_sink.send(tung).await?; + } + Ok::<_, Box>(()) + }; + + let to_client = async { + while let Some(msg) = up_stream.next().await { + let msg = msg?; + let axum_msg = match msg { + TungMsg::Text(t) => AxumMsg::Text(t), + TungMsg::Binary(b) => AxumMsg::Binary(b), + TungMsg::Ping(b) => AxumMsg::Ping(b), + TungMsg::Pong(b) => AxumMsg::Pong(b), + TungMsg::Close(frame) => { + AxumMsg::Close(frame.map(|f| axum::extract::ws::CloseFrame { + code: f.code.into(), + reason: f.reason, + })) + } + TungMsg::Frame(_) => continue, + }; + client_sink.send(axum_msg).await?; + } + Ok::<_, Box>(()) + }; + + tokio::select! { + r = to_upstream => r?, + r = to_client => r?, + } + Ok(()) +} + +fn build_upstream_url(base: &str, uri: &Uri) -> String { + let path = uri.path(); + match uri.query() { + Some(q) => format!("{base}{path}?{q}"), + None => format!("{base}{path}"), + } +} + +fn reqwest_method(m: &Method) -> reqwest::Method { + reqwest::Method::from_bytes(m.as_str().as_bytes()).unwrap_or(reqwest::Method::GET) +} + +fn error_response(status: StatusCode, msg: &str) -> Response { + ( + status, + [(header::CONTENT_TYPE, HeaderValue::from_static("text/plain"))], + msg.to_string(), + ) + .into_response() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn upstream_config_derives_ws_base_from_http() { + let u = UpstreamConfig::new("http://127.0.0.1:8080").unwrap(); + assert_eq!(u.ws_base, "ws://127.0.0.1:8080"); + + let u = UpstreamConfig::new("https://example.com/").unwrap(); + assert_eq!(u.http_base, "https://example.com"); + assert_eq!(u.ws_base, "wss://example.com"); + } + + #[test] + fn upstream_config_rejects_non_http() { + let err = UpstreamConfig::new("ws://example.com").unwrap_err(); + assert!(format!("{err}").contains("http://")); + } + + #[test] + fn build_url_appends_path_and_query() { + let uri: Uri = "/api/sessions/abc?since=42".parse().unwrap(); + assert_eq!( + build_upstream_url("https://r.example", &uri), + "https://r.example/api/sessions/abc?since=42" + ); + } +} diff --git a/crates/maestro-web/src/server.rs b/crates/maestro-web/src/server.rs new file mode 100644 index 0000000..b038b1d --- /dev/null +++ b/crates/maestro-web/src/server.rs @@ -0,0 +1,40 @@ +//! Axum router that ties the embedded web assets and the upstream proxy +//! together. + +use std::sync::Arc; + +use axum::extract::Path; +use axum::response::Response; +use axum::routing::{any, get}; +use axum::Router; + +use crate::proxy::{proxy_api, UpstreamConfig}; +use crate::web_assets; + +/// Resolved runtime configuration. `bind` is local; `upstream` is the +/// registry the proxy forwards `/api/*` to. +#[derive(Debug, Clone)] +pub struct Config { + pub bind_addr: std::net::SocketAddr, + pub upstream: Arc, +} + +pub fn router(upstream: Arc) -> Router { + Router::new() + .route("/", get(index)) + .route("/assets/*file", get(asset)) + // Everything under /api goes to the upstream registry — including + // the events WebSocket. axum dispatches WS via the + // `WebSocketUpgrade` extractor inside the handler. + .route("/api", any(proxy_api)) + .route("/api/*rest", any(proxy_api)) + .with_state(upstream) +} + +async fn index() -> Response { + web_assets::serve_index() +} + +async fn asset(Path(file): Path) -> Response { + web_assets::serve_asset(&file) +} diff --git a/crates/maestro-web/src/web_assets.rs b/crates/maestro-web/src/web_assets.rs new file mode 100644 index 0000000..ff24bba --- /dev/null +++ b/crates/maestro-web/src/web_assets.rs @@ -0,0 +1,101 @@ +//! Embedded static assets for the web client bundle. +//! +//! Mirror of `maestro-registry::web_assets` — kept independent so the two +//! binaries don't have to share a code path. The web bundle is built by Vite +//! under `web/dist/` and embedded at `cargo build` time via `rust-embed`. + +use axum::http::{header, StatusCode}; +use axum::response::{IntoResponse, Response}; +use rust_embed::RustEmbed; + +#[derive(RustEmbed)] +#[folder = "../../web/dist/"] +struct WebAssets; + +const FALLBACK_INDEX: &str = "maestro\ +

maestro web

\ +

Web bundle not built. Run pnpm --dir web install \ + then pnpm --dir web build, then rebuild maestro-web.

"; + +fn content_type_for(path: &str) -> &'static str { + let ext = path.rsplit('.').next().unwrap_or(""); + match ext { + "html" | "htm" => "text/html; charset=utf-8", + "js" | "mjs" => "text/javascript; charset=utf-8", + "css" => "text/css; charset=utf-8", + "json" => "application/json", + "svg" => "image/svg+xml", + "png" => "image/png", + "ico" => "image/x-icon", + "woff" => "font/woff", + "woff2" => "font/woff2", + "map" => "application/json", + "txt" => "text/plain; charset=utf-8", + _ => "application/octet-stream", + } +} + +fn cache_headers(path: &str) -> &'static str { + if path.starts_with("assets/") { + "public, max-age=31536000, immutable" + } else { + "no-cache" + } +} + +fn serve(path: &str) -> Option { + let asset = WebAssets::get(path)?; + let body = asset.data.into_owned(); + Some( + ( + [ + (header::CONTENT_TYPE, content_type_for(path)), + (header::CACHE_CONTROL, cache_headers(path)), + ], + body, + ) + .into_response(), + ) +} + +pub fn serve_index() -> Response { + if let Some(resp) = serve("index.html") { + return resp; + } + ( + StatusCode::OK, + [(header::CONTENT_TYPE, "text/html; charset=utf-8")], + FALLBACK_INDEX, + ) + .into_response() +} + +pub fn serve_asset(file: &str) -> Response { + if file.contains("..") || file.starts_with('/') { + return StatusCode::NOT_FOUND.into_response(); + } + let path = format!("assets/{file}"); + match serve(&path) { + Some(resp) => resp, + None => StatusCode::NOT_FOUND.into_response(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn content_type_routes_common_extensions() { + assert_eq!(content_type_for("foo.js"), "text/javascript; charset=utf-8"); + assert_eq!(content_type_for("a/b.css"), "text/css; charset=utf-8"); + assert_eq!(content_type_for("index.html"), "text/html; charset=utf-8"); + assert_eq!(content_type_for("noext"), "application/octet-stream"); + } + + #[test] + fn serve_asset_rejects_traversal() { + let resp = serve_asset("../secret"); + assert_eq!(resp.status(), StatusCode::NOT_FOUND); + } +} diff --git a/crates/maestro-web/tests/proxy.rs b/crates/maestro-web/tests/proxy.rs new file mode 100644 index 0000000..f3e90e9 --- /dev/null +++ b/crates/maestro-web/tests/proxy.rs @@ -0,0 +1,153 @@ +//! End-to-end test for the maestro-web proxy. +//! +//! Stands up a tiny axum app that pretends to be the upstream registry, +//! then a `maestro-web` router pointed at it, and verifies HTTP requests +//! and a WebSocket round-trip both make it through. + +use std::net::{Ipv4Addr, SocketAddr}; +use std::sync::Arc; +use std::time::Duration; + +use axum::extract::ws::{Message, WebSocketUpgrade}; +use axum::response::IntoResponse; +use axum::routing::{any, get}; +use axum::Router; +use futures_util::{SinkExt, StreamExt}; +use maestro_web::proxy::UpstreamConfig; +use maestro_web::router; +use tokio::net::TcpListener; +use tokio::sync::oneshot; +use tokio_tungstenite::tungstenite::client::IntoClientRequest; +use tokio_tungstenite::{connect_async, tungstenite::Message as WsMessage}; + +struct Server { + addr: SocketAddr, + _shutdown: oneshot::Sender<()>, +} + +async fn spawn_app(app: Router) -> Server { + 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; + }); + Server { + addr, + _shutdown: tx, + } +} + +async fn upstream_mock() -> Server { + let app = Router::new() + .route( + "/api/daemons", + get(|| async { + ( + [("set-cookie", "maestro_session=test; Path=/")], + "[{\"daemon_id\":\"echo\"}]", + ) + .into_response() + }), + ) + .route( + "/api/echo", + get(|req: axum::extract::Request| async move { + // Echo the incoming Cookie header back in the body — proves it + // forwarded. + let cookie = req + .headers() + .get("cookie") + .and_then(|v| v.to_str().ok()) + .unwrap_or("none") + .to_string(); + (axum::http::StatusCode::OK, format!("cookie={cookie}")) + }), + ) + .route( + "/api/events", + any(|ws: WebSocketUpgrade| async move { + ws.on_upgrade(|mut socket| async move { + // Echo every text frame back, then close. + while let Some(Ok(msg)) = socket.next().await { + if let Message::Text(t) = msg { + let _ = socket.send(Message::Text(format!("upstream:{t}"))).await; + } + } + }) + }), + ); + spawn_app(app).await +} + +#[tokio::test(flavor = "multi_thread")] +async fn proxies_http_with_cookies_and_set_cookie() { + let upstream = upstream_mock().await; + let cfg = Arc::new(UpstreamConfig::new(&format!("http://{}", upstream.addr)).unwrap()); + let proxy = spawn_app(router(cfg)).await; + + let client = reqwest::Client::builder().build().unwrap(); + let resp = client + .get(format!("http://{}/api/daemons", proxy.addr)) + .send() + .await + .unwrap(); + assert!(resp.status().is_success()); + let cookie = resp + .headers() + .get("set-cookie") + .and_then(|v| v.to_str().ok()) + .unwrap_or_default() + .to_string(); + assert!( + cookie.contains("maestro_session=test"), + "expected Set-Cookie passthrough, got {cookie:?}" + ); + let body = resp.text().await.unwrap(); + assert!(body.contains("\"echo\"")); + + // Subsequent request: forward the cookie back upstream. + let resp = client + .get(format!("http://{}/api/echo", proxy.addr)) + .header("cookie", "maestro_session=test") + .send() + .await + .unwrap(); + let body = resp.text().await.unwrap(); + assert!( + body.contains("maestro_session=test"), + "upstream didn't see the cookie: {body:?}" + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn proxies_websocket_text_round_trip() { + let upstream = upstream_mock().await; + let cfg = Arc::new(UpstreamConfig::new(&format!("http://{}", upstream.addr)).unwrap()); + let proxy = spawn_app(router(cfg)).await; + + let url = format!("ws://{}/api/events", proxy.addr); + let req = url.into_client_request().unwrap(); + let (mut ws, _resp) = connect_async(req).await.expect("ws connect"); + ws.send(WsMessage::Text("hello".into())).await.unwrap(); + + let deadline = std::time::Instant::now() + Duration::from_secs(3); + while std::time::Instant::now() < deadline { + match tokio::time::timeout(Duration::from_millis(200), ws.next()).await { + Ok(Some(Ok(WsMessage::Text(t)))) => { + assert_eq!(t, "upstream:hello"); + return; + } + Ok(Some(Err(e))) => panic!("ws error: {e}"), + Ok(None) => panic!("ws closed before echo"), + _ => continue, + } + } + panic!("did not receive echo within timeout"); +} diff --git a/docs/INSTALL.md b/docs/INSTALL.md index ca059aa..3f5b845 100644 --- a/docs/INSTALL.md +++ b/docs/INSTALL.md @@ -45,9 +45,10 @@ The wizard: enable + start it. 4. On systemd hosts, also offers a **local web UI** unit (`~/.config/systemd/user/maestro-web.service`). When accepted, this also - `cargo install`s `maestro-registry` and runs it bound to `127.0.0.1:8888` - (override with `--web-bind`). Skip with `--no-web` (or just answer "n"); a - remote registry deployment is the alternative. + `cargo install`s `maestro-web` — a thin front-end that serves the bundled + UI on `127.0.0.1:8888` (override with `--web-bind`) and reverse-proxies + `/api` to your existing registry. Skip with `--no-web` (or answer "n"); you + can always just open the registry's own URL in a browser instead. Binaries land in `~/.cargo/bin` — make sure that's on your `$PATH`. @@ -62,9 +63,10 @@ Binaries land in `~/.cargo/bin` — make sure that's on your `$PATH`. | `--exec-start ` | Override the systemd unit's `ExecStart` (default `~/.cargo/bin/maestrod`). | | `--no-systemd` | Skip the systemd step even if detected. | | `--no-configure` | Skip the `maestrod configure` step. | -| `--no-web` | Skip the optional local web-UI unit + `maestro-registry` install. | +| `--no-web` | Skip the optional local web-UI unit + `maestro-web` install. | | `--web-bind ` | Loopback bind for the web UI (default `127.0.0.1:8888`). | -| `--web-exec-start ` | Override the web unit's `ExecStart` (default `~/.cargo/bin/maestro-registry`). | +| `--web-registry ` | Upstream registry the proxy forwards `/api` to (default: read from `~/.config/maestrod/config.toml`). | +| `--web-exec-start ` | Override the web unit's `ExecStart` (default `~/.cargo/bin/maestro-web`). | | `-y`, `--yes` | Non-interactive: install binaries + drop systemd unit + reload + enable, but **don't** auto-run `configure` or auto-start the unit (those need real config). | ## What the systemd unit looks like @@ -95,19 +97,23 @@ systemctl --user status maestrod journalctl --user -u maestrod -f ``` -The optional `maestro-web.service` looks like: +The optional `maestro-web.service` runs a thin front-end that serves the +embedded web bundle and reverse-proxies `/api` (HTTP + WS) to a configured +upstream registry — so you can hit the UI on `localhost` while the daemon +keeps talking to whichever registry it was registered with: ```ini [Unit] -Description=Maestro local web UI (maestro-registry on 127.0.0.1:8888) +Description=Maestro local web UI (maestro-web on 127.0.0.1:8888, proxying https://maestro-registry.example.com) Documentation=https://git.naps.pt/naps62/maestro After=network-online.target maestrod.service Wants=maestrod.service [Service] Type=simple -Environment=MAESTRO_REGISTRY_BIND_ADDR=127.0.0.1:8888 -ExecStart=%h/.cargo/bin/maestro-registry +Environment=MAESTRO_WEB_BIND_ADDR=127.0.0.1:8888 +Environment=MAESTRO_REGISTRY=https://maestro-registry.example.com +ExecStart=%h/.cargo/bin/maestro-web Restart=on-failure RestartSec=5 @@ -115,10 +121,14 @@ RestartSec=5 WantedBy=default.target ``` -`Wants=maestrod.service` (not `Requires=`) — the registry process is -technically independent of the daemon, but the web UI has nothing to show -without one connected, so we order this after `maestrod` and politely -encourage it. +`Wants=maestrod.service` (not `Requires=`) — `maestro-web` is just a proxy +to the upstream registry; locally it doesn't depend on `maestrod` to start. +But the UI has nothing to show without a daemon connected, so we order +this after `maestrod` and politely encourage it. + +The wizard fills in `MAESTRO_REGISTRY` from the daemon's +`~/.config/maestrod/config.toml` automatically; override with +`--web-registry `. ## Running the wizard without the bootstrap script