feat(maestro): maestro-web standalone binary + reverse-proxy (NAP-241)

New crate crates/maestro-web/: axum server that

* embeds the web/dist SPA bundle via include_dir! (graceful placeholder when
  pnpm build hasn't run yet — keeps fresh checkouts compiling)
* reverse-proxies /api/* HTTP to a co-located daemon (default
  http://127.0.0.1:7070) via reqwest, stripping hop-by-hop headers
* reverse-proxies /api/attach/:id and /api/events WebSockets via
  tokio_tungstenite::connect_async + a tokio task pumping both directions
* reads bind_addr / daemon_url / dev_proxy_target from the new [web]
  config section; env-var overrides MAESTRO_WEB_BIND_ADDR / _DAEMON_URL /
  _DEV_PROXY_TARGET

Integration tests spin up a mock daemon + maestro-web pair on random ports
and verify HTTP forward, SPA fallback, WS echo, and 502 on unreachable
daemon.

Adds a top-level Justfile (`just web` = pnpm install + pnpm build + cargo
build), crates/maestro-web/README.md (config + trust model + dev workflow),
and a docs/INSTALL.md section on optional web UI install.

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