WebSection holds bind_addr, daemon_url, and dev_proxy_target — all optional so a partial file is still valid. Bumps the workspace deps with include_dir, mime_guess, bytes; adds `stream` to reqwest features for proxying bodies. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
436 lines
16 KiB
Rust
436 lines
16 KiB
Rust
//! Unified Maestro on-disk config.
|
|
//!
|
|
//! One TOML file at `~/.config/maestro/config.toml` (override via
|
|
//! `$MAESTRO_CONFIG`) holds settings for every Maestro binary:
|
|
//!
|
|
//! ```toml
|
|
//! [shared]
|
|
//! registry_url = "https://maestro.example.com"
|
|
//! token = "<shared secret>"
|
|
//!
|
|
//! [cli]
|
|
//! cookie = "<jwt cookie>"
|
|
//!
|
|
//! [daemon]
|
|
//! daemon_url = "ws://laptop.lan:7070"
|
|
//! bind_addr = "127.0.0.1:7070"
|
|
//!
|
|
//! [registry]
|
|
//! bind_addr = "127.0.0.1:8080"
|
|
//! ```
|
|
//!
|
|
//! The CLI consumes `[shared].registry_url` as its HTTP base; the daemon
|
|
//! derives its WS endpoint with [`derive_daemon_ws_url`] (scheme swap +
|
|
//! `/daemon` suffix).
|
|
//!
|
|
//! This crate owns the file schema, path resolution, atomic save, and the
|
|
//! URL derivation helper. Each binary applies its own env-var precedence and
|
|
//! defaults on top of [`MaestroConfigFile`].
|
|
|
|
use std::net::SocketAddr;
|
|
use std::path::{Path, PathBuf};
|
|
|
|
use serde::{Deserialize, Serialize};
|
|
|
|
/// Directory name under `$XDG_CONFIG_HOME` (default `~/.config`).
|
|
pub const DIR_NAME: &str = "maestro";
|
|
/// File name inside [`DIR_NAME`].
|
|
pub const FILE_NAME: &str = "config.toml";
|
|
/// Env var that, when set, overrides the resolved config-file path.
|
|
pub const CONFIG_PATH_ENV: &str = "MAESTRO_CONFIG";
|
|
|
|
#[derive(Debug, thiserror::Error)]
|
|
pub enum ConfigError {
|
|
#[error("io: {0}")]
|
|
Io(#[from] std::io::Error),
|
|
#[error("toml decode: {0}")]
|
|
TomlDecode(#[from] toml::de::Error),
|
|
#[error("toml encode: {0}")]
|
|
TomlEncode(#[from] toml::ser::Error),
|
|
#[error("could not determine config directory (XDG_CONFIG_HOME / $HOME unset?)")]
|
|
NoConfigDir,
|
|
#[error(
|
|
"maestro config not found at {}\n\
|
|
Run `maestrod configure` to create it. \
|
|
(Legacy ~/.config/maestrod/config.toml and ~/.config/maestro/{{cli,registry}}.toml \
|
|
are no longer read.)",
|
|
path.display()
|
|
)]
|
|
MissingFile { path: PathBuf },
|
|
#[error("invalid registry_url {input:?}: {reason}")]
|
|
InvalidRegistryUrl { input: String, reason: String },
|
|
}
|
|
|
|
/// On-disk representation of the unified config. Every section is optional
|
|
/// so a partial file (e.g. just `[shared]` + `[registry]` for a registry
|
|
/// host) is valid.
|
|
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
|
|
pub struct MaestroConfigFile {
|
|
#[serde(default, skip_serializing_if = "SharedSection::is_empty")]
|
|
pub shared: SharedSection,
|
|
#[serde(default, skip_serializing_if = "CliSection::is_empty")]
|
|
pub cli: CliSection,
|
|
#[serde(default, skip_serializing_if = "DaemonSection::is_empty")]
|
|
pub daemon: DaemonSection,
|
|
#[serde(default, skip_serializing_if = "RegistrySection::is_empty")]
|
|
pub registry: RegistrySection,
|
|
#[serde(default, skip_serializing_if = "WebSection::is_empty")]
|
|
pub web: WebSection,
|
|
}
|
|
|
|
/// Fields consumed by more than one binary (CLI + daemon, daemon + registry).
|
|
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
|
|
pub struct SharedSection {
|
|
/// Registry base URL, e.g. `https://maestro.example.com`. CLI uses it
|
|
/// as-is for HTTP; the daemon derives its WS endpoint via
|
|
/// [`derive_daemon_ws_url`].
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub registry_url: Option<String>,
|
|
/// Shared HS256 secret. Registry signs JWTs with it, daemons verify
|
|
/// (and authenticate registry calls) with it.
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub token: Option<String>,
|
|
}
|
|
|
|
impl SharedSection {
|
|
fn is_empty(&self) -> bool {
|
|
self.registry_url.is_none() && self.token.is_none()
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
|
|
pub struct CliSection {
|
|
/// Persisted `maestro_session` cookie value (a JWT). Opaque to the CLI.
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub cookie: Option<String>,
|
|
}
|
|
|
|
impl CliSection {
|
|
fn is_empty(&self) -> bool {
|
|
self.cookie.is_none()
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
|
|
pub struct DaemonSection {
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub daemon_id: Option<String>,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub hostname: Option<String>,
|
|
/// Reachable URL clients dial for the daemon's per-session WS endpoint.
|
|
/// Distinct from `bind_addr` because the bind may be `0.0.0.0` while
|
|
/// the publicly reachable address is a Wireguard / VPN interface.
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub daemon_url: Option<String>,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub bind_addr: Option<SocketAddr>,
|
|
/// Root for everything daemon-local: sidecars, FIFOs, etc.
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub state_dir: Option<PathBuf>,
|
|
/// Root for ephemeral worktrees the daemon creates on `CreateSession`
|
|
/// with a `worktree` spec.
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub worktree_root: Option<PathBuf>,
|
|
/// Operator-set perimeter id (e.g. `"wg-naps62"`). When two daemons share
|
|
/// a perimeter, peer-to-peer attach can use the T2 transport. Advertised
|
|
/// to the registry in heartbeats. See connection-orchestration spec.
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub perimeter_id: Option<String>,
|
|
/// Daemon's reachable address on the declared perimeter (e.g.
|
|
/// `"10.0.0.7:7070"`). Advertised in heartbeats; the registry hands it
|
|
/// back to peer daemons as a T2 dial target.
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub private_addr: Option<String>,
|
|
}
|
|
|
|
impl DaemonSection {
|
|
fn is_empty(&self) -> bool {
|
|
self.daemon_id.is_none()
|
|
&& self.hostname.is_none()
|
|
&& self.daemon_url.is_none()
|
|
&& self.bind_addr.is_none()
|
|
&& self.state_dir.is_none()
|
|
&& self.worktree_root.is_none()
|
|
&& self.perimeter_id.is_none()
|
|
&& self.private_addr.is_none()
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
|
|
pub struct RegistrySection {
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub bind_addr: Option<SocketAddr>,
|
|
}
|
|
|
|
impl RegistrySection {
|
|
fn is_empty(&self) -> bool {
|
|
self.bind_addr.is_none()
|
|
}
|
|
}
|
|
|
|
/// `[web]` — `maestro-web` binary settings (NAP-241).
|
|
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
|
|
pub struct WebSection {
|
|
/// Address `maestro-web` binds. Default `127.0.0.1:8080`. Bind a
|
|
/// perimeter-facing address (LAN/WG) to share with other devices on a
|
|
/// trusted segment; do NOT bind a public address without adding auth.
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub bind_addr: Option<SocketAddr>,
|
|
/// HTTP base URL of the co-located daemon `maestro-web` proxies to.
|
|
/// Default `http://127.0.0.1:7070`.
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub daemon_url: Option<String>,
|
|
/// When set, requests for `/` are proxied to a Vite dev server at this
|
|
/// URL instead of being served from the embedded bundle. Use it during
|
|
/// frontend development so live reload works.
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub dev_proxy_target: Option<String>,
|
|
}
|
|
|
|
impl WebSection {
|
|
fn is_empty(&self) -> bool {
|
|
self.bind_addr.is_none() && self.daemon_url.is_none() && self.dev_proxy_target.is_none()
|
|
}
|
|
}
|
|
|
|
impl MaestroConfigFile {
|
|
/// Strict load — error with [`ConfigError::MissingFile`] if absent.
|
|
pub fn load(path: &Path) -> Result<Self, ConfigError> {
|
|
if !path.exists() {
|
|
return Err(ConfigError::MissingFile {
|
|
path: path.to_path_buf(),
|
|
});
|
|
}
|
|
let bytes = std::fs::read(path)?;
|
|
let s = std::str::from_utf8(&bytes).unwrap_or("");
|
|
Ok(toml::from_str(s)?)
|
|
}
|
|
|
|
/// Load if present, else return an empty config. Use for callers that
|
|
/// want a default-on-missing read (e.g. CLI's `set-registry` bootstrap,
|
|
/// registry's first-run token generation).
|
|
pub fn load_or_default(path: &Path) -> Result<Self, ConfigError> {
|
|
if !path.exists() {
|
|
return Ok(Self::default());
|
|
}
|
|
Self::load(path)
|
|
}
|
|
|
|
/// Atomic write (tmp + rename). Creates parent dirs on demand.
|
|
pub fn save(&self, path: &Path) -> Result<(), ConfigError> {
|
|
if let Some(parent) = path.parent() {
|
|
std::fs::create_dir_all(parent)?;
|
|
}
|
|
let body = toml::to_string_pretty(self)?;
|
|
let tmp = path.with_extension("toml.tmp");
|
|
std::fs::write(&tmp, body)?;
|
|
std::fs::rename(&tmp, path)?;
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
/// `$MAESTRO_CONFIG` if set, else [`default_config_path`].
|
|
pub fn resolve_config_path() -> Result<PathBuf, ConfigError> {
|
|
if let Some(p) = std::env::var_os(CONFIG_PATH_ENV) {
|
|
return Ok(PathBuf::from(p));
|
|
}
|
|
default_config_path()
|
|
}
|
|
|
|
/// `~/.config/maestro/config.toml` (XDG-aware).
|
|
pub fn default_config_path() -> Result<PathBuf, ConfigError> {
|
|
let base = dirs::config_dir().ok_or(ConfigError::NoConfigDir)?;
|
|
Ok(base.join(DIR_NAME).join(FILE_NAME))
|
|
}
|
|
|
|
/// Derive the daemon's WS endpoint from the shared `registry_url`.
|
|
///
|
|
/// | input | output |
|
|
/// |--------------------------|---------------------------------|
|
|
/// | `https://r.example` | `wss://r.example/daemon` |
|
|
/// | `http://r.example:8080` | `ws://r.example:8080/daemon` |
|
|
/// | `http://r/some/prefix/` | `ws://r/some/prefix/daemon` |
|
|
///
|
|
/// Trailing slashes are normalised so the result never has `//daemon`.
|
|
/// Any non-`http(s)` scheme returns [`ConfigError::InvalidRegistryUrl`].
|
|
pub fn derive_daemon_ws_url(registry_url: &str) -> Result<String, ConfigError> {
|
|
let trimmed = registry_url.trim_end_matches('/');
|
|
if let Some(rest) = trimmed.strip_prefix("https://") {
|
|
Ok(format!("wss://{rest}/daemon"))
|
|
} else if let Some(rest) = trimmed.strip_prefix("http://") {
|
|
Ok(format!("ws://{rest}/daemon"))
|
|
} else {
|
|
Err(ConfigError::InvalidRegistryUrl {
|
|
input: registry_url.to_string(),
|
|
reason: "must start with http:// or https://".into(),
|
|
})
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn missing_file_strict_load_errors_with_pointer_to_configure() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let path = dir.path().join("config.toml");
|
|
let err = MaestroConfigFile::load(&path).unwrap_err();
|
|
let msg = err.to_string();
|
|
assert!(matches!(err, ConfigError::MissingFile { .. }));
|
|
assert!(msg.contains("maestrod configure"), "msg was: {msg}");
|
|
assert!(msg.contains("Legacy"), "msg was: {msg}");
|
|
}
|
|
|
|
#[test]
|
|
fn missing_file_load_or_default_returns_empty() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let path = dir.path().join("config.toml");
|
|
let cfg = MaestroConfigFile::load_or_default(&path).unwrap();
|
|
assert_eq!(cfg, MaestroConfigFile::default());
|
|
}
|
|
|
|
#[test]
|
|
fn full_file_round_trips() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let path = dir.path().join("config.toml");
|
|
let cfg = MaestroConfigFile {
|
|
shared: SharedSection {
|
|
registry_url: Some("https://r.example".into()),
|
|
token: Some("secret".into()),
|
|
},
|
|
cli: CliSection {
|
|
cookie: Some("eyJ...".into()),
|
|
},
|
|
daemon: DaemonSection {
|
|
daemon_id: Some("laptop".into()),
|
|
hostname: Some("laptop.lan".into()),
|
|
daemon_url: Some("ws://laptop.lan:7070".into()),
|
|
bind_addr: Some("127.0.0.1:7070".parse().unwrap()),
|
|
state_dir: Some(PathBuf::from("/var/lib/maestrod")),
|
|
worktree_root: Some(PathBuf::from("/var/lib/maestrod/wt")),
|
|
perimeter_id: Some("wg-naps62".into()),
|
|
private_addr: Some("10.0.0.7:7070".into()),
|
|
},
|
|
registry: RegistrySection {
|
|
bind_addr: Some("0.0.0.0:8080".parse().unwrap()),
|
|
},
|
|
web: WebSection {
|
|
bind_addr: Some("127.0.0.1:8080".parse().unwrap()),
|
|
daemon_url: Some("http://127.0.0.1:7070".into()),
|
|
dev_proxy_target: Some("http://127.0.0.1:5173".into()),
|
|
},
|
|
};
|
|
cfg.save(&path).unwrap();
|
|
assert_eq!(MaestroConfigFile::load(&path).unwrap(), cfg);
|
|
}
|
|
|
|
#[test]
|
|
fn partial_file_loads_with_section_defaults() {
|
|
// Only [shared] + [registry] present (typical registry host).
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let path = dir.path().join("config.toml");
|
|
std::fs::write(
|
|
&path,
|
|
r#"
|
|
[shared]
|
|
token = "abc"
|
|
|
|
[registry]
|
|
bind_addr = "127.0.0.1:9090"
|
|
"#,
|
|
)
|
|
.unwrap();
|
|
let cfg = MaestroConfigFile::load(&path).unwrap();
|
|
assert_eq!(cfg.shared.token.as_deref(), Some("abc"));
|
|
assert!(cfg.shared.registry_url.is_none());
|
|
assert_eq!(cfg.cli, CliSection::default());
|
|
assert_eq!(cfg.daemon, DaemonSection::default());
|
|
assert_eq!(
|
|
cfg.registry.bind_addr,
|
|
Some("127.0.0.1:9090".parse().unwrap())
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn save_omits_empty_sections() {
|
|
// Avoid littering the file with empty `[cli]` / `[daemon]` tables
|
|
// when only one component is configured.
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let path = dir.path().join("config.toml");
|
|
let cfg = MaestroConfigFile {
|
|
shared: SharedSection {
|
|
token: Some("t".into()),
|
|
..Default::default()
|
|
},
|
|
..Default::default()
|
|
};
|
|
cfg.save(&path).unwrap();
|
|
let body = std::fs::read_to_string(&path).unwrap();
|
|
assert!(body.contains("[shared]"), "body: {body}");
|
|
assert!(!body.contains("[cli]"), "body: {body}");
|
|
assert!(!body.contains("[daemon]"), "body: {body}");
|
|
assert!(!body.contains("[registry]"), "body: {body}");
|
|
}
|
|
|
|
#[test]
|
|
fn save_atomic_creates_parent_dirs() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let path = dir.path().join("nested/here/config.toml");
|
|
MaestroConfigFile::default().save(&path).unwrap();
|
|
assert!(path.exists());
|
|
}
|
|
|
|
#[test]
|
|
fn resolve_config_path_respects_env_var() {
|
|
// We can't safely tweak the process env in a unit test (it races with
|
|
// other tests). Exercise the default path branch instead and rely on
|
|
// an integration-level smoke that env-override works.
|
|
let p = default_config_path().unwrap();
|
|
assert!(p.ends_with(format!("{DIR_NAME}/{FILE_NAME}")));
|
|
}
|
|
|
|
#[test]
|
|
fn derive_daemon_ws_url_swaps_http_to_ws() {
|
|
assert_eq!(
|
|
derive_daemon_ws_url("http://r.example").unwrap(),
|
|
"ws://r.example/daemon"
|
|
);
|
|
assert_eq!(
|
|
derive_daemon_ws_url("http://r.example:8080").unwrap(),
|
|
"ws://r.example:8080/daemon"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn derive_daemon_ws_url_swaps_https_to_wss() {
|
|
assert_eq!(
|
|
derive_daemon_ws_url("https://r.example").unwrap(),
|
|
"wss://r.example/daemon"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn derive_daemon_ws_url_normalises_trailing_slash() {
|
|
assert_eq!(
|
|
derive_daemon_ws_url("https://r.example/").unwrap(),
|
|
"wss://r.example/daemon"
|
|
);
|
|
assert_eq!(
|
|
derive_daemon_ws_url("https://r.example/path/").unwrap(),
|
|
"wss://r.example/path/daemon"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn derive_daemon_ws_url_rejects_non_http_schemes() {
|
|
let err = derive_daemon_ws_url("ws://r.example/daemon").unwrap_err();
|
|
match err {
|
|
ConfigError::InvalidRegistryUrl { input, .. } => {
|
|
assert_eq!(input, "ws://r.example/daemon");
|
|
}
|
|
other => panic!("expected InvalidRegistryUrl, got {other:?}"),
|
|
}
|
|
}
|
|
}
|