Revert "fix(maestro): maestro-web is now a proxy, not a local registry (NAP-231)"

This reverts commit 3b6dd50c1d.
This commit is contained in:
2026-05-26 09:05:38 +00:00
parent 3b6dd50c1d
commit 7409cd6ad8
12 changed files with 43 additions and 916 deletions
-1
View File
@@ -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)
```
Generated
-64
View File
@@ -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"
+14 -83
View File
@@ -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<PathBuf>,
/// 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<PathBuf>,
/// 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<String>,
}
fn main() -> ExitCode {
@@ -339,71 +333,11 @@ fn default_exec_start() -> Result<PathBuf, Box<dyn std::error::Error>> {
}
fn default_web_exec_start() -> Result<PathBuf, Box<dyn std::error::Error>> {
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<String, Box<dyn std::error::Error>> {
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 <URL> 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<String> {
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<String> {
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());
+16 -20
View File
@@ -76,25 +76,24 @@ pub fn web_user_unit_path() -> Option<PathBuf> {
}
/// 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<PathBuf> {
/// Default `ExecStart` for the web unit: `~/.cargo/bin/maestro-registry`.
pub fn default_maestro_registry_path() -> Option<PathBuf> {
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"));
-29
View File
@@ -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"] }
-15
View File
@@ -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};
-107
View File
@@ -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<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"
);
}
}
-280
View File
@@ -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<Self, ProxyError> {
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<Arc<UpstreamConfig>>,
OriginalUri(uri): OriginalUri,
ws: Option<WebSocketUpgrade>,
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<UpstreamConfig>, 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<UpstreamConfig>,
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<dyn std::error::Error + Send + Sync>> {
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<dyn std::error::Error + Send + Sync>>(())
};
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<dyn std::error::Error + Send + Sync>>(())
};
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"
);
}
}
-40
View File
@@ -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<UpstreamConfig>,
}
pub fn router(upstream: Arc<UpstreamConfig>) -> 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<String>) -> Response {
web_assets::serve_asset(&file)
}
-101
View File
@@ -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 = "<!doctype html><meta charset=utf-8><title>maestro</title>\
<h1>maestro web</h1>\
<p>Web bundle not built. Run <code>pnpm --dir web install</code> \
then <code>pnpm --dir web build</code>, then rebuild <code>maestro-web</code>.</p>";
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<Response> {
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);
}
}
-153
View File
@@ -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");
}
+13 -23
View File
@@ -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 <path>` | 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 <addr>` | Loopback bind for the web UI (default `127.0.0.1:8888`). |
| `--web-registry <url>` | Upstream registry the proxy forwards `/api` to (default: read from `~/.config/maestrod/config.toml`). |
| `--web-exec-start <path>` | Override the web unit's `ExecStart` (default `~/.cargo/bin/maestro-web`). |
| `--web-exec-start <path>` | 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 <URL>`.
`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