Revert "fix(maestro): maestro-web is now a proxy, not a local registry (NAP-231)"
This reverts commit 3b6dd50c1d.
This commit is contained in:
@@ -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
@@ -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"
|
||||
|
||||
@@ -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());
|
||||
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -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"] }
|
||||
@@ -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};
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user