2026-04-24 18:39:16 +00:00
|
|
|
//! Gateway configuration types — pure parsing and validation.
|
|
|
|
|
//!
|
|
|
|
|
//! Contains `ProjectEntry`, `GatewayConfig`, and validation logic.
|
|
|
|
|
//! All filesystem I/O (loading from disk) lives in `io.rs`.
|
|
|
|
|
|
|
|
|
|
use serde::{Deserialize, Serialize};
|
|
|
|
|
use std::collections::BTreeMap;
|
|
|
|
|
|
|
|
|
|
/// A single project entry in `projects.toml`.
|
2026-05-12 23:11:34 +00:00
|
|
|
///
|
|
|
|
|
/// Phase 2 (story 899): `url` is now optional — a project served exclusively
|
|
|
|
|
/// via the sled-uplink WebSocket does not need an HTTP base URL. The `url`
|
|
|
|
|
/// field is deprecated for removal in a future release; configure
|
|
|
|
|
/// `auth_token` instead and rely on the WS uplink for all traffic.
|
2026-04-24 18:39:16 +00:00
|
|
|
#[derive(Debug, Clone, Deserialize, Serialize)]
|
|
|
|
|
pub struct ProjectEntry {
|
|
|
|
|
/// Base URL of the project's huskies container (e.g. `http://localhost:3001`).
|
2026-05-12 23:11:34 +00:00
|
|
|
///
|
|
|
|
|
/// **Deprecated** (story 899) — when a sled connects via the uplink WS the
|
|
|
|
|
/// gateway routes all MCP traffic over that connection instead. The URL is
|
|
|
|
|
/// used as a fallback when no live uplink exists. Omit for WS-only projects.
|
|
|
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
|
|
|
pub url: Option<String>,
|
|
|
|
|
/// Shared-secret token used to authenticate this project's sled when it
|
|
|
|
|
/// connects to `/api/sled-uplink`. Takes precedence over the top-level
|
|
|
|
|
/// `[sled_tokens]` table for projects that set this field.
|
|
|
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
|
|
|
pub auth_token: Option<String>,
|
2026-05-16 23:32:33 +00:00
|
|
|
/// Host-local port for SSH access into the project container.
|
|
|
|
|
///
|
|
|
|
|
/// Set by `new project` (story 1108). The container's SSH server is bound
|
|
|
|
|
/// to `127.0.0.1:<ssh_port>:22` so the user can connect with
|
|
|
|
|
/// `ssh huskies@127.0.0.1 -p <ssh_port> -i ~/.huskies/<name>/id_ed25519`.
|
|
|
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
|
|
|
pub ssh_port: Option<u16>,
|
2026-05-12 23:11:34 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl ProjectEntry {
|
|
|
|
|
/// Convenience constructor for entries that only have a URL (e.g. in tests
|
|
|
|
|
/// and existing `projects.toml` files that have not yet been migrated to
|
|
|
|
|
/// the WS-uplink model).
|
|
|
|
|
pub fn with_url(url: impl Into<String>) -> Self {
|
|
|
|
|
Self {
|
|
|
|
|
url: Some(url.into()),
|
|
|
|
|
auth_token: None,
|
2026-05-16 23:32:33 +00:00
|
|
|
ssh_port: None,
|
2026-05-12 23:11:34 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Returns `true` if this entry has a configured HTTP base URL.
|
|
|
|
|
pub fn has_url(&self) -> bool {
|
|
|
|
|
self.url.as_ref().is_some_and(|u| !u.is_empty())
|
|
|
|
|
}
|
2026-04-24 18:39:16 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Top-level `projects.toml` config.
|
|
|
|
|
#[derive(Debug, Clone, Deserialize, Serialize)]
|
|
|
|
|
pub struct GatewayConfig {
|
2026-05-12 23:11:34 +00:00
|
|
|
/// Map of project name → container configuration.
|
2026-04-24 18:39:16 +00:00
|
|
|
#[serde(default)]
|
|
|
|
|
pub projects: BTreeMap<String, ProjectEntry>,
|
2026-05-12 21:29:04 +00:00
|
|
|
/// Map of sled_id → shared secret token for sled-uplink authentication.
|
|
|
|
|
///
|
2026-05-12 23:11:34 +00:00
|
|
|
/// **Deprecated** (story 899) — move tokens into per-project
|
|
|
|
|
/// `auth_token` fields instead. The gateway still honours entries here for
|
|
|
|
|
/// one release to provide a smooth migration window.
|
|
|
|
|
///
|
2026-05-12 21:29:04 +00:00
|
|
|
/// Each entry allows a sled identified by `sled_id` to connect to
|
|
|
|
|
/// `/api/sled-uplink` using the given secret token as a bearer credential.
|
|
|
|
|
#[serde(default)]
|
|
|
|
|
pub sled_tokens: BTreeMap<String, String>,
|
2026-04-24 18:39:16 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Validate that a gateway config has at least one project.
|
|
|
|
|
///
|
|
|
|
|
/// Returns the name of the first project (alphabetically) on success,
|
|
|
|
|
/// or an error message if the config is empty.
|
|
|
|
|
pub fn validate_config(config: &GatewayConfig) -> Result<String, String> {
|
|
|
|
|
if config.projects.is_empty() {
|
|
|
|
|
return Err("projects.toml must define at least one project".to_string());
|
|
|
|
|
}
|
|
|
|
|
Ok(config.projects.keys().next().unwrap().clone())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Validate that a project name exists in the given project map.
|
|
|
|
|
///
|
2026-05-12 23:11:34 +00:00
|
|
|
/// Returns the project's URL (may be empty for WS-uplink-only projects) on
|
|
|
|
|
/// success.
|
2026-04-24 18:39:16 +00:00
|
|
|
pub fn validate_project_exists(
|
|
|
|
|
projects: &BTreeMap<String, ProjectEntry>,
|
|
|
|
|
name: &str,
|
|
|
|
|
) -> Result<String, String> {
|
2026-05-12 23:11:34 +00:00
|
|
|
projects
|
|
|
|
|
.get(name)
|
|
|
|
|
.map(|p| p.url.clone().unwrap_or_default())
|
|
|
|
|
.ok_or_else(|| {
|
|
|
|
|
let available: Vec<&str> = projects.keys().map(|s| s.as_str()).collect();
|
|
|
|
|
format!(
|
|
|
|
|
"unknown project '{name}'. Available: {}",
|
|
|
|
|
available.join(", ")
|
|
|
|
|
)
|
|
|
|
|
})
|
2026-04-24 18:39:16 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Escape a string as a TOML quoted string.
|
|
|
|
|
pub fn toml_string(s: &str) -> String {
|
|
|
|
|
format!("\"{}\"", s.replace('\\', "\\\\").replace('"', "\\\""))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Serialize a `bot.toml` content string from the given fields.
|
|
|
|
|
pub fn serialize_bot_config(
|
|
|
|
|
transport: &str,
|
|
|
|
|
homeserver: Option<&str>,
|
|
|
|
|
username: Option<&str>,
|
|
|
|
|
password: Option<&str>,
|
|
|
|
|
slack_bot_token: Option<&str>,
|
|
|
|
|
slack_signing_secret: Option<&str>,
|
|
|
|
|
) -> String {
|
|
|
|
|
match transport {
|
|
|
|
|
"slack" => {
|
|
|
|
|
format!(
|
|
|
|
|
"enabled = true\ntransport = \"slack\"\n\nslack_bot_token = {}\nslack_signing_secret = {}\nslack_channel_ids = []\n",
|
|
|
|
|
toml_string(slack_bot_token.unwrap_or("")),
|
|
|
|
|
toml_string(slack_signing_secret.unwrap_or("")),
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
_ => {
|
|
|
|
|
format!(
|
|
|
|
|
"enabled = true\ntransport = \"matrix\"\n\nhomeserver = {}\nusername = {}\npassword = {}\nroom_ids = []\nallowed_users = []\n",
|
|
|
|
|
toml_string(homeserver.unwrap_or("")),
|
|
|
|
|
toml_string(username.unwrap_or("")),
|
|
|
|
|
toml_string(password.unwrap_or("")),
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── Tests ────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
#[cfg(test)]
|
|
|
|
|
mod tests {
|
|
|
|
|
use super::*;
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn parse_valid_projects_toml() {
|
|
|
|
|
let toml_str = r#"
|
|
|
|
|
[projects.huskies]
|
|
|
|
|
url = "http://localhost:3001"
|
|
|
|
|
|
|
|
|
|
[projects.robot-studio]
|
|
|
|
|
url = "http://localhost:3002"
|
|
|
|
|
"#;
|
|
|
|
|
let config: GatewayConfig = toml::from_str(toml_str).unwrap();
|
|
|
|
|
assert_eq!(config.projects.len(), 2);
|
2026-05-12 23:11:34 +00:00
|
|
|
assert_eq!(
|
|
|
|
|
config.projects["huskies"].url.as_deref(),
|
|
|
|
|
Some("http://localhost:3001")
|
|
|
|
|
);
|
|
|
|
|
assert_eq!(
|
|
|
|
|
config.projects["robot-studio"].url.as_deref(),
|
|
|
|
|
Some("http://localhost:3002")
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn parse_project_without_url_is_valid() {
|
|
|
|
|
let toml_str = r#"
|
|
|
|
|
[projects.ws-only]
|
|
|
|
|
auth_token = "secret"
|
|
|
|
|
"#;
|
|
|
|
|
let config: GatewayConfig = toml::from_str(toml_str).unwrap();
|
|
|
|
|
assert_eq!(config.projects.len(), 1);
|
|
|
|
|
assert!(config.projects["ws-only"].url.is_none());
|
|
|
|
|
assert_eq!(
|
|
|
|
|
config.projects["ws-only"].auth_token.as_deref(),
|
|
|
|
|
Some("secret")
|
|
|
|
|
);
|
2026-04-24 18:39:16 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn parse_empty_projects_toml() {
|
|
|
|
|
let toml_str = "[projects]\n";
|
|
|
|
|
let config: GatewayConfig = toml::from_str(toml_str).unwrap();
|
|
|
|
|
assert!(config.projects.is_empty());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn validate_config_rejects_empty() {
|
|
|
|
|
let config = GatewayConfig {
|
|
|
|
|
projects: BTreeMap::new(),
|
2026-05-12 21:29:04 +00:00
|
|
|
sled_tokens: BTreeMap::new(),
|
2026-04-24 18:39:16 +00:00
|
|
|
};
|
|
|
|
|
assert!(validate_config(&config).is_err());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn validate_config_returns_first_project_name() {
|
|
|
|
|
let mut projects = BTreeMap::new();
|
2026-05-12 23:11:34 +00:00
|
|
|
projects.insert("beta".into(), ProjectEntry::with_url("http://b"));
|
|
|
|
|
projects.insert("alpha".into(), ProjectEntry::with_url("http://a"));
|
2026-05-12 21:29:04 +00:00
|
|
|
let config = GatewayConfig {
|
|
|
|
|
projects,
|
|
|
|
|
sled_tokens: BTreeMap::new(),
|
|
|
|
|
};
|
2026-04-24 18:39:16 +00:00
|
|
|
assert_eq!(validate_config(&config).unwrap(), "alpha");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
2026-05-12 23:11:34 +00:00
|
|
|
fn validate_config_accepts_ws_only_project() {
|
2026-04-24 18:39:16 +00:00
|
|
|
let mut projects = BTreeMap::new();
|
|
|
|
|
projects.insert(
|
2026-05-12 23:11:34 +00:00
|
|
|
"ws-only".into(),
|
2026-04-24 18:39:16 +00:00
|
|
|
ProjectEntry {
|
2026-05-12 23:11:34 +00:00
|
|
|
url: None,
|
|
|
|
|
auth_token: Some("secret".into()),
|
2026-05-16 23:32:33 +00:00
|
|
|
ssh_port: None,
|
2026-04-24 18:39:16 +00:00
|
|
|
},
|
|
|
|
|
);
|
2026-05-12 23:11:34 +00:00
|
|
|
let config = GatewayConfig {
|
|
|
|
|
projects,
|
|
|
|
|
sled_tokens: BTreeMap::new(),
|
|
|
|
|
};
|
|
|
|
|
assert!(validate_config(&config).is_ok());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn validate_project_exists_succeeds() {
|
|
|
|
|
let mut projects = BTreeMap::new();
|
|
|
|
|
projects.insert("p1".into(), ProjectEntry::with_url("http://p1"));
|
2026-04-24 18:39:16 +00:00
|
|
|
assert_eq!(
|
|
|
|
|
validate_project_exists(&projects, "p1").unwrap(),
|
|
|
|
|
"http://p1"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn validate_project_exists_fails() {
|
|
|
|
|
let projects = BTreeMap::new();
|
|
|
|
|
assert!(validate_project_exists(&projects, "missing").is_err());
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-12 23:11:34 +00:00
|
|
|
#[test]
|
|
|
|
|
fn validate_project_exists_ws_only_returns_empty_url() {
|
|
|
|
|
let mut projects = BTreeMap::new();
|
|
|
|
|
projects.insert(
|
|
|
|
|
"ws".into(),
|
|
|
|
|
ProjectEntry {
|
|
|
|
|
url: None,
|
|
|
|
|
auth_token: Some("tok".into()),
|
2026-05-16 23:32:33 +00:00
|
|
|
ssh_port: None,
|
2026-05-12 23:11:34 +00:00
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
assert_eq!(validate_project_exists(&projects, "ws").unwrap(), "");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn project_entry_with_url_constructor() {
|
|
|
|
|
let e = ProjectEntry::with_url("http://example.com");
|
|
|
|
|
assert_eq!(e.url.as_deref(), Some("http://example.com"));
|
|
|
|
|
assert!(e.auth_token.is_none());
|
|
|
|
|
assert!(e.has_url());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn project_entry_has_url_false_when_none() {
|
|
|
|
|
let e = ProjectEntry {
|
|
|
|
|
url: None,
|
|
|
|
|
auth_token: Some("tok".into()),
|
2026-05-16 23:32:33 +00:00
|
|
|
ssh_port: None,
|
2026-05-12 23:11:34 +00:00
|
|
|
};
|
|
|
|
|
assert!(!e.has_url());
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-24 18:39:16 +00:00
|
|
|
#[test]
|
|
|
|
|
fn toml_string_escapes_quotes() {
|
|
|
|
|
assert_eq!(toml_string(r#"a"b"#), r#""a\"b""#);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn toml_string_escapes_backslashes() {
|
|
|
|
|
assert_eq!(toml_string(r"a\b"), r#""a\\b""#);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn serialize_bot_config_matrix() {
|
|
|
|
|
let content = serialize_bot_config(
|
|
|
|
|
"matrix",
|
|
|
|
|
Some("https://mx.io"),
|
|
|
|
|
Some("@bot:mx.io"),
|
|
|
|
|
Some("pass"),
|
|
|
|
|
None,
|
|
|
|
|
None,
|
|
|
|
|
);
|
|
|
|
|
assert!(content.contains("transport = \"matrix\""));
|
|
|
|
|
assert!(content.contains("homeserver = \"https://mx.io\""));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn serialize_bot_config_slack() {
|
|
|
|
|
let content =
|
|
|
|
|
serialize_bot_config("slack", None, None, None, Some("xoxb-123"), Some("secret"));
|
|
|
|
|
assert!(content.contains("transport = \"slack\""));
|
|
|
|
|
assert!(content.contains("slack_bot_token = \"xoxb-123\""));
|
|
|
|
|
}
|
2026-05-12 23:11:34 +00:00
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn roundtrip_project_entry_with_auth_token() {
|
|
|
|
|
let entry = ProjectEntry {
|
|
|
|
|
url: Some("http://a:3001".into()),
|
|
|
|
|
auth_token: Some("mysecret".into()),
|
2026-05-16 23:32:33 +00:00
|
|
|
ssh_port: None,
|
2026-05-12 23:11:34 +00:00
|
|
|
};
|
|
|
|
|
let mut projects = BTreeMap::new();
|
|
|
|
|
projects.insert("myproj".into(), entry);
|
|
|
|
|
let config = GatewayConfig {
|
|
|
|
|
projects,
|
|
|
|
|
sled_tokens: BTreeMap::new(),
|
|
|
|
|
};
|
|
|
|
|
let toml_str = toml::to_string_pretty(&config).unwrap();
|
|
|
|
|
let parsed: GatewayConfig = toml::from_str(&toml_str).unwrap();
|
|
|
|
|
assert_eq!(
|
|
|
|
|
parsed.projects["myproj"].url.as_deref(),
|
|
|
|
|
Some("http://a:3001")
|
|
|
|
|
);
|
|
|
|
|
assert_eq!(
|
|
|
|
|
parsed.projects["myproj"].auth_token.as_deref(),
|
|
|
|
|
Some("mysecret")
|
|
|
|
|
);
|
|
|
|
|
}
|
2026-05-16 23:32:33 +00:00
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn roundtrip_project_entry_with_ssh_port() {
|
|
|
|
|
let entry = ProjectEntry {
|
|
|
|
|
url: Some("http://127.0.0.1:3101".into()),
|
|
|
|
|
auth_token: None,
|
|
|
|
|
ssh_port: Some(2201),
|
|
|
|
|
};
|
|
|
|
|
let mut projects = BTreeMap::new();
|
|
|
|
|
projects.insert("myproj".into(), entry);
|
|
|
|
|
let config = GatewayConfig {
|
|
|
|
|
projects,
|
|
|
|
|
sled_tokens: BTreeMap::new(),
|
|
|
|
|
};
|
|
|
|
|
let toml_str = toml::to_string_pretty(&config).unwrap();
|
|
|
|
|
let parsed: GatewayConfig = toml::from_str(&toml_str).unwrap();
|
|
|
|
|
assert_eq!(parsed.projects["myproj"].ssh_port, Some(2201));
|
|
|
|
|
// ssh_port must appear in the serialised TOML.
|
|
|
|
|
assert!(
|
|
|
|
|
toml_str.contains("ssh_port = 2201"),
|
|
|
|
|
"ssh_port missing from TOML: {toml_str}"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn ssh_port_none_is_omitted_from_toml() {
|
|
|
|
|
let entry = ProjectEntry::with_url("http://127.0.0.1:3101");
|
|
|
|
|
let mut projects = BTreeMap::new();
|
|
|
|
|
projects.insert("p".into(), entry);
|
|
|
|
|
let config = GatewayConfig {
|
|
|
|
|
projects,
|
|
|
|
|
sled_tokens: BTreeMap::new(),
|
|
|
|
|
};
|
|
|
|
|
let toml_str = toml::to_string_pretty(&config).unwrap();
|
|
|
|
|
assert!(
|
|
|
|
|
!toml_str.contains("ssh_port"),
|
|
|
|
|
"ssh_port should be omitted when None: {toml_str}"
|
|
|
|
|
);
|
|
|
|
|
}
|
2026-04-24 18:39:16 +00:00
|
|
|
}
|