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-registry/ # always-on phone book + HTTP API (NAP-213, NAP-215)
|
||||||
├── maestro-cli/ # `maestro list/spawn/attach/kill` (NAP-216)
|
├── maestro-cli/ # `maestro list/spawn/attach/kill` (NAP-216)
|
||||||
├── maestro-installer/ # `maestro-install` wizard: cargo install + systemd (NAP-225)
|
├── 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)
|
└── maestro-e2e/ # cross-process integration tests (NAP-217)
|
||||||
web/ # browser client (NAP-218, plan 2)
|
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"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d"
|
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]]
|
[[package]]
|
||||||
name = "futures-sink"
|
name = "futures-sink"
|
||||||
version = "0.3.32"
|
version = "0.3.32"
|
||||||
@@ -490,11 +473,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6"
|
checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"futures-core",
|
"futures-core",
|
||||||
"futures-io",
|
|
||||||
"futures-macro",
|
|
||||||
"futures-sink",
|
"futures-sink",
|
||||||
"futures-task",
|
"futures-task",
|
||||||
"memchr",
|
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
"slab",
|
"slab",
|
||||||
]
|
]
|
||||||
@@ -1033,21 +1013,6 @@ dependencies = [
|
|||||||
"uuid",
|
"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]]
|
[[package]]
|
||||||
name = "matchers"
|
name = "matchers"
|
||||||
version = "0.2.0"
|
version = "0.2.0"
|
||||||
@@ -1385,7 +1350,6 @@ dependencies = [
|
|||||||
"cookie",
|
"cookie",
|
||||||
"cookie_store",
|
"cookie_store",
|
||||||
"futures-core",
|
"futures-core",
|
||||||
"futures-util",
|
|
||||||
"http",
|
"http",
|
||||||
"http-body",
|
"http-body",
|
||||||
"http-body-util",
|
"http-body-util",
|
||||||
@@ -1405,14 +1369,12 @@ dependencies = [
|
|||||||
"sync_wrapper",
|
"sync_wrapper",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tokio-rustls 0.26.4",
|
"tokio-rustls 0.26.4",
|
||||||
"tokio-util",
|
|
||||||
"tower",
|
"tower",
|
||||||
"tower-http",
|
"tower-http",
|
||||||
"tower-service",
|
"tower-service",
|
||||||
"url",
|
"url",
|
||||||
"wasm-bindgen",
|
"wasm-bindgen",
|
||||||
"wasm-bindgen-futures",
|
"wasm-bindgen-futures",
|
||||||
"wasm-streams",
|
|
||||||
"web-sys",
|
"web-sys",
|
||||||
"webpki-roots 1.0.7",
|
"webpki-roots 1.0.7",
|
||||||
]
|
]
|
||||||
@@ -1958,19 +1920,6 @@ dependencies = [
|
|||||||
"tungstenite 0.24.0",
|
"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]]
|
[[package]]
|
||||||
name = "toml"
|
name = "toml"
|
||||||
version = "0.8.23"
|
version = "0.8.23"
|
||||||
@@ -2363,19 +2312,6 @@ dependencies = [
|
|||||||
"wasmparser",
|
"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]]
|
[[package]]
|
||||||
name = "wasmparser"
|
name = "wasmparser"
|
||||||
version = "0.244.0"
|
version = "0.244.0"
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ use maestro_installer::{
|
|||||||
const DEFAULT_GIT_URL: &str = "https://git.naps.pt/naps62/maestro";
|
const DEFAULT_GIT_URL: &str = "https://git.naps.pt/naps62/maestro";
|
||||||
const DAEMON_CRATE: &str = "maestro-daemon";
|
const DAEMON_CRATE: &str = "maestro-daemon";
|
||||||
const CLI_CRATE: &str = "maestro-cli";
|
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";
|
const SYSTEMD_PROBE: &str = "/run/systemd/system";
|
||||||
|
|
||||||
#[derive(Debug, Parser)]
|
#[derive(Debug, Parser)]
|
||||||
@@ -63,8 +63,8 @@ struct Args {
|
|||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
no_configure: bool,
|
no_configure: bool,
|
||||||
|
|
||||||
/// Skip the optional local web-UI systemd unit (no `maestro-web`
|
/// Skip the optional local web-UI systemd unit (no
|
||||||
/// install, no `maestro-web.service`).
|
/// `maestro-registry` install, no `maestro-web.service`).
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
no_web: bool,
|
no_web: bool,
|
||||||
|
|
||||||
@@ -74,19 +74,13 @@ struct Args {
|
|||||||
exec_start: Option<PathBuf>,
|
exec_start: Option<PathBuf>,
|
||||||
|
|
||||||
/// Override the ExecStart path for `maestro-web.service`.
|
/// Override the ExecStart path for `maestro-web.service`.
|
||||||
/// Default: `~/.cargo/bin/maestro-web`.
|
/// Default: `~/.cargo/bin/maestro-registry`.
|
||||||
#[arg(long, value_name = "PATH")]
|
#[arg(long, value_name = "PATH")]
|
||||||
web_exec_start: Option<PathBuf>,
|
web_exec_start: Option<PathBuf>,
|
||||||
|
|
||||||
/// Loopback `host:port` the local web UI binds to.
|
/// Loopback `host:port` the local web UI binds to.
|
||||||
#[arg(long, value_name = "ADDR", default_value = DEFAULT_WEB_BIND_ADDR)]
|
#[arg(long, value_name = "ADDR", default_value = DEFAULT_WEB_BIND_ADDR)]
|
||||||
web_bind: String,
|
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 {
|
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>> {
|
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())
|
.ok_or_else(|| "could not resolve $HOME (needed for web ExecStart)".into())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Decide which registry URL to bake into `Environment=MAESTRO_REGISTRY=`.
|
/// Drop the web-UI unit (and install `maestro-registry` if the user opts in).
|
||||||
///
|
|
||||||
/// 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).
|
|
||||||
/// Returns `true` if the unit ended up on disk after this call.
|
/// Returns `true` if the unit ended up on disk after this call.
|
||||||
fn handle_web_unit(
|
fn handle_web_unit(
|
||||||
source: &CargoSource,
|
source: &CargoSource,
|
||||||
@@ -420,12 +354,12 @@ fn handle_web_unit(
|
|||||||
unit_path.display()
|
unit_path.display()
|
||||||
);
|
);
|
||||||
} else {
|
} 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!(
|
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
|
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 {
|
let want_unit = if already_installed || !interactive {
|
||||||
@@ -433,7 +367,7 @@ fn handle_web_unit(
|
|||||||
} else {
|
} else {
|
||||||
confirm(
|
confirm(
|
||||||
&format!(
|
&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()
|
unit_path.display()
|
||||||
),
|
),
|
||||||
false,
|
false,
|
||||||
@@ -443,13 +377,10 @@ fn handle_web_unit(
|
|||||||
return Ok(false);
|
return Ok(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Resolve the upstream registry URL early so we don't burn install time
|
// Install (or update) the registry binary first — the unit's ExecStart
|
||||||
// on cargo just to fail at unit-rendering.
|
// points at it.
|
||||||
let upstream = resolve_web_registry(args, interactive)?;
|
|
||||||
|
|
||||||
// Install (or update) the web front-end binary.
|
|
||||||
let locked = !args.no_locked;
|
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 {
|
let exec_start = match &args.web_exec_start {
|
||||||
Some(p) => p.clone(),
|
Some(p) => p.clone(),
|
||||||
@@ -458,7 +389,7 @@ fn handle_web_unit(
|
|||||||
if let Some(parent) = unit_path.parent() {
|
if let Some(parent) = unit_path.parent() {
|
||||||
std::fs::create_dir_all(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)?;
|
std::fs::write(&unit_path, body)?;
|
||||||
eprintln!("wrote {}", unit_path.display());
|
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
|
/// 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
|
/// `maestro-registry`; `bind_addr` is the `host:port` the registry listens
|
||||||
/// to; `upstream` is the URL of the registry the proxy forwards `/api` to.
|
/// on (set via the `MAESTRO_REGISTRY_BIND_ADDR` env var).
|
||||||
///
|
///
|
||||||
/// `Wants=` rather than `Requires=`: `maestro-web` is just a proxy to the
|
/// `Wants=` rather than `Requires=`: the registry binary doesn't actually
|
||||||
/// upstream registry; locally it doesn't depend on `maestrod` to start.
|
/// need maestrod to start — they're decoupled at the process level. But the
|
||||||
/// But the web UI has nothing to show without a daemon connected to the
|
/// web UI has nothing to show without a daemon connected, so we order this
|
||||||
/// registry, so we order after maestrod and politely express the dep.
|
/// after maestrod and politely express the dependency.
|
||||||
pub fn render_web_user_unit(exec_start: &Path, bind_addr: &str, upstream: &str) -> String {
|
pub fn render_web_user_unit(exec_start: &Path, bind_addr: &str) -> String {
|
||||||
format!(
|
format!(
|
||||||
"[Unit]\n\
|
"[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\
|
Documentation=https://git.naps.pt/naps62/maestro\n\
|
||||||
After=network-online.target maestrod.service\n\
|
After=network-online.target maestrod.service\n\
|
||||||
Wants=maestrod.service\n\
|
Wants=maestrod.service\n\
|
||||||
\n\
|
\n\
|
||||||
[Service]\n\
|
[Service]\n\
|
||||||
Type=simple\n\
|
Type=simple\n\
|
||||||
Environment=MAESTRO_WEB_BIND_ADDR={bind}\n\
|
Environment=MAESTRO_REGISTRY_BIND_ADDR={bind}\n\
|
||||||
Environment=MAESTRO_REGISTRY={upstream}\n\
|
|
||||||
ExecStart={exec}\n\
|
ExecStart={exec}\n\
|
||||||
Restart=on-failure\n\
|
Restart=on-failure\n\
|
||||||
RestartSec=5\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",
|
WantedBy=default.target\n",
|
||||||
exec = exec_start.display(),
|
exec = exec_start.display(),
|
||||||
bind = bind_addr,
|
bind = bind_addr,
|
||||||
upstream = upstream,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Default `ExecStart` for the web unit: `~/.cargo/bin/maestro-web`.
|
/// Default `ExecStart` for the web unit: `~/.cargo/bin/maestro-registry`.
|
||||||
pub fn default_maestro_web_path() -> Option<PathBuf> {
|
pub fn default_maestro_registry_path() -> Option<PathBuf> {
|
||||||
Some(
|
Some(
|
||||||
dirs::home_dir()?
|
dirs::home_dir()?
|
||||||
.join(".cargo")
|
.join(".cargo")
|
||||||
.join("bin")
|
.join("bin")
|
||||||
.join("maestro-web"),
|
.join("maestro-registry"),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -160,15 +158,13 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn web_unit_renders_with_bind_exec_and_upstream() {
|
fn web_unit_renders_with_bind_and_exec() {
|
||||||
let body = render_web_user_unit(
|
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",
|
"127.0.0.1:8888",
|
||||||
"https://maestro-registry.example.com",
|
|
||||||
);
|
);
|
||||||
assert!(body.contains("ExecStart=/home/alice/.cargo/bin/maestro-web"));
|
assert!(body.contains("ExecStart=/home/alice/.cargo/bin/maestro-registry"));
|
||||||
assert!(body.contains("Environment=MAESTRO_WEB_BIND_ADDR=127.0.0.1:8888"));
|
assert!(body.contains("Environment=MAESTRO_REGISTRY_BIND_ADDR=127.0.0.1:8888"));
|
||||||
assert!(body.contains("Environment=MAESTRO_REGISTRY=https://maestro-registry.example.com"));
|
|
||||||
assert!(body.contains("After=network-online.target maestrod.service"));
|
assert!(body.contains("After=network-online.target maestrod.service"));
|
||||||
assert!(body.contains("Wants=maestrod.service"));
|
assert!(body.contains("Wants=maestrod.service"));
|
||||||
assert!(body.contains("WantedBy=default.target"));
|
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.
|
enable + start it.
|
||||||
4. On systemd hosts, also offers a **local web UI** unit
|
4. On systemd hosts, also offers a **local web UI** unit
|
||||||
(`~/.config/systemd/user/maestro-web.service`). When accepted, this also
|
(`~/.config/systemd/user/maestro-web.service`). When accepted, this also
|
||||||
`cargo install`s `maestro-web` — a thin front-end that serves the bundled
|
`cargo install`s `maestro-registry` and runs it bound to `127.0.0.1:8888`
|
||||||
UI on `127.0.0.1:8888` (override with `--web-bind`) and reverse-proxies
|
(override with `--web-bind`). Skip with `--no-web` (or just answer "n"); a
|
||||||
`/api` to your existing registry. Skip with `--no-web` (or answer "n"); you
|
remote registry deployment is the alternative.
|
||||||
can always just open the registry's own URL in a browser instead.
|
|
||||||
|
|
||||||
Binaries land in `~/.cargo/bin` — make sure that's on your `$PATH`.
|
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`). |
|
| `--exec-start <path>` | Override the systemd unit's `ExecStart` (default `~/.cargo/bin/maestrod`). |
|
||||||
| `--no-systemd` | Skip the systemd step even if detected. |
|
| `--no-systemd` | Skip the systemd step even if detected. |
|
||||||
| `--no-configure` | Skip the `maestrod configure` step. |
|
| `--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-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-registry`). |
|
||||||
| `--web-exec-start <path>` | Override the web unit's `ExecStart` (default `~/.cargo/bin/maestro-web`). |
|
|
||||||
| `-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). |
|
| `-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
|
## What the systemd unit looks like
|
||||||
@@ -97,23 +95,19 @@ systemctl --user status maestrod
|
|||||||
journalctl --user -u maestrod -f
|
journalctl --user -u maestrod -f
|
||||||
```
|
```
|
||||||
|
|
||||||
The optional `maestro-web.service` runs a thin front-end that serves the
|
The optional `maestro-web.service` looks like:
|
||||||
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:
|
|
||||||
|
|
||||||
```ini
|
```ini
|
||||||
[Unit]
|
[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
|
Documentation=https://git.naps.pt/naps62/maestro
|
||||||
After=network-online.target maestrod.service
|
After=network-online.target maestrod.service
|
||||||
Wants=maestrod.service
|
Wants=maestrod.service
|
||||||
|
|
||||||
[Service]
|
[Service]
|
||||||
Type=simple
|
Type=simple
|
||||||
Environment=MAESTRO_WEB_BIND_ADDR=127.0.0.1:8888
|
Environment=MAESTRO_REGISTRY_BIND_ADDR=127.0.0.1:8888
|
||||||
Environment=MAESTRO_REGISTRY=https://maestro-registry.example.com
|
ExecStart=%h/.cargo/bin/maestro-registry
|
||||||
ExecStart=%h/.cargo/bin/maestro-web
|
|
||||||
Restart=on-failure
|
Restart=on-failure
|
||||||
RestartSec=5
|
RestartSec=5
|
||||||
|
|
||||||
@@ -121,14 +115,10 @@ RestartSec=5
|
|||||||
WantedBy=default.target
|
WantedBy=default.target
|
||||||
```
|
```
|
||||||
|
|
||||||
`Wants=maestrod.service` (not `Requires=`) — `maestro-web` is just a proxy
|
`Wants=maestrod.service` (not `Requires=`) — the registry process is
|
||||||
to the upstream registry; locally it doesn't depend on `maestrod` to start.
|
technically independent of the daemon, but the web UI has nothing to show
|
||||||
But the UI has nothing to show without a daemon connected, so we order
|
without one connected, so we order this after `maestrod` and politely
|
||||||
this after `maestrod` and politely encourage it.
|
encourage it.
|
||||||
|
|
||||||
The wizard fills in `MAESTRO_REGISTRY` from the daemon's
|
|
||||||
`~/.config/maestrod/config.toml` automatically; override with
|
|
||||||
`--web-registry <URL>`.
|
|
||||||
|
|
||||||
## Running the wizard without the bootstrap script
|
## Running the wizard without the bootstrap script
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user