ci / rust (push) Successful in 4m36s
Earlier today I picked option 1 from NAP-231's open question (run `maestro-registry` locally on a different port). That was wrong: the user-facing framing is "local web UI", and option 2 — a thin front-end that serves the web bundle and reverse-proxies `/api/*` to whatever registry the daemon is already registered with — is what actually delivers that. What the new crate does: - `crates/maestro-web` ships a single binary `maestro-web`. - It embeds the web bundle the same way `maestro-registry` does (rust-embed against `web/dist/`). - Mounts the bundle at `/` and a reverse proxy at `/api/*` (catch-all). - HTTP proxy via `reqwest` — forwards method, headers (minus hop-by-hop), body, and passes upstream response headers (including `Set-Cookie`) back to the browser. - WS proxy upgrades both sides and shuttles frames with `tokio::select!`. Cookies on the initial `WebSocket` upgrade headers are forwarded so the registry sees the same browser session. - Reads `MAESTRO_REGISTRY` (upstream URL) and `MAESTRO_WEB_BIND_ADDR` (default `127.0.0.1:8888`) from env. - An integration test (`tests/proxy.rs`) stands up a mock upstream, proxies an HTTP request with cookies through, and round-trips a text WebSocket frame to verify the WS path. Installer changes: - Wizard now `cargo install`s `maestro-web` (not `maestro-registry`) when the user opts into the web service. - Unit template switched: `ExecStart=%h/.cargo/bin/maestro-web` plus `Environment=MAESTRO_REGISTRY=<upstream>` + `Environment=MAESTRO_WEB_BIND_ADDR=<bind>`. - New `--web-registry <URL>` flag. Default in interactive mode: read `registry_url` straight out of `~/.config/maestrod/config.toml`, so the proxy lines up with what the daemon is using without further prompting. Non-interactive runs without either of those error out rather than guess. Docs: - `docs/INSTALL.md` updated unit body + flag table. - `CLAUDE.md` repo-layout block now lists `maestro-web`. 148 tests pass workspace-wide (9 new in maestro-web, including the proxy integration test; 15 in installer).
108 lines
3.2 KiB
Rust
108 lines
3.2 KiB
Rust
//! `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<dyn Error>> {
|
|
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"
|
|
);
|
|
}
|
|
}
|