diff --git a/src/enrol.rs b/src/enrol.rs new file mode 100644 index 0000000..772042c --- /dev/null +++ b/src/enrol.rs @@ -0,0 +1,188 @@ +//! `continuum-proxy enrol [name]`: connect this gateway to +//! a farm with the one-time code the console shows, instead of copying a +//! farm id and token between machines. +//! +//! The code is traded at `POST /auth/enrol` for the farm id and the gateway +//! token, which are written into the proxy's config file (owner-only, since +//! the token is a credential) next to whatever else it already holds. The +//! next start reads them like any other setting. +//! +//! PC mode only. A Proxy Box is configured by a signed bundle, and writing +//! its installed config from here would bypass exactly the check that exists +//! to stop that. +use std::path::Path; + +use anyhow::{bail, Context}; +use serde_json::Value; + +pub struct Enrolment { + pub farm_id: String, + pub gateway_token: String, +} + +pub const USAGE: &str = "usage: continuum-proxy enrol [gateway-name]\n\ + \n\ + Connects this gateway to a farm using the one-time code shown in the console\n\ + (valid for 15 minutes, usable once). is the API's address, e.g.\n\ + https://api.example.com. Settings are written to the proxy's config file."; + +/// The websocket address of the uplink for an API at `cloud`. +pub fn uplink_url(cloud: &str) -> anyhow::Result { + let cloud = cloud.trim().trim_end_matches('/'); + let rest = if let Some(rest) = cloud.strip_prefix("https://") { + format!("wss://{rest}") + } else if let Some(rest) = cloud.strip_prefix("http://") { + format!("ws://{rest}") + } else { + bail!("the cloud address must start with http:// or https:// (got '{cloud}')"); + }; + Ok(format!("{rest}/ws/edge/v1")) +} + +pub fn parse_response(body: &str) -> anyhow::Result { + let value: Value = serde_json::from_str(body).context("the cloud's answer was not JSON")?; + let field = |name: &str| { + value + .get(name) + .and_then(Value::as_str) + .filter(|v| !v.is_empty()) + .map(str::to_string) + .with_context(|| format!("the cloud's answer has no '{name}'")) + }; + Ok(Enrolment { farm_id: field("farmId")?, gateway_token: field("gatewayToken")? }) +} + +/// The config file's new text: the four enrolment settings set, everything +/// else the file already said kept. (Comments are not preserved - the file is +/// parsed and written back.) +pub fn merge_config(existing: Option<&str>, gateway_id: &str, enrolment: &Enrolment, uplink_url: &str) -> anyhow::Result { + let mut table: toml::Table = match existing { + Some(text) => toml::from_str(text).context("the existing config file is not valid TOML; not touching it")?, + None => toml::Table::new(), + }; + table.insert("gateway_id".into(), gateway_id.into()); + table.insert("farm_id".into(), enrolment.farm_id.clone().into()); + table.insert("gateway_token".into(), enrolment.gateway_token.clone().into()); + table.insert("uplink_url".into(), uplink_url.into()); + Ok(toml::to_string(&table)?) +} + +/// Trades `code` for the farm's credentials. +pub async fn exchange(cloud: &str, code: &str) -> anyhow::Result { + let url = format!("{}/auth/enrol", cloud.trim().trim_end_matches('/')); + let body = serde_json::json!({ "code": code }).to_string(); + let response = reqwest::Client::new() + .post(&url) + .header("Content-Type", "application/json") + .timeout(std::time::Duration::from_secs(20)) + .body(body) + .send() + .await + .with_context(|| format!("could not reach {cloud}"))?; + let status = response.status(); + let text = response.text().await.unwrap_or_default(); + if !status.is_success() { + let message = serde_json::from_str::(&text) + .ok() + .and_then(|v| v.pointer("/error/message").and_then(Value::as_str).map(str::to_string)) + .unwrap_or_else(|| format!("HTTP {}", status.as_u16())); + bail!("the cloud refused the code: {message}"); + } + parse_response(&text) +} + +/// Owner-only, and replaced atomically so a crash cannot leave half a file. +fn write_private(path: &Path, text: &str) -> anyhow::Result<()> { + use std::io::Write; + let tmp = path.with_extension("tmp"); + let mut options = std::fs::OpenOptions::new(); + options.write(true).create(true).truncate(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600); + } + let mut file = options.open(&tmp).with_context(|| format!("writing {tmp:?}"))?; + file.write_all(text.as_bytes())?; + file.sync_all()?; + std::fs::rename(&tmp, path).with_context(|| format!("replacing {path:?}"))?; + Ok(()) +} + +fn default_gateway_name() -> String { + std::env::var("HOSTNAME") + .ok() + .or_else(|| std::fs::read_to_string("/etc/hostname").ok()) + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .unwrap_or_else(|| "gateway".to_string()) +} + +/// The `enrol` subcommand. `args` are what follows the word `enrol`. +pub async fn run(args: &[String], bootstrap: &crate::bootstrap::Bootstrap) -> anyhow::Result<()> { + if args.iter().any(|a| a == "--help" || a == "-h") { + println!("{USAGE}"); + return Ok(()); + } + let [cloud, code, rest @ ..] = args else { + bail!("{USAGE}"); + }; + if bootstrap.mode == crate::bootstrap::Mode::ProxyBox { + bail!("this is a Proxy Box: it is configured by a signed config bundle on a pendrive, not by enrol"); + } + let uplink = uplink_url(cloud)?; + let enrolment = exchange(cloud, code).await?; + + let path = &bootstrap.config_path; + let existing = match std::fs::read_to_string(path) { + Ok(text) => Some(text), + Err(err) if err.kind() == std::io::ErrorKind::NotFound => None, + Err(err) => bail!("could not read {path:?}: {err}"), + }; + let name = rest.first().cloned().unwrap_or_else(default_gateway_name); + let text = merge_config(existing.as_deref(), &name, &enrolment, &uplink)?; + write_private(path, &text)?; + println!("Enrolled gateway '{name}' in farm {}. Settings written to {path:?}.", enrolment.farm_id); + println!("Start the proxy to connect it."); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_uplink_follows_the_api_scheme() { + assert_eq!(uplink_url("https://api.example.com/").unwrap(), "wss://api.example.com/ws/edge/v1"); + assert_eq!(uplink_url("http://localhost:3060").unwrap(), "ws://localhost:3060/ws/edge/v1"); + assert!(uplink_url("api.example.com").is_err()); + } + + #[test] + fn the_answer_needs_both_fields() { + let ok = parse_response(r#"{"farmId":"f","gatewayToken":"t"}"#).unwrap(); + assert_eq!((ok.farm_id.as_str(), ok.gateway_token.as_str()), ("f", "t")); + assert!(parse_response(r#"{"farmId":"f"}"#).is_err()); + assert!(parse_response(r#"{"farmId":"f","gatewayToken":""}"#).is_err()); + assert!(parse_response("not json").is_err()); + } + + #[test] + fn enrolling_keeps_the_rest_of_the_file_and_the_result_parses_as_a_config() { + let enrolment = Enrolment { farm_id: "farm-1".into(), gateway_token: "secret".into() }; + let existing = "go2rtc_bin = \"/opt/go2rtc\"\ngateway_token = \"old\"\n[discovery]\nenabled = false\n"; + let text = merge_config(Some(existing), "bench", &enrolment, "wss://x/ws/edge/v1").unwrap(); + let parsed = crate::config::ProxyFile::parse(&text).unwrap(); + assert_eq!(parsed.gateway_token.as_deref(), Some("secret"), "the new token replaces the old"); + assert_eq!(parsed.farm_id.as_deref(), Some("farm-1")); + assert_eq!(parsed.gateway_id.as_deref(), Some("bench")); + assert_eq!(parsed.go2rtc_bin.as_deref(), Some("/opt/go2rtc")); + assert!(parsed.discovery.is_some()); + } + + #[test] + fn a_file_that_is_not_toml_is_left_alone() { + let enrolment = Enrolment { farm_id: "f".into(), gateway_token: "t".into() }; + assert!(merge_config(Some("= nonsense ["), "g", &enrolment, "ws://x").is_err()); + } +} diff --git a/src/lib.rs b/src/lib.rs index 31015dc..e87fd79 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -13,6 +13,7 @@ pub mod camera; pub mod completion; pub mod config; pub mod config_import; +pub mod enrol; pub mod discovery; pub mod fleet; pub mod jobs; diff --git a/src/main.rs b/src/main.rs index 6358af7..786398f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -23,6 +23,13 @@ async fn main() -> anyhow::Result<()> { dotenvy::dotenv().ok(); + // `continuum-proxy enrol ...` connects this gateway to a farm and exits; + // it needs the bootstrap (for where the config file is) but no settings. + let args: Vec = std::env::args().skip(1).collect(); + if args.first().map(String::as_str) == Some("enrol") { + return continuum_proxy::enrol::run(&args[1..], &Bootstrap::discover()?).await; + } + // Order matters: import first, so a stick present at boot is already // the installed config by the time it is read. A rejected stick is // logged inside and does not stop startup - the box runs on what it has.