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:
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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.
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -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(())
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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}");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
@@ -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 |
|
||||||
|
|||||||
Reference in New Issue
Block a user