Add continuum-proxy enrol to connect a gateway with a one-time code
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:
2026-10-30 08:13:00 +00:00
parent f21ad0d497
commit f027fe7947
3 changed files with 196 additions and 0 deletions
+188
View File
@@ -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());
}
}
+1
View File
@@ -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;
+7
View File
@@ -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.