Add continuum-proxy enrol to connect a gateway with a one-time code
Windows discovery tool / windows (push) Successful in 1m0s
Windows discovery tool / windows (push) Successful in 1m0s
Exchanges a short code from the Setup page for the farm's gateway credentials and writes them into the config file (owner-only, atomic, other keys kept). PC installs only; refused on a Proxy Box.
This commit is contained in:
+188
@@ -0,0 +1,188 @@
|
||||
//! `continuum-proxy enrol <cloud-url> <code> [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 <cloud-url> <code> [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). <cloud-url> 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<String> {
|
||||
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<Enrolment> {
|
||||
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<String> {
|
||||
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<Enrolment> {
|
||||
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::<Value>(&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());
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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<String> = 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.
|
||||
|
||||
Reference in New Issue
Block a user