From 7409cd6ad8a4cb968cf9b306af0b295429e4dd97 Mon Sep 17 00:00:00 2001 From: naps62-yolo Date: Tue, 26 May 2026 09:05:38 +0000 Subject: [PATCH] Revert "fix(maestro): maestro-web is now a proxy, not a local registry (NAP-231)" This reverts commit 3b6dd50c1db64477f7fcb06ee1151379d5f3b3b2. --- CLAUDE.md | 1 - Cargo.lock | 64 ------ crates/maestro-installer/src/main.rs | 97 ++------ crates/maestro-installer/src/systemd.rs | 36 ++- crates/maestro-web/Cargo.toml | 29 --- crates/maestro-web/src/lib.rs | 15 -- crates/maestro-web/src/main.rs | 107 --------- crates/maestro-web/src/proxy.rs | 280 ------------------------ crates/maestro-web/src/server.rs | 40 ---- crates/maestro-web/src/web_assets.rs | 101 --------- crates/maestro-web/tests/proxy.rs | 153 ------------- docs/INSTALL.md | 36 ++- 12 files changed, 43 insertions(+), 916 deletions(-) delete mode 100644 crates/maestro-web/Cargo.toml delete mode 100644 crates/maestro-web/src/lib.rs delete mode 100644 crates/maestro-web/src/main.rs delete mode 100644 crates/maestro-web/src/proxy.rs delete mode 100644 crates/maestro-web/src/server.rs delete mode 100644 crates/maestro-web/src/web_assets.rs delete mode 100644 crates/maestro-web/tests/proxy.rs diff --git a/CLAUDE.md b/CLAUDE.md index ae38fa4..152ebbd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -23,7 +23,6 @@ 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 b0444ef..0e23b15 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -454,23 +454,6 @@ 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" @@ -490,11 +473,8 @@ 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", ] @@ -1033,21 +1013,6 @@ 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" @@ -1385,7 +1350,6 @@ dependencies = [ "cookie", "cookie_store", "futures-core", - "futures-util", "http", "http-body", "http-body-util", @@ -1405,14 +1369,12 @@ 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", ] @@ -1958,19 +1920,6 @@ 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" @@ -2363,19 +2312,6 @@ 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 e0ad999..f7fbcd2 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 WEB_CRATE: &str = "maestro-web"; +const REGISTRY_CRATE: &str = "maestro-registry"; 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-web` - /// install, no `maestro-web.service`). + /// Skip the optional local web-UI systemd unit (no + /// `maestro-registry` install, no `maestro-web.service`). #[arg(long)] no_web: bool, @@ -74,19 +74,13 @@ struct Args { exec_start: Option, /// Override the ExecStart path for `maestro-web.service`. - /// Default: `~/.cargo/bin/maestro-web`. + /// Default: `~/.cargo/bin/maestro-registry`. #[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 { @@ -339,71 +333,11 @@ fn default_exec_start() -> Result> { } fn default_web_exec_start() -> Result> { - maestro_installer::systemd::default_maestro_web_path() + maestro_installer::systemd::default_maestro_registry_path() .ok_or_else(|| "could not resolve $HOME (needed for web ExecStart)".into()) } -/// 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). +/// Drop the web-UI unit (and install `maestro-registry` if the user opts in). /// Returns `true` if the unit ended up on disk after this call. fn handle_web_unit( source: &CargoSource, @@ -420,12 +354,12 @@ fn handle_web_unit( unit_path.display() ); } else { - eprintln!("Local web UI: an optional `maestro-web.service` serves the web bundle on"); + eprintln!("Local web UI: an optional `maestro-web.service` runs `maestro-registry`"); eprintln!( - "{bind} and reverse-proxies /api to your registry. Useful if you want the UI", + "on {bind} so you can open it in a browser. Useful for single-machine setups;", bind = args.web_bind ); - eprintln!("on localhost instead of opening the registry's hostname directly."); + eprintln!("skip if you already have a remote registry deployed."); } let want_unit = if already_installed || !interactive { @@ -433,7 +367,7 @@ fn handle_web_unit( } else { confirm( &format!( - "Install local web UI unit at {} (also runs cargo install maestro-web)?", + "Install local web UI unit at {} (also runs cargo install maestro-registry)?", unit_path.display() ), false, @@ -443,13 +377,10 @@ fn handle_web_unit( return Ok(false); } - // 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. + // Install (or update) the registry binary first — the unit's ExecStart + // points at it. let locked = !args.no_locked; - install_crate(source, WEB_CRATE, locked)?; + install_crate(source, REGISTRY_CRATE, locked)?; let exec_start = match &args.web_exec_start { Some(p) => p.clone(), @@ -458,7 +389,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, &upstream); + let body = render_web_user_unit(&exec_start, &args.web_bind); 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 0389998..8b82b6d 100644 --- a/crates/maestro-installer/src/systemd.rs +++ b/crates/maestro-installer/src/systemd.rs @@ -76,25 +76,24 @@ pub fn web_user_unit_path() -> Option { } /// Render the web-UI unit body. `exec_start` is the absolute path to -/// `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. +/// `maestro-registry`; `bind_addr` is the `host:port` the registry listens +/// on (set via the `MAESTRO_REGISTRY_BIND_ADDR` env var). /// -/// `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 { +/// `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 { format!( "[Unit]\n\ - Description=Maestro local web UI (maestro-web on {bind}, proxying {upstream})\n\ + Description=Maestro local web UI (maestro-registry on {bind})\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_WEB_BIND_ADDR={bind}\n\ - Environment=MAESTRO_REGISTRY={upstream}\n\ + Environment=MAESTRO_REGISTRY_BIND_ADDR={bind}\n\ ExecStart={exec}\n\ Restart=on-failure\n\ RestartSec=5\n\ @@ -103,17 +102,16 @@ pub fn render_web_user_unit(exec_start: &Path, bind_addr: &str, upstream: &str) WantedBy=default.target\n", exec = exec_start.display(), bind = bind_addr, - upstream = upstream, ) } -/// Default `ExecStart` for the web unit: `~/.cargo/bin/maestro-web`. -pub fn default_maestro_web_path() -> Option { +/// Default `ExecStart` for the web unit: `~/.cargo/bin/maestro-registry`. +pub fn default_maestro_registry_path() -> Option { Some( dirs::home_dir()? .join(".cargo") .join("bin") - .join("maestro-web"), + .join("maestro-registry"), ) } @@ -160,15 +158,13 @@ mod tests { } #[test] - fn web_unit_renders_with_bind_exec_and_upstream() { + fn web_unit_renders_with_bind_and_exec() { let body = render_web_user_unit( - Path::new("/home/alice/.cargo/bin/maestro-web"), + Path::new("/home/alice/.cargo/bin/maestro-registry"), "127.0.0.1:8888", - "https://maestro-registry.example.com", ); - 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("ExecStart=/home/alice/.cargo/bin/maestro-registry")); + assert!(body.contains("Environment=MAESTRO_REGISTRY_BIND_ADDR=127.0.0.1:8888")); 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 deleted file mode 100644 index 53aa805..0000000 --- a/crates/maestro-web/Cargo.toml +++ /dev/null @@ -1,29 +0,0 @@ -[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 deleted file mode 100644 index abed839..0000000 --- a/crates/maestro-web/src/lib.rs +++ /dev/null @@ -1,15 +0,0 @@ -//! 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 deleted file mode 100644 index e47e9aa..0000000 --- a/crates/maestro-web/src/main.rs +++ /dev/null @@ -1,107 +0,0 @@ -//! `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 deleted file mode 100644 index c88df96..0000000 --- a/crates/maestro-web/src/proxy.rs +++ /dev/null @@ -1,280 +0,0 @@ -//! 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 deleted file mode 100644 index b038b1d..0000000 --- a/crates/maestro-web/src/server.rs +++ /dev/null @@ -1,40 +0,0 @@ -//! 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 deleted file mode 100644 index ff24bba..0000000 --- a/crates/maestro-web/src/web_assets.rs +++ /dev/null @@ -1,101 +0,0 @@ -//! 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 deleted file mode 100644 index f3e90e9..0000000 --- a/crates/maestro-web/tests/proxy.rs +++ /dev/null @@ -1,153 +0,0 @@ -//! 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 3f5b845..ca059aa 100644 --- a/docs/INSTALL.md +++ b/docs/INSTALL.md @@ -45,10 +45,9 @@ 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-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. + `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. Binaries land in `~/.cargo/bin` — make sure that's on your `$PATH`. @@ -63,10 +62,9 @@ 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-web` install. | +| `--no-web` | Skip the optional local web-UI unit + `maestro-registry` install. | | `--web-bind ` | Loopback bind for the web UI (default `127.0.0.1:8888`). | -| `--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`). | +| `--web-exec-start ` | Override the web unit's `ExecStart` (default `~/.cargo/bin/maestro-registry`). | | `-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 @@ -97,23 +95,19 @@ systemctl --user status maestrod journalctl --user -u maestrod -f ``` -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: +The optional `maestro-web.service` looks like: ```ini [Unit] -Description=Maestro local web UI (maestro-web on 127.0.0.1:8888, proxying https://maestro-registry.example.com) +Description=Maestro local web UI (maestro-registry on 127.0.0.1:8888) Documentation=https://git.naps.pt/naps62/maestro After=network-online.target maestrod.service Wants=maestrod.service [Service] Type=simple -Environment=MAESTRO_WEB_BIND_ADDR=127.0.0.1:8888 -Environment=MAESTRO_REGISTRY=https://maestro-registry.example.com -ExecStart=%h/.cargo/bin/maestro-web +Environment=MAESTRO_REGISTRY_BIND_ADDR=127.0.0.1:8888 +ExecStart=%h/.cargo/bin/maestro-registry Restart=on-failure RestartSec=5 @@ -121,14 +115,10 @@ RestartSec=5 WantedBy=default.target ``` -`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 `. +`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. ## Running the wizard without the bootstrap script