Load proxy settings from a file, and import them from a pendrive
Everything came from CONTINUUM_* environment variables, which suits neither shape the product ships in. A Proxy Box is an appliance whose settings arrive on a USB stick; a proxy on someone's PC is a config file they edit. Precedence is stated once, in config.rs: the file is the base, env overrides it, defaults fill the rest. `Config::from_env` keeps its behaviour and the struct is unchanged, so existing callers and tests are untouched. Layering is `Config::layered(file, env_lookup)` with the environment injected as a closure, so tests do not have to mutate process env to cover it. The bootstrap is read from $CONTINUUM_BOOTSTRAP, then /etc/continuum/bootstrap.toml, then nowhere - and nowhere means PC mode with ./continuum.toml, so an env-only deployment behaves exactly as before. The working directory is deliberately not searched, and the bootstrap itself must never come from a stick: it is what says whether sticks are trusted at all. Bundle format is continuum-config.zip holding config.toml and config.toml.sha256. The hash is checked before the bytes are parsed - the TOML parser should not be the first thing to read possibly-corrupt input - and **the comments say plainly what that buys: corruption and change, not forgery.** Anyone who can write the stick writes both files. It is an integrity check under a recorded trust assumption, that physical access to the box is trusted; a signature with the key in the box image is named as the follow-up that would give authenticity. sha256 via openssl, already a direct dependency, so no new crate and `cargo deny check licenses` still passes. The hazards of ingesting a file off removable media, all tested: Zip-slip is refused on absolute paths, `..` components, backslash separators, drive prefixes, NUL and non-UTF-8 names, and one bad entry rejects the whole bundle rather than being skipped. The stronger guarantee is structural though: entries are only ever read **by index, matched against two literal names**, and no write path is ever derived from an archive name, so a hole in the name check still writes nothing. A test confirms a bundle carrying `../evil.toml` alongside a valid config writes nothing outside the target and leaves the installed config alone. Zip bombs are capped at 16 entries, 256 KiB per entry and a 1 MiB file on the stick. Declared sizes are refused in a first pass that decompresses nothing, and a bounded reader then caps actual output so a lying header gains nothing. Installing is atomic: temp file in the destination directory - not /tmp, since rename is only atomic within a filesystem - created 0600 from the first byte because the config holds the gateway token, fsync, rename, then a best-effort directory fsync. A write that fails halfway leaves the previous config intact and no temp file behind. A bad stick never costs the box its config: a bad hash, bad TOML, an unsafe entry or missing settings all return Rejected and are logged as keeping the existing config. A correctly-hashed config is also validated **with no environment at all**, so a stick cannot install something that parses now and fails to start on the next boot - which would be a bricked box caused by exactly the stick we were being careful about. PC mode ignores pendrives twice over, by mode and by an emptied directory list, because on a general-purpose machine importing settings from a USB stick would be a way to change them behind the user's back. A [network] block is parsed, validated and exposed, and stops there. main.rs logs a warning that this build does not apply it, so a box given network settings cannot look like it acted on them. Applying them needs root, is specific to the appliance image, and a wrong write strands the box - it wants its own decision, not a side effect of reading a file. continuum.toml is gitignored, since it holds the gateway token. Not done, and not claimed: no signature, so no authenticity. There is no real pendrive here, so the removable-media path was tested with ordinary directories standing in for mount points - real udisks/udev mounts, FAT behaviour, a genuinely yanked stick and how promptly the 5s poll notices a mount are all unproven. The watcher's install-then-exit flow, which relies on a supervisor restarting the process, has no test and no supervisor exists in this environment; a stick left permanently inserted reports Unchanged rather than looping, because the hash matches. Env still overrides on a box, so a box is only pendrive-only to the extent its environment is trusted - bootstrap.rs says so. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -13,3 +13,4 @@ printers.toml
|
|||||||
# access codes.
|
# access codes.
|
||||||
go2rtc.yaml
|
go2rtc.yaml
|
||||||
go2rtc.yaml.tmp
|
go2rtc.yaml.tmp
|
||||||
|
continuum.toml
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
# Says what kind of installation this is and where the real config lives.
|
||||||
|
# Read from CONTINUUM_BOOTSTRAP, else /etc/continuum/bootstrap.toml, else
|
||||||
|
# absent (which means: PC mode, ./continuum.toml). See src/bootstrap.rs.
|
||||||
|
#
|
||||||
|
# A Proxy Box image ships this root-owned. It must NOT come from a pendrive:
|
||||||
|
# whether a pendrive may change settings is what it decides.
|
||||||
|
|
||||||
|
mode = "proxy_box" # or "pc"
|
||||||
|
# config_path = "/var/lib/continuum/config.toml"
|
||||||
|
# pendrive_dirs = ["/media", "/run/media"] # ignored (and dropped) in pc mode
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
# The proxy's OWN settings - not the printers (that is printers.toml, which
|
||||||
|
# holds per-site secrets and is never shipped on a pendrive). See
|
||||||
|
# src/config.rs for how this file layers with CONTINUUM_* environment
|
||||||
|
# variables: this file is the base, an env var overrides the same key.
|
||||||
|
#
|
||||||
|
# PC / server: copy to continuum.toml next to the proxy and edit it. Nothing
|
||||||
|
# else is needed; removable media is never read.
|
||||||
|
# Proxy Box: put this file in a ZIP named continuum-config.zip together
|
||||||
|
# with its SHA-256, and insert the stick:
|
||||||
|
#
|
||||||
|
# cp continuum.example.toml config.toml # edit it
|
||||||
|
# sha256sum config.toml > config.toml.sha256
|
||||||
|
# zip continuum-config.zip config.toml config.toml.sha256
|
||||||
|
#
|
||||||
|
# The hash catches a damaged or hand-edited stick. It does NOT
|
||||||
|
# prove who made it - anyone who can write the stick can write
|
||||||
|
# both files. See src/config_import.rs.
|
||||||
|
#
|
||||||
|
# Unknown keys are rejected on purpose, so a typo cannot silently fall back to
|
||||||
|
# a default. On a Proxy Box the file must be complete by itself.
|
||||||
|
|
||||||
|
gateway_id = "REPLACE_ME"
|
||||||
|
farm_id = "REPLACE_ME"
|
||||||
|
gateway_token = "REPLACE_ME"
|
||||||
|
|
||||||
|
# uplink_url = "wss://api.continuum.local/ws/edge/v1"
|
||||||
|
# go2rtc_bin = "go2rtc"
|
||||||
|
# go2rtc_config = "./go2rtc.yaml"
|
||||||
|
# go2rtc_api_listen = ":1984"
|
||||||
|
# stream_base_url = "https://proxy.example/"
|
||||||
|
# printers_path = "/var/lib/continuum/printers.toml" # absolute on a box
|
||||||
|
|
||||||
|
# Proxy Box only. PARSED AND VALIDATED, NOT APPLIED - this build never
|
||||||
|
# touches the OS network configuration.
|
||||||
|
# [network]
|
||||||
|
# mode = "static" # or "dhcp"
|
||||||
|
# ip = "192.168.1.60"
|
||||||
|
# prefix_len = 24
|
||||||
|
# gateway = "192.168.1.1"
|
||||||
|
# dns = ["1.1.1.1"]
|
||||||
|
# [network.wifi]
|
||||||
|
# ssid = "farm"
|
||||||
|
# psk = "REPLACE_ME"
|
||||||
@@ -0,0 +1,274 @@
|
|||||||
|
//! The tiny, fixed-location answer to "what kind of installation is this, and
|
||||||
|
//! where is my real config?" - which has to be known *before* the real config
|
||||||
|
//! can be found.
|
||||||
|
//!
|
||||||
|
//! # Why a second file at all
|
||||||
|
//!
|
||||||
|
//! The proxy's settings live in a config file (see `config.rs`), but a Proxy
|
||||||
|
//! Box replaces that file from a pendrive, and a PC user keeps it wherever
|
||||||
|
//! they like. Something has to say which of those two worlds this is, and
|
||||||
|
//! where the file is. That something cannot be in the file it locates, and it
|
||||||
|
//! must not be on the pendrive: whether removable media may change settings
|
||||||
|
//! at all is the very thing being decided, so the media cannot be the one to
|
||||||
|
//! say yes.
|
||||||
|
//!
|
||||||
|
//! # Discovery order
|
||||||
|
//!
|
||||||
|
//! 1. `CONTINUUM_BOOTSTRAP` - an explicit path; if set and unreadable that is
|
||||||
|
//! an error, not a fallthrough, because someone pointed at a file on
|
||||||
|
//! purpose.
|
||||||
|
//! 2. `/etc/continuum/bootstrap.toml` - the fixed location a Proxy Box image
|
||||||
|
//! ships. Root-owned, so a settings change on a box cannot be made by the
|
||||||
|
//! service user or by a pendrive.
|
||||||
|
//! 3. Nothing found: **PC mode with defaults**. This is what every existing
|
||||||
|
//! deployment (env vars only, no files) lands in, so adding the file layer
|
||||||
|
//! changes nothing for them.
|
||||||
|
//!
|
||||||
|
//! A file in the working directory is deliberately *not* searched. It would
|
||||||
|
//! be convenient on a PC, but "whichever directory the service happened to
|
||||||
|
//! start in can flip the box into a different mode" is a bad property for the
|
||||||
|
//! one file that gates removable-media import.
|
||||||
|
//!
|
||||||
|
//! # Overrides
|
||||||
|
//!
|
||||||
|
//! The same precedence as `config.rs`: bootstrap file, then env on top.
|
||||||
|
//! `CONTINUUM_MODE` (`proxy_box` | `pc`), `CONTINUUM_CONFIG_PATH`,
|
||||||
|
//! `CONTINUUM_PENDRIVE_DIRS` (colon-separated).
|
||||||
|
//!
|
||||||
|
//! Note what that means on a box: an environment variable can still override
|
||||||
|
//! a setting. The env of a service is set by its unit file, which is part of
|
||||||
|
//! the root-owned image, so it sits on the same side of the trust line as the
|
||||||
|
//! bootstrap file. It is not a way for a pendrive or the service user to
|
||||||
|
//! change anything.
|
||||||
|
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
use serde::Deserialize;
|
||||||
|
|
||||||
|
use crate::config::ProxyFile;
|
||||||
|
|
||||||
|
/// Where a Proxy Box image puts its bootstrap file.
|
||||||
|
pub const BOOTSTRAP_PATH: &str = "/etc/continuum/bootstrap.toml";
|
||||||
|
|
||||||
|
/// Where a Proxy Box keeps the config it is running on. Under `/var/lib`, not
|
||||||
|
/// `/etc`: the service has to be able to replace this file atomically (which
|
||||||
|
/// needs a writable directory), and it should not need write access to `/etc`
|
||||||
|
/// to do so.
|
||||||
|
const BOX_CONFIG_PATH: &str = "/var/lib/continuum/config.toml";
|
||||||
|
|
||||||
|
/// Relative on purpose: next to wherever the user runs the proxy from, like
|
||||||
|
/// `printers.toml`.
|
||||||
|
const PC_CONFIG_PATH: &str = "continuum.toml";
|
||||||
|
|
||||||
|
/// Where desktop environments and udev rules mount removable media. Only
|
||||||
|
/// consulted on a Proxy Box.
|
||||||
|
const DEFAULT_PENDRIVE_DIRS: [&str; 2] = ["/media", "/run/media"];
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum Mode {
|
||||||
|
/// A dedicated appliance. Settings change only by supplying a new
|
||||||
|
/// pendrive/upload.
|
||||||
|
ProxyBox,
|
||||||
|
/// A program on someone's PC or server. The config is a local file they
|
||||||
|
/// edit; removable media is never read.
|
||||||
|
Pc,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Default, Deserialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
struct BootstrapFile {
|
||||||
|
mode: Option<Mode>,
|
||||||
|
config_path: Option<PathBuf>,
|
||||||
|
pendrive_dirs: Option<Vec<PathBuf>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct Bootstrap {
|
||||||
|
pub mode: Mode,
|
||||||
|
/// The config the proxy runs on - on a box, the installed copy that
|
||||||
|
/// imports replace.
|
||||||
|
pub config_path: PathBuf,
|
||||||
|
/// Whether `config_path` was named by someone, rather than defaulted. An
|
||||||
|
/// explicit path that does not exist is an error; a defaulted one just
|
||||||
|
/// means "no file", which keeps env-only deployments working.
|
||||||
|
pub config_path_is_explicit: bool,
|
||||||
|
/// Directories searched for a config bundle. Always empty in PC mode,
|
||||||
|
/// whatever the bootstrap said - see `from_parts`.
|
||||||
|
pub pendrive_dirs: Vec<PathBuf>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Bootstrap {
|
||||||
|
pub fn discover() -> anyhow::Result<Self> {
|
||||||
|
Self::discover_at(Path::new(BOOTSTRAP_PATH), &|key| std::env::var(key).ok())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `discover` with the fixed path and the environment injected, so tests
|
||||||
|
/// do not depend on the machine's `/etc` or mutate process-wide env.
|
||||||
|
pub fn discover_at(fixed: &Path, env: &dyn Fn(&str) -> Option<String>) -> anyhow::Result<Self> {
|
||||||
|
let file = match env("CONTINUUM_BOOTSTRAP") {
|
||||||
|
Some(explicit) => Self::read(Path::new(&explicit))?,
|
||||||
|
None => match std::fs::read_to_string(fixed) {
|
||||||
|
Ok(text) => Self::parse(&text, fixed)?,
|
||||||
|
// Absent is the normal case on a PC. Anything else (a file
|
||||||
|
// we cannot read) is not "absent" and must not quietly turn
|
||||||
|
// a box into a PC.
|
||||||
|
Err(err) if err.kind() == std::io::ErrorKind::NotFound => BootstrapFile::default(),
|
||||||
|
Err(err) => anyhow::bail!("failed to read bootstrap config {fixed:?}: {err}"),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
Self::from_parts(file, env)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read(path: &Path) -> anyhow::Result<BootstrapFile> {
|
||||||
|
let text = std::fs::read_to_string(path)
|
||||||
|
.map_err(|err| anyhow::anyhow!("failed to read bootstrap config {path:?} (from CONTINUUM_BOOTSTRAP): {err}"))?;
|
||||||
|
Self::parse(&text, path)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse(text: &str, path: &Path) -> anyhow::Result<BootstrapFile> {
|
||||||
|
toml::from_str(text).map_err(|err| anyhow::anyhow!("bootstrap config {path:?} is invalid: {err}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn from_parts(file: BootstrapFile, env: &dyn Fn(&str) -> Option<String>) -> anyhow::Result<Self> {
|
||||||
|
let mode = match env("CONTINUUM_MODE") {
|
||||||
|
Some(value) => match value.as_str() {
|
||||||
|
"proxy_box" => Mode::ProxyBox,
|
||||||
|
"pc" => Mode::Pc,
|
||||||
|
other => anyhow::bail!("CONTINUUM_MODE must be \"proxy_box\" or \"pc\", not {other:?}"),
|
||||||
|
},
|
||||||
|
None => file.mode.unwrap_or(Mode::Pc),
|
||||||
|
};
|
||||||
|
|
||||||
|
let explicit_config = env("CONTINUUM_CONFIG_PATH").map(PathBuf::from).or(file.config_path);
|
||||||
|
let config_path_is_explicit = explicit_config.is_some();
|
||||||
|
let config_path = explicit_config.unwrap_or_else(|| match mode {
|
||||||
|
Mode::ProxyBox => PathBuf::from(BOX_CONFIG_PATH),
|
||||||
|
Mode::Pc => PathBuf::from(PC_CONFIG_PATH),
|
||||||
|
});
|
||||||
|
|
||||||
|
let requested_dirs = env("CONTINUUM_PENDRIVE_DIRS")
|
||||||
|
.map(|v| v.split(':').filter(|d| !d.is_empty()).map(PathBuf::from).collect())
|
||||||
|
.or(file.pendrive_dirs);
|
||||||
|
let pendrive_dirs = match mode {
|
||||||
|
Mode::ProxyBox => requested_dirs
|
||||||
|
.unwrap_or_else(|| DEFAULT_PENDRIVE_DIRS.iter().map(PathBuf::from).collect()),
|
||||||
|
// Dropped here, at the source, and not merely ignored later: the
|
||||||
|
// import code then has nothing to scan even if a future caller
|
||||||
|
// forgets to check the mode. In PC mode pendrive import is a way
|
||||||
|
// to change settings behind the user's back, so the safe state
|
||||||
|
// is the one where the list cannot be non-empty.
|
||||||
|
Mode::Pc => {
|
||||||
|
if requested_dirs.is_some_and(|dirs| !dirs.is_empty()) {
|
||||||
|
tracing::warn!("pendrive_dirs is set but this is PC mode; removable media will NOT be read");
|
||||||
|
}
|
||||||
|
Vec::new()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(Self { mode, config_path, config_path_is_explicit, pendrive_dirs })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reads the real config. `None` means "no file, use env and defaults" -
|
||||||
|
/// only ever for a path nobody named, so env-only deployments keep
|
||||||
|
/// working. A named path that is missing, or any file that does not
|
||||||
|
/// parse, is an error: starting on defaults because the operator's file
|
||||||
|
/// had a typo would run a proxy that looks configured and is not.
|
||||||
|
pub fn load_config_file(&self) -> anyhow::Result<Option<ProxyFile>> {
|
||||||
|
match std::fs::read_to_string(&self.config_path) {
|
||||||
|
Ok(text) => ProxyFile::parse(&text)
|
||||||
|
.map(Some)
|
||||||
|
.map_err(|err| anyhow::anyhow!("config file {:?} is invalid: {err}", self.config_path)),
|
||||||
|
Err(err) if err.kind() == std::io::ErrorKind::NotFound && !self.config_path_is_explicit => Ok(None),
|
||||||
|
Err(err) => Err(anyhow::anyhow!("failed to read config file {:?}: {err}", self.config_path)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn no_env(_: &str) -> Option<String> {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
fn from_text(text: &str, env: &dyn Fn(&str) -> Option<String>) -> anyhow::Result<Bootstrap> {
|
||||||
|
Bootstrap::from_parts(Bootstrap::parse(text, Path::new("test"))?, env)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn with_no_bootstrap_anywhere_the_proxy_is_a_pc_and_reads_no_pendrives() {
|
||||||
|
let missing = Path::new("/definitely/not/here/bootstrap.toml");
|
||||||
|
let b = Bootstrap::discover_at(missing, &no_env).unwrap();
|
||||||
|
assert_eq!(b.mode, Mode::Pc);
|
||||||
|
assert_eq!(b.config_path, PathBuf::from("continuum.toml"));
|
||||||
|
assert!(!b.config_path_is_explicit);
|
||||||
|
assert!(b.pendrive_dirs.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_proxy_box_defaults_to_the_var_lib_config_and_the_standard_mount_points() {
|
||||||
|
let b = from_text("mode = \"proxy_box\"", &no_env).unwrap();
|
||||||
|
assert_eq!(b.config_path, PathBuf::from(BOX_CONFIG_PATH));
|
||||||
|
assert_eq!(b.pendrive_dirs.len(), 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pc_mode_drops_pendrive_dirs_even_when_the_bootstrap_lists_some() {
|
||||||
|
let b = from_text("mode = \"pc\"\npendrive_dirs = [\"/media\"]", &no_env).unwrap();
|
||||||
|
assert!(b.pendrive_dirs.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pc_mode_drops_pendrive_dirs_supplied_through_the_environment_too() {
|
||||||
|
let env = |k: &str| (k == "CONTINUUM_PENDRIVE_DIRS").then(|| "/media:/mnt".to_string());
|
||||||
|
let b = from_text("mode = \"pc\"", &env).unwrap();
|
||||||
|
assert!(b.pendrive_dirs.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_environment_overrides_the_bootstrap_file() {
|
||||||
|
let env = |k: &str| match k {
|
||||||
|
"CONTINUUM_MODE" => Some("pc".to_string()),
|
||||||
|
"CONTINUUM_CONFIG_PATH" => Some("/tmp/mine.toml".to_string()),
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
let b = from_text("mode = \"proxy_box\"", &env).unwrap();
|
||||||
|
assert_eq!(b.mode, Mode::Pc);
|
||||||
|
assert_eq!(b.config_path, PathBuf::from("/tmp/mine.toml"));
|
||||||
|
assert!(b.config_path_is_explicit);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_unknown_mode_in_the_environment_is_an_error_not_a_guess() {
|
||||||
|
let env = |k: &str| (k == "CONTINUUM_MODE").then(|| "appliance".to_string());
|
||||||
|
assert!(from_text("", &env).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_misspelled_bootstrap_key_is_refused() {
|
||||||
|
assert!(from_text("mod = \"proxy_box\"", &no_env).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_explicit_bootstrap_path_that_does_not_exist_is_an_error() {
|
||||||
|
let env = |k: &str| (k == "CONTINUUM_BOOTSTRAP").then(|| "/definitely/not/here.toml".to_string());
|
||||||
|
assert!(Bootstrap::discover_at(Path::new("/also/missing"), &env).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_defaulted_config_path_that_does_not_exist_means_no_file_not_an_error() {
|
||||||
|
let mut b = from_text("", &no_env).unwrap();
|
||||||
|
b.config_path = PathBuf::from("/definitely/not/here/continuum.toml");
|
||||||
|
assert!(b.load_config_file().unwrap().is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_named_config_path_that_does_not_exist_is_an_error() {
|
||||||
|
let mut b = from_text("", &no_env).unwrap();
|
||||||
|
b.config_path = PathBuf::from("/definitely/not/here/continuum.toml");
|
||||||
|
b.config_path_is_explicit = true;
|
||||||
|
assert!(b.load_config_file().is_err());
|
||||||
|
}
|
||||||
|
}
|
||||||
+277
-13
@@ -1,4 +1,48 @@
|
|||||||
|
//! The proxy's own settings: what it is called, where the cloud is, where
|
||||||
|
//! go2rtc lives, and (on a Proxy Box) how it is networked.
|
||||||
|
//!
|
||||||
|
//! # Where settings come from, in one place
|
||||||
|
//!
|
||||||
|
//! **A config file is the base; `CONTINUUM_*` environment variables override
|
||||||
|
//! it; built-in defaults fill what neither says.** That is the whole
|
||||||
|
//! precedence rule and nothing else in the crate re-states it. Env wins
|
||||||
|
//! because that is what every existing deployment, test and `.env` file
|
||||||
|
//! already relies on - `Config::from_env` predates the file, and a file that
|
||||||
|
//! silently beat the env would change the behaviour of setups that never
|
||||||
|
//! asked for a file.
|
||||||
|
//!
|
||||||
|
//! *Which* file is not decided here: that is the bootstrap's job (see
|
||||||
|
//! `bootstrap.rs`), because the proxy has to know whether it is a Proxy Box
|
||||||
|
//! or a program on someone's PC before it knows where to look. This module
|
||||||
|
//! only parses a file it is handed and layers it under the environment.
|
||||||
|
//!
|
||||||
|
//! # Not the fleet file
|
||||||
|
//!
|
||||||
|
//! `printers.toml` (see `fleet.rs`) lists the printers and holds their access
|
||||||
|
//! codes. This file configures the *proxy itself*. They are separate on
|
||||||
|
//! purpose: the proxy config is meant to be shipped to a box on a pendrive by
|
||||||
|
//! whoever provisions it, and the fleet file is full of per-site secrets that
|
||||||
|
//! should never ride along on a stick that gets passed around. The two meet
|
||||||
|
//! at exactly one field, `printers_path`, which says where the fleet file is.
|
||||||
|
//! On a Proxy Box the fleet file is therefore NOT delivered by this
|
||||||
|
//! mechanism - how it gets there is a separate, unsolved question.
|
||||||
|
//!
|
||||||
|
//! # Network settings are parsed, not applied
|
||||||
|
//!
|
||||||
|
//! `[network]` is read, validated and exposed (`ProxyFile::network`), and
|
||||||
|
//! that is where it stops. Nothing in this crate touches `/etc/network`,
|
||||||
|
//! NetworkManager or any other OS state. Applying them means choosing a
|
||||||
|
//! network stack for an image that does not exist yet, running as root, and a
|
||||||
|
//! wrong write can take the box off the network with nobody able to log in
|
||||||
|
//! to fix it - so it belongs in the appliance's provisioning layer, designed
|
||||||
|
//! together with a rollback. The boundary is `NetworkSettings` itself:
|
||||||
|
//! whatever applies it consumes that type and the proxy never will.
|
||||||
|
|
||||||
use std::env;
|
use std::env;
|
||||||
|
use std::fmt;
|
||||||
|
use std::net::IpAddr;
|
||||||
|
|
||||||
|
use serde::Deserialize;
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct Config {
|
pub struct Config {
|
||||||
@@ -33,26 +77,246 @@ pub struct Config {
|
|||||||
pub printers_path: String,
|
pub printers_path: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The file form of the proxy's settings. Every key is optional here: a key
|
||||||
|
/// the file omits falls through to the environment and then the default, so
|
||||||
|
/// a PC user can keep secrets in the environment and a file for the rest.
|
||||||
|
///
|
||||||
|
/// `deny_unknown_fields` is deliberate and the opposite of lenient. On a
|
||||||
|
/// Proxy Box nobody can edit the file afterwards, so a typo'd key
|
||||||
|
/// (`uplink_ulr`) that was silently ignored would ship a box running on the
|
||||||
|
/// default cloud URL with nothing saying so. Refusing at import time turns
|
||||||
|
/// that into a rejected pendrive the operator sees immediately.
|
||||||
|
#[derive(Debug, Clone, Default, Deserialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct ProxyFile {
|
||||||
|
pub gateway_id: Option<String>,
|
||||||
|
pub farm_id: Option<String>,
|
||||||
|
pub gateway_token: Option<String>,
|
||||||
|
pub uplink_url: Option<String>,
|
||||||
|
pub go2rtc_bin: Option<String>,
|
||||||
|
pub go2rtc_config: Option<String>,
|
||||||
|
pub go2rtc_api_listen: Option<String>,
|
||||||
|
pub stream_base_url: Option<String>,
|
||||||
|
pub printers_path: Option<String>,
|
||||||
|
/// Only meaningful on a Proxy Box; see the module docs for why it is
|
||||||
|
/// exposed and not applied.
|
||||||
|
pub network: Option<NetworkSettings>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ProxyFile {
|
||||||
|
pub fn parse(text: &str) -> anyhow::Result<Self> {
|
||||||
|
let file: Self = toml::from_str(text)?;
|
||||||
|
if let Some(network) = &file.network {
|
||||||
|
network.validate()?;
|
||||||
|
}
|
||||||
|
Ok(file)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum NetworkMode {
|
||||||
|
Dhcp,
|
||||||
|
Static,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What a Proxy Box should be configured to do on the network. Parsed and
|
||||||
|
/// validated only - nothing applies it (module docs).
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct NetworkSettings {
|
||||||
|
pub mode: NetworkMode,
|
||||||
|
pub ip: Option<IpAddr>,
|
||||||
|
pub prefix_len: Option<u8>,
|
||||||
|
pub gateway: Option<IpAddr>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub dns: Vec<IpAddr>,
|
||||||
|
pub wifi: Option<WifiSettings>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl NetworkSettings {
|
||||||
|
/// Rejects a static config that could not possibly work. This is the one
|
||||||
|
/// place a bad network block is caught before it is installed: a box
|
||||||
|
/// whose stored config says "static" with no address would, once
|
||||||
|
/// something does apply it, come up unreachable - the failure we least
|
||||||
|
/// want to learn about from a customer.
|
||||||
|
pub fn validate(&self) -> anyhow::Result<()> {
|
||||||
|
if self.mode == NetworkMode::Static {
|
||||||
|
let ip = self.ip.ok_or_else(|| anyhow::anyhow!("network.mode is \"static\" but network.ip is not set"))?;
|
||||||
|
let prefix = self
|
||||||
|
.prefix_len
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("network.mode is \"static\" but network.prefix_len is not set"))?;
|
||||||
|
let max = if ip.is_ipv4() { 32 } else { 128 };
|
||||||
|
anyhow::ensure!(prefix <= max, "network.prefix_len {prefix} is out of range for {ip} (max {max})");
|
||||||
|
}
|
||||||
|
if let Some(wifi) = &self.wifi {
|
||||||
|
anyhow::ensure!(!wifi.ssid.is_empty(), "network.wifi.ssid is empty");
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `psk` is a secret, so `Debug` is written by hand: the derived one would
|
||||||
|
/// put the wifi password in any log line that prints the parsed config.
|
||||||
|
#[derive(Clone, Deserialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct WifiSettings {
|
||||||
|
pub ssid: String,
|
||||||
|
pub psk: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Debug for WifiSettings {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
f.debug_struct("WifiSettings")
|
||||||
|
.field("ssid", &self.ssid)
|
||||||
|
.field("psk", &self.psk.as_ref().map(|_| "<redacted>"))
|
||||||
|
.finish()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl Config {
|
impl Config {
|
||||||
|
/// Environment only - the behaviour from before there was a file, kept
|
||||||
|
/// so existing callers and tests do not change.
|
||||||
pub fn from_env() -> anyhow::Result<Self> {
|
pub fn from_env() -> anyhow::Result<Self> {
|
||||||
|
Self::layered(None, &|key| env::var(key).ok())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// File as the base, environment on top (module docs).
|
||||||
|
pub fn from_file_and_env(file: Option<&ProxyFile>) -> anyhow::Result<Self> {
|
||||||
|
Self::layered(file, &|key| env::var(key).ok())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The layering itself, with the environment passed in as a lookup so
|
||||||
|
/// tests can exercise precedence without mutating the process-wide env,
|
||||||
|
/// which the parallel test runner would turn into a race.
|
||||||
|
pub fn layered(file: Option<&ProxyFile>, env: &dyn Fn(&str) -> Option<String>) -> anyhow::Result<Self> {
|
||||||
|
let empty = ProxyFile::default();
|
||||||
|
let file = file.unwrap_or(&empty);
|
||||||
|
|
||||||
|
let require = |key: &str, field: &Option<String>, name: &str| {
|
||||||
|
env(key).or_else(|| field.clone()).ok_or_else(|| {
|
||||||
|
anyhow::anyhow!("missing required env var {key} (or `{name}` in the proxy config file)")
|
||||||
|
})
|
||||||
|
};
|
||||||
|
let or_default = |key: &str, field: &Option<String>, default: &str| {
|
||||||
|
env(key).or_else(|| field.clone()).unwrap_or_else(|| default.to_string())
|
||||||
|
};
|
||||||
|
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
gateway_id: require("CONTINUUM_GATEWAY_ID")?,
|
gateway_id: require("CONTINUUM_GATEWAY_ID", &file.gateway_id, "gateway_id")?,
|
||||||
farm_id: require("CONTINUUM_FARM_ID")?,
|
farm_id: require("CONTINUUM_FARM_ID", &file.farm_id, "farm_id")?,
|
||||||
gateway_token: require("CONTINUUM_GATEWAY_TOKEN")?,
|
gateway_token: require("CONTINUUM_GATEWAY_TOKEN", &file.gateway_token, "gateway_token")?,
|
||||||
uplink_url: env_or("CONTINUUM_UPLINK_URL", "wss://api.continuum.local/ws/edge/v1"),
|
uplink_url: or_default("CONTINUUM_UPLINK_URL", &file.uplink_url, "wss://api.continuum.local/ws/edge/v1"),
|
||||||
go2rtc_bin: env_or("CONTINUUM_GO2RTC_BIN", "go2rtc"),
|
go2rtc_bin: or_default("CONTINUUM_GO2RTC_BIN", &file.go2rtc_bin, "go2rtc"),
|
||||||
go2rtc_config: env_or("CONTINUUM_GO2RTC_CONFIG", "./go2rtc.yaml"),
|
go2rtc_config: or_default("CONTINUUM_GO2RTC_CONFIG", &file.go2rtc_config, "./go2rtc.yaml"),
|
||||||
go2rtc_api_listen: env_or("CONTINUUM_GO2RTC_API_LISTEN", ":1984"),
|
go2rtc_api_listen: or_default("CONTINUUM_GO2RTC_API_LISTEN", &file.go2rtc_api_listen, ":1984"),
|
||||||
stream_base_url: env::var("CONTINUUM_STREAM_BASE_URL").ok().filter(|v| !v.is_empty()),
|
// An empty value means "unset" at every layer, so an env var
|
||||||
printers_path: env_or("CONTINUUM_PRINTERS_PATH", "printers.toml"),
|
// exported as empty can switch the advertisement off even when
|
||||||
|
// the file sets one.
|
||||||
|
stream_base_url: env("CONTINUUM_STREAM_BASE_URL")
|
||||||
|
.or_else(|| file.stream_base_url.clone())
|
||||||
|
.filter(|v| !v.is_empty()),
|
||||||
|
printers_path: or_default("CONTINUUM_PRINTERS_PATH", &file.printers_path, "printers.toml"),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn require(key: &str) -> anyhow::Result<String> {
|
#[cfg(test)]
|
||||||
env::var(key).map_err(|_| anyhow::anyhow!("missing required env var {key}"))
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn no_env(_: &str) -> Option<String> {
|
||||||
|
None
|
||||||
}
|
}
|
||||||
|
|
||||||
fn env_or(key: &str, default: &str) -> String {
|
fn file(text: &str) -> ProxyFile {
|
||||||
env::var(key).unwrap_or_else(|_| default.to_string())
|
ProxyFile::parse(text).expect("test config parses")
|
||||||
|
}
|
||||||
|
|
||||||
|
const MINIMAL: &str = "gateway_id = \"g\"\nfarm_id = \"f\"\ngateway_token = \"t\"\n";
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn with_no_file_and_no_env_the_required_settings_are_reported_missing() {
|
||||||
|
let err = Config::layered(None, &no_env).unwrap_err().to_string();
|
||||||
|
assert!(err.contains("CONTINUUM_GATEWAY_ID"), "{err}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_environment_alone_still_configures_the_proxy_as_it_did_before_the_file_existed() {
|
||||||
|
let env = |k: &str| match k {
|
||||||
|
"CONTINUUM_GATEWAY_ID" => Some("g".into()),
|
||||||
|
"CONTINUUM_FARM_ID" => Some("f".into()),
|
||||||
|
"CONTINUUM_GATEWAY_TOKEN" => Some("t".into()),
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
let config = Config::layered(None, &env).unwrap();
|
||||||
|
assert_eq!(config.gateway_id, "g");
|
||||||
|
assert_eq!(config.go2rtc_api_listen, ":1984");
|
||||||
|
assert_eq!(config.printers_path, "printers.toml");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_file_alone_is_enough_and_unset_keys_take_the_defaults() {
|
||||||
|
let config = Config::layered(Some(&file(MINIMAL)), &no_env).unwrap();
|
||||||
|
assert_eq!(config.farm_id, "f");
|
||||||
|
assert_eq!(config.go2rtc_bin, "go2rtc");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_environment_variable_overrides_the_same_key_in_the_file() {
|
||||||
|
let text = format!("{MINIMAL}uplink_url = \"wss://from-file\"\n");
|
||||||
|
let env = |k: &str| (k == "CONTINUUM_UPLINK_URL").then(|| "wss://from-env".to_string());
|
||||||
|
let config = Config::layered(Some(&file(&text)), &env).unwrap();
|
||||||
|
assert_eq!(config.uplink_url, "wss://from-env");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_file_key_the_environment_does_not_mention_survives_the_overlay() {
|
||||||
|
let text = format!("{MINIMAL}go2rtc_bin = \"/opt/go2rtc\"\n");
|
||||||
|
let env = |k: &str| (k == "CONTINUUM_UPLINK_URL").then(|| "wss://x".to_string());
|
||||||
|
let config = Config::layered(Some(&file(&text)), &env).unwrap();
|
||||||
|
assert_eq!(config.go2rtc_bin, "/opt/go2rtc");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_empty_stream_base_url_in_the_environment_switches_off_one_set_in_the_file() {
|
||||||
|
let text = format!("{MINIMAL}stream_base_url = \"https://cam.example\"\n");
|
||||||
|
let env = |k: &str| (k == "CONTINUUM_STREAM_BASE_URL").then(String::new);
|
||||||
|
let config = Config::layered(Some(&file(&text)), &env).unwrap();
|
||||||
|
assert_eq!(config.stream_base_url, None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_misspelled_key_is_refused_rather_than_silently_ignored() {
|
||||||
|
let err = ProxyFile::parse(&format!("{MINIMAL}uplink_ulr = \"x\"\n")).unwrap_err().to_string();
|
||||||
|
assert!(err.contains("uplink_ulr"), "{err}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_static_network_block_without_an_address_is_refused() {
|
||||||
|
let err = ProxyFile::parse("[network]\nmode = \"static\"\n").unwrap_err().to_string();
|
||||||
|
assert!(err.contains("network.ip"), "{err}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_static_network_block_with_an_impossible_prefix_is_refused() {
|
||||||
|
let text = "[network]\nmode = \"static\"\nip = \"10.0.0.5\"\nprefix_len = 40\n";
|
||||||
|
assert!(ProxyFile::parse(text).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_complete_network_block_is_parsed_and_exposed_but_not_applied() {
|
||||||
|
let text = "[network]\nmode = \"static\"\nip = \"10.0.0.5\"\nprefix_len = 24\ngateway = \"10.0.0.1\"\ndns = [\"1.1.1.1\"]\n";
|
||||||
|
let network = ProxyFile::parse(text).unwrap().network.unwrap();
|
||||||
|
assert_eq!(network.ip, Some("10.0.0.5".parse().unwrap()));
|
||||||
|
assert_eq!(network.dns.len(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_wifi_password_never_appears_in_debug_output() {
|
||||||
|
let text = "[network]\nmode = \"dhcp\"\n[network.wifi]\nssid = \"farm\"\npsk = \"hunter2hunter2\"\n";
|
||||||
|
let parsed = ProxyFile::parse(text).unwrap();
|
||||||
|
let shown = format!("{parsed:?}");
|
||||||
|
assert!(shown.contains("farm") && !shown.contains("hunter2"), "{shown}");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,831 @@
|
|||||||
|
//! Installing a Proxy Box's config from a pendrive.
|
||||||
|
//!
|
||||||
|
//! A bundle is a ZIP (`continuum-config.zip`) holding `config.toml` and
|
||||||
|
//! `config.toml.sha256`. On boot, and while running when a stick appears,
|
||||||
|
//! a Proxy Box looks for one, checks it, and replaces its installed config
|
||||||
|
//! only if the new one differs from what it already has. This module is
|
||||||
|
//! everything between "there is a file on the stick" and "the installed
|
||||||
|
//! config is now different"; *which* installation this is lives in
|
||||||
|
//! `bootstrap.rs`, and what the config means in `config.rs`.
|
||||||
|
//!
|
||||||
|
//! # What the hash does and does not buy - read this before relying on it
|
||||||
|
//!
|
||||||
|
//! The SHA-256 in the bundle detects **corruption and change**: a stick that
|
||||||
|
//! was half-copied, a file edited by hand without regenerating the hash, a
|
||||||
|
//! bit flipped in flash. It does **not** detect **forgery**. The hash sits in
|
||||||
|
//! the same archive as the file it hashes, so anyone able to write the
|
||||||
|
//! pendrive can write a matching pair. It is a checksum, not authentication,
|
||||||
|
//! and nothing here calls it that.
|
||||||
|
//!
|
||||||
|
//! What would give authenticity is a signature (e.g. Ed25519 over the config
|
||||||
|
//! bytes, public key baked into the box image) - the follow-up, not done
|
||||||
|
//! here. Until then the **trust assumption is: whoever can physically put a
|
||||||
|
//! stick in the box is trusted to configure it.** That is the same trust a
|
||||||
|
//! person with physical access already has, and it is why PC mode refuses
|
||||||
|
//! removable media entirely: there the assumption does not hold.
|
||||||
|
//!
|
||||||
|
//! SHA-256 over MD5/CRC32: not for strength (forgery is out of scope as
|
||||||
|
//! above) but because it is the hash every operator's tooling already has
|
||||||
|
//! (`sha256sum`), it costs nothing here (`openssl` is already a direct
|
||||||
|
//! dependency; no `sha2` crate added), and it will not need changing when a
|
||||||
|
//! signature is layered on.
|
||||||
|
//!
|
||||||
|
//! # Hostile archives
|
||||||
|
//!
|
||||||
|
//! The stick is untrusted input even though its author is trusted: it can be
|
||||||
|
//! mistyped, mis-made by a script, or simply the wrong stick.
|
||||||
|
//!
|
||||||
|
//! - **Zip-slip.** Entry names with a leading `/`, a `..` component, a drive
|
||||||
|
//! prefix or a backslash separator reject the whole bundle. This code never
|
||||||
|
//! writes to a path derived from an entry name - only the two expected
|
||||||
|
//! entries are read, into memory, and the only file written is the
|
||||||
|
//! configured destination - so the check is defence in depth, kept because
|
||||||
|
//! a later change that starts extracting would otherwise be one line from a
|
||||||
|
//! path-traversal bug, and because a bundle with such a name is wrong
|
||||||
|
//! enough to refuse on its own.
|
||||||
|
//! - **Zip bomb.** At most [`MAX_ENTRIES`] entries and [`MAX_ENTRY_BYTES`]
|
||||||
|
//! per entry, checked against the *declared* size before decompressing and
|
||||||
|
//! again against the *actual* output while reading, because a header can
|
||||||
|
//! lie. Only the two expected entries are ever decompressed, so output is
|
||||||
|
//! bounded by twice the per-entry cap whatever the archive claims. The
|
||||||
|
//! file read off the stick is itself capped at [`MAX_BUNDLE_BYTES`]
|
||||||
|
//! so a mis-named multi-gigabyte file cannot exhaust memory first.
|
||||||
|
//! - **Non-regular bundle files** (symlinks, devices) are skipped: a link
|
||||||
|
//! from a stick to a device node would otherwise be read as a bundle.
|
||||||
|
//!
|
||||||
|
//! # A bad stick never bricks the box
|
||||||
|
//!
|
||||||
|
//! Every failure here is an [`Outcome::Rejected`], not an error that
|
||||||
|
//! propagates: the installed config is untouched, the reason is logged at
|
||||||
|
//! `error` level, and the box carries on running with what it has. Install
|
||||||
|
//! is atomic - temp file in the destination directory, `fsync`, `rename` -
|
||||||
|
//! so a stick yanked mid-copy, or power lost mid-write, leaves either the old
|
||||||
|
//! config or the new one and never a truncated mix. The temp file lives in
|
||||||
|
//! the destination's directory (not `/tmp`) because `rename` is only atomic
|
||||||
|
//! within one filesystem.
|
||||||
|
|
||||||
|
use std::collections::HashSet;
|
||||||
|
use std::fs;
|
||||||
|
use std::io::{self, Read, Write};
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use crate::bootstrap::{Bootstrap, Mode};
|
||||||
|
use crate::config::{Config, ProxyFile};
|
||||||
|
|
||||||
|
/// The file an operator puts on the stick.
|
||||||
|
pub const BUNDLE_NAME: &str = "continuum-config.zip";
|
||||||
|
const CONFIG_ENTRY: &str = "config.toml";
|
||||||
|
const HASH_ENTRY: &str = "config.toml.sha256";
|
||||||
|
|
||||||
|
/// A real config is a few hundred bytes; 256 KiB is three orders of
|
||||||
|
/// magnitude of headroom and still small enough to hold in memory twice.
|
||||||
|
pub const MAX_ENTRY_BYTES: u64 = 256 * 1024;
|
||||||
|
/// Two expected entries plus a little for whatever a ZIP tool adds
|
||||||
|
/// (`__MACOSX/` resource forks, a README). A bundle with dozens of entries
|
||||||
|
/// is not a config bundle.
|
||||||
|
pub const MAX_ENTRIES: usize = 16;
|
||||||
|
/// The compressed file. Generous against `MAX_ENTRY_BYTES * MAX_ENTRIES`
|
||||||
|
/// worth of incompressible junk; its job is only to bound the read.
|
||||||
|
pub const MAX_BUNDLE_BYTES: u64 = 1024 * 1024;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub enum Outcome {
|
||||||
|
/// PC mode, or no directories configured: nothing was looked at.
|
||||||
|
Disabled,
|
||||||
|
/// No bundle on any scanned medium.
|
||||||
|
NoBundle,
|
||||||
|
/// A valid bundle whose config is byte-identical to the installed one.
|
||||||
|
Unchanged { bundle: PathBuf },
|
||||||
|
/// The installed config was replaced. `sha256` is the new config's hash.
|
||||||
|
Installed { bundle: PathBuf, sha256: String },
|
||||||
|
/// The bundle was refused; the installed config is untouched.
|
||||||
|
Rejected { bundle: PathBuf, reason: String },
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, thiserror::Error)]
|
||||||
|
enum BundleError {
|
||||||
|
#[error("not a readable ZIP archive: {0}")]
|
||||||
|
NotAnArchive(String),
|
||||||
|
#[error("unsafe entry name {0:?} (absolute path, `..`, drive prefix or backslash)")]
|
||||||
|
UnsafeEntry(String),
|
||||||
|
#[error("{0} entries; at most {MAX_ENTRIES} are allowed")]
|
||||||
|
TooManyEntries(usize),
|
||||||
|
#[error("entry {name:?} is {size} bytes; at most {MAX_ENTRY_BYTES} are allowed")]
|
||||||
|
EntryTooLarge { name: String, size: u64 },
|
||||||
|
#[error("missing entry {0:?}")]
|
||||||
|
MissingEntry(&'static str),
|
||||||
|
#[error("entry {0:?} appears more than once")]
|
||||||
|
DuplicateEntry(&'static str),
|
||||||
|
#[error("entry {0:?} is not a regular file")]
|
||||||
|
NotARegularFile(&'static str),
|
||||||
|
#[error("unreadable entry {name:?}: {detail}")]
|
||||||
|
Unreadable { name: &'static str, detail: String },
|
||||||
|
#[error("{HASH_ENTRY} is malformed: {0}")]
|
||||||
|
MalformedHash(String),
|
||||||
|
#[error("hash mismatch: {HASH_ENTRY} says {claimed}, {CONFIG_ENTRY} is {actual} - the stick is corrupt or the config was edited without regenerating the hash")]
|
||||||
|
HashMismatch { claimed: String, actual: String },
|
||||||
|
#[error("{CONFIG_ENTRY} is not valid: {0}")]
|
||||||
|
InvalidConfig(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A config that passed every check and is safe to install.
|
||||||
|
struct Verified {
|
||||||
|
bytes: Vec<u8>,
|
||||||
|
sha256: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rejects an entry name that could escape a directory it were joined onto.
|
||||||
|
///
|
||||||
|
/// Checked on the raw string rather than via `Path::components`, because the
|
||||||
|
/// platform's idea of a separator is the wrong test for an archive: a ZIP
|
||||||
|
/// made on Windows uses `\`, and on Linux `..\evil` is a legal single file
|
||||||
|
/// name that becomes a traversal the moment anything extracts it on the
|
||||||
|
/// other platform. Treating both as separators is stricter than needed on
|
||||||
|
/// Linux and costs nothing.
|
||||||
|
fn entry_name_is_safe(name: &str) -> bool {
|
||||||
|
if name.is_empty() || name.contains('\0') || name.starts_with('/') || name.starts_with('\\') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// `C:foo` and `C:\foo`.
|
||||||
|
let mut chars = name.chars();
|
||||||
|
if let (Some(first), Some(':')) = (chars.next(), chars.next())
|
||||||
|
&& first.is_ascii_alphabetic()
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
!name.split(['/', '\\']).any(|component| component == "..")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reads at most `limit` bytes, failing if the source has more. Separate from
|
||||||
|
/// the archive so the cap on *actual* output - the one that survives a lying
|
||||||
|
/// header - can be tested with a source that never ends.
|
||||||
|
fn read_bounded(source: impl Read, limit: u64) -> io::Result<Option<Vec<u8>>> {
|
||||||
|
let mut out = Vec::new();
|
||||||
|
source.take(limit + 1).read_to_end(&mut out)?;
|
||||||
|
Ok((out.len() as u64 <= limit).then_some(out))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn sha256_hex(bytes: &[u8]) -> String {
|
||||||
|
openssl::sha::sha256(bytes).iter().map(|b| format!("{b:02x}")).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Accepts `sha256sum`'s own output (`<hex> config.toml`, with or without
|
||||||
|
/// the `*` binary marker) as well as a bare hex digest, because the first is
|
||||||
|
/// what an operator will actually produce and the second is what a script
|
||||||
|
/// will. A filename, if present, must name our config: a hash file copied
|
||||||
|
/// from some other project is a mistake worth catching.
|
||||||
|
fn parse_hash_file(text: &str) -> Result<String, BundleError> {
|
||||||
|
let mut tokens = text.split_whitespace();
|
||||||
|
let digest = tokens.next().ok_or_else(|| BundleError::MalformedHash("empty".into()))?;
|
||||||
|
if digest.len() != 64 || !digest.bytes().all(|b| b.is_ascii_hexdigit()) {
|
||||||
|
return Err(BundleError::MalformedHash(format!("expected 64 hex characters, got {digest:?}")));
|
||||||
|
}
|
||||||
|
if let Some(name) = tokens.next()
|
||||||
|
&& name.trim_start_matches('*') != CONFIG_ENTRY
|
||||||
|
{
|
||||||
|
return Err(BundleError::MalformedHash(format!("it hashes {name:?}, not {CONFIG_ENTRY:?}")));
|
||||||
|
}
|
||||||
|
if tokens.next().is_some() {
|
||||||
|
return Err(BundleError::MalformedHash("unexpected trailing text".into()));
|
||||||
|
}
|
||||||
|
Ok(digest.to_ascii_lowercase())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Everything that decides whether bytes from a stick are acceptable, with no
|
||||||
|
/// filesystem effects - so the whole policy is testable on in-memory ZIPs.
|
||||||
|
fn verify_bundle(archive: &[u8]) -> Result<Verified, BundleError> {
|
||||||
|
let mut zip = zip::ZipArchive::new(io::Cursor::new(archive)).map_err(|e| BundleError::NotAnArchive(e.to_string()))?;
|
||||||
|
|
||||||
|
if zip.len() > MAX_ENTRIES {
|
||||||
|
return Err(BundleError::TooManyEntries(zip.len()));
|
||||||
|
}
|
||||||
|
|
||||||
|
// First pass: names and declared sizes of every entry, expected or not.
|
||||||
|
// Nothing is decompressed yet, so a bomb is refused before it costs
|
||||||
|
// anything.
|
||||||
|
let mut config_at = None;
|
||||||
|
let mut hash_at = None;
|
||||||
|
for index in 0..zip.len() {
|
||||||
|
let entry = zip.by_index_raw(index).map_err(|e| BundleError::NotAnArchive(e.to_string()))?;
|
||||||
|
// A name that is not valid UTF-8 cannot be vetted component by
|
||||||
|
// component, so it is refused like any other unsafe name.
|
||||||
|
let Ok(name) = entry.name().map(|n| n.to_string()) else {
|
||||||
|
return Err(BundleError::UnsafeEntry(String::from_utf8_lossy(entry.name_raw()).into_owned()));
|
||||||
|
};
|
||||||
|
if !entry_name_is_safe(&name) {
|
||||||
|
return Err(BundleError::UnsafeEntry(name));
|
||||||
|
}
|
||||||
|
if entry.size() > MAX_ENTRY_BYTES {
|
||||||
|
return Err(BundleError::EntryTooLarge { size: entry.size(), name });
|
||||||
|
}
|
||||||
|
for (wanted, slot) in [(CONFIG_ENTRY, &mut config_at), (HASH_ENTRY, &mut hash_at)] {
|
||||||
|
if name == wanted && slot.replace(index).is_some() {
|
||||||
|
return Err(BundleError::DuplicateEntry(wanted));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut read = |index: Option<usize>, name: &'static str| -> Result<Vec<u8>, BundleError> {
|
||||||
|
let index = index.ok_or(BundleError::MissingEntry(name))?;
|
||||||
|
let entry = zip.by_index(index).map_err(|e| BundleError::Unreadable { name, detail: e.to_string() })?;
|
||||||
|
if entry.is_dir() || entry.is_symlink() {
|
||||||
|
return Err(BundleError::NotARegularFile(name));
|
||||||
|
}
|
||||||
|
read_bounded(entry, MAX_ENTRY_BYTES)
|
||||||
|
.map_err(|e| BundleError::Unreadable { name, detail: e.to_string() })?
|
||||||
|
.ok_or_else(|| BundleError::EntryTooLarge { name: name.into(), size: MAX_ENTRY_BYTES + 1 })
|
||||||
|
};
|
||||||
|
let config = read(config_at, CONFIG_ENTRY)?;
|
||||||
|
let hash_text = read(hash_at, HASH_ENTRY)?;
|
||||||
|
|
||||||
|
// The hash is checked before the bytes are parsed or trusted for
|
||||||
|
// anything: the TOML parser should not be the first thing to look at
|
||||||
|
// possibly-corrupt input. (Corruption only - see the module docs.)
|
||||||
|
let claimed = parse_hash_file(&String::from_utf8_lossy(&hash_text))?;
|
||||||
|
let actual = sha256_hex(&config);
|
||||||
|
if claimed != actual {
|
||||||
|
return Err(BundleError::HashMismatch { claimed, actual });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validated as the box will actually run it: with no environment. On a
|
||||||
|
// box the file has to be complete by itself, so a config that parses but
|
||||||
|
// lacks `gateway_id` is refused now rather than installed and then
|
||||||
|
// failing to start on the next boot - which would be a bricked box
|
||||||
|
// caused by exactly the stick we were being careful about.
|
||||||
|
let text = std::str::from_utf8(&config).map_err(|e| BundleError::InvalidConfig(e.to_string()))?;
|
||||||
|
let parsed = ProxyFile::parse(text).map_err(|e| BundleError::InvalidConfig(e.to_string()))?;
|
||||||
|
Config::layered(Some(&parsed), &|_| None).map_err(|e| BundleError::InvalidConfig(e.to_string()))?;
|
||||||
|
|
||||||
|
Ok(Verified { bytes: config, sha256: actual })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Replaces `dest` with `bytes` so that no observer, and no crash, ever sees
|
||||||
|
/// anything but the old contents or the new ones.
|
||||||
|
pub fn install_atomically(dest: &Path, bytes: &[u8]) -> io::Result<()> {
|
||||||
|
install_atomically_with(dest, bytes, |file, bytes| file.write_all(bytes))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `install_atomically` with the write step injectable, so a test can make it
|
||||||
|
/// fail halfway the way a pulled stick or a full disk would - the situation
|
||||||
|
/// the atomicity exists for, and not one a happy-path test ever reaches.
|
||||||
|
fn install_atomically_with(
|
||||||
|
dest: &Path,
|
||||||
|
bytes: &[u8],
|
||||||
|
write: impl FnOnce(&mut fs::File, &[u8]) -> io::Result<()>,
|
||||||
|
) -> io::Result<()> {
|
||||||
|
let dir = match dest.parent() {
|
||||||
|
Some(p) if !p.as_os_str().is_empty() => p,
|
||||||
|
_ => Path::new("."),
|
||||||
|
};
|
||||||
|
let name = dest
|
||||||
|
.file_name()
|
||||||
|
.ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "destination has no file name"))?;
|
||||||
|
fs::create_dir_all(dir)?;
|
||||||
|
|
||||||
|
let mut temp_name = std::ffi::OsString::from(".");
|
||||||
|
temp_name.push(name);
|
||||||
|
temp_name.push(format!(".tmp-{}", std::process::id()));
|
||||||
|
let temp = dir.join(temp_name);
|
||||||
|
// A leftover from a previous crash with a recycled pid would make
|
||||||
|
// `create_new` fail forever; it is ours by name, so clear it.
|
||||||
|
let _ = fs::remove_file(&temp);
|
||||||
|
|
||||||
|
let result = (|| {
|
||||||
|
let mut options = fs::OpenOptions::new();
|
||||||
|
options.write(true).create_new(true);
|
||||||
|
// The config holds the gateway token (and maybe a wifi password), so
|
||||||
|
// it is owner-only from the first byte rather than chmod'd after.
|
||||||
|
#[cfg(unix)]
|
||||||
|
std::os::unix::fs::OpenOptionsExt::mode(&mut options, 0o600);
|
||||||
|
let mut file = options.open(&temp)?;
|
||||||
|
write(&mut file, bytes)?;
|
||||||
|
// Data to disk before the rename makes it visible: without this a
|
||||||
|
// power cut can leave a renamed-but-empty file on some filesystems.
|
||||||
|
file.sync_all()?;
|
||||||
|
drop(file);
|
||||||
|
fs::rename(&temp, dest)
|
||||||
|
})();
|
||||||
|
|
||||||
|
if result.is_err() {
|
||||||
|
let _ = fs::remove_file(&temp);
|
||||||
|
} else if let Ok(dir_handle) = fs::File::open(dir) {
|
||||||
|
// Best effort: persists the rename itself. Failure here does not
|
||||||
|
// undo it, so it is not reported as an install failure.
|
||||||
|
let _ = dir_handle.sync_all();
|
||||||
|
}
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Verifies one bundle and installs it if it differs from `installed`.
|
||||||
|
/// Never panics and never returns an error: every outcome, including every
|
||||||
|
/// refusal, is a value the caller reports, and none of them touch the
|
||||||
|
/// installed config except `Installed`.
|
||||||
|
pub fn import_bundle(bundle: &Path, installed: &Path) -> Outcome {
|
||||||
|
let reject = |reason: String| Outcome::Rejected { bundle: bundle.to_path_buf(), reason };
|
||||||
|
|
||||||
|
let archive = match read_bundle_file(bundle) {
|
||||||
|
Ok(bytes) => bytes,
|
||||||
|
Err(err) => return reject(format!("could not read the bundle: {err}")),
|
||||||
|
};
|
||||||
|
let verified = match verify_bundle(&archive) {
|
||||||
|
Ok(v) => v,
|
||||||
|
Err(err) => return reject(err.to_string()),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Compared against the bytes actually on disk, not a remembered hash:
|
||||||
|
// if someone did edit the installed file, the next stick restores it,
|
||||||
|
// which is the "changed only by a new pendrive" rule enforced and not
|
||||||
|
// just asserted. An unreadable or missing installed file counts as
|
||||||
|
// different.
|
||||||
|
if fs::read(installed).is_ok_and(|current| sha256_hex(¤t) == verified.sha256) {
|
||||||
|
return Outcome::Unchanged { bundle: bundle.to_path_buf() };
|
||||||
|
}
|
||||||
|
|
||||||
|
match install_atomically(installed, &verified.bytes) {
|
||||||
|
Ok(()) => Outcome::Installed { bundle: bundle.to_path_buf(), sha256: verified.sha256 },
|
||||||
|
Err(err) => reject(format!("could not install into {installed:?}: {err}")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_bundle_file(path: &Path) -> io::Result<Vec<u8>> {
|
||||||
|
// `symlink_metadata`, not `metadata`: do not follow a link off the stick.
|
||||||
|
let meta = fs::symlink_metadata(path)?;
|
||||||
|
if !meta.is_file() {
|
||||||
|
return Err(io::Error::other("not a regular file"));
|
||||||
|
}
|
||||||
|
read_bounded(fs::File::open(path)?, MAX_BUNDLE_BYTES)?
|
||||||
|
.ok_or_else(|| io::Error::other(format!("larger than {MAX_BUNDLE_BYTES} bytes")))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bundles under `roots`, looking at `root`, `root/*` and `root/*/*` - the
|
||||||
|
/// shapes of `/mnt/stick`, `/media/LABEL` and `/media/<user>/LABEL`, which
|
||||||
|
/// between them are where udisks and a hand `mount` put a stick. Sorted, so
|
||||||
|
/// two sticks are always tried in the same order.
|
||||||
|
fn find_bundles(roots: &[PathBuf]) -> Vec<PathBuf> {
|
||||||
|
fn subdirs(dir: &Path) -> Vec<PathBuf> {
|
||||||
|
let Ok(entries) = fs::read_dir(dir) else { return Vec::new() };
|
||||||
|
// `file_type` does not follow links, so a symlinked directory is not
|
||||||
|
// descended into.
|
||||||
|
entries
|
||||||
|
.flatten()
|
||||||
|
.filter(|e| e.file_type().is_ok_and(|t| t.is_dir()))
|
||||||
|
.map(|e| e.path())
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut found = Vec::new();
|
||||||
|
for root in roots {
|
||||||
|
let mut dirs = vec![root.clone()];
|
||||||
|
let level1 = subdirs(root);
|
||||||
|
dirs.extend(level1.iter().flat_map(|d| subdirs(d)));
|
||||||
|
dirs.extend(level1);
|
||||||
|
for dir in dirs {
|
||||||
|
let candidate = dir.join(BUNDLE_NAME);
|
||||||
|
if fs::symlink_metadata(&candidate).is_ok() {
|
||||||
|
found.push(candidate);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
found.sort();
|
||||||
|
found.dedup();
|
||||||
|
found
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One pass over every configured medium. Returns what happened to each
|
||||||
|
/// bundle; stops after the first install, since a second stick would
|
||||||
|
/// otherwise immediately overwrite the first with whichever sorts last.
|
||||||
|
///
|
||||||
|
/// This is the gate for PC mode: anything but a Proxy Box returns
|
||||||
|
/// `Disabled` without opening a directory.
|
||||||
|
pub fn import_from_pendrives(bootstrap: &Bootstrap) -> Vec<Outcome> {
|
||||||
|
if bootstrap.mode != Mode::ProxyBox || bootstrap.pendrive_dirs.is_empty() {
|
||||||
|
return vec![Outcome::Disabled];
|
||||||
|
}
|
||||||
|
let bundles = find_bundles(&bootstrap.pendrive_dirs);
|
||||||
|
if bundles.is_empty() {
|
||||||
|
return vec![Outcome::NoBundle];
|
||||||
|
}
|
||||||
|
let mut outcomes = Vec::new();
|
||||||
|
for bundle in bundles {
|
||||||
|
let outcome = import_bundle(&bundle, &bootstrap.config_path);
|
||||||
|
let installed = matches!(outcome, Outcome::Installed { .. });
|
||||||
|
outcomes.push(outcome);
|
||||||
|
if installed {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
outcomes
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn log_outcome(outcome: &Outcome) {
|
||||||
|
match outcome {
|
||||||
|
Outcome::Disabled | Outcome::NoBundle => {}
|
||||||
|
Outcome::Unchanged { bundle } => {
|
||||||
|
tracing::info!(bundle = %bundle.display(), "config bundle matches the installed config; nothing to do");
|
||||||
|
}
|
||||||
|
Outcome::Installed { bundle, sha256 } => {
|
||||||
|
tracing::warn!(bundle = %bundle.display(), %sha256, "installed a new config from the pendrive; a restart is needed to use it");
|
||||||
|
}
|
||||||
|
// `error`, not `warn`: someone inserted a stick expecting something
|
||||||
|
// to happen, and "nothing happened" must not be silent.
|
||||||
|
Outcome::Rejected { bundle, reason } => {
|
||||||
|
tracing::error!(
|
||||||
|
bundle = %bundle.display(),
|
||||||
|
%reason,
|
||||||
|
"REJECTED the config bundle; the box keeps running on its existing config"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Polls for a stick while the proxy runs. Returns only after installing a
|
||||||
|
/// new config, because settings are read once at startup: the caller is
|
||||||
|
/// expected to let the process exit so its supervisor (systemd
|
||||||
|
/// `Restart=always` on a box) starts it on the new config. Never returns in
|
||||||
|
/// PC mode.
|
||||||
|
///
|
||||||
|
/// Polling, not inotify/udev: a mount appearing under `/media` is not a
|
||||||
|
/// reliable inotify event across desktop and headless setups, and a 5s scan
|
||||||
|
/// of a handful of directories is cheap. A rejected stick is reported once
|
||||||
|
/// per distinct reason, not every five seconds for as long as it stays in.
|
||||||
|
pub async fn watch(bootstrap: Bootstrap) {
|
||||||
|
if bootstrap.mode != Mode::ProxyBox {
|
||||||
|
std::future::pending::<()>().await;
|
||||||
|
}
|
||||||
|
let mut reported: HashSet<(PathBuf, String)> = HashSet::new();
|
||||||
|
loop {
|
||||||
|
tokio::time::sleep(Duration::from_secs(5)).await;
|
||||||
|
let scan = bootstrap.clone();
|
||||||
|
let Ok(outcomes) = tokio::task::spawn_blocking(move || import_from_pendrives(&scan)).await else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
for outcome in &outcomes {
|
||||||
|
if let Outcome::Rejected { bundle, reason } = outcome
|
||||||
|
&& !reported.insert((bundle.clone(), reason.clone()))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
log_outcome(outcome);
|
||||||
|
}
|
||||||
|
if outcomes.iter().any(|o| matches!(o, Outcome::Installed { .. })) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
const GOOD: &str = "gateway_id = \"g\"\nfarm_id = \"f\"\ngateway_token = \"t\"\n";
|
||||||
|
|
||||||
|
/// A self-deleting scratch directory, as in tests/discovery_to_fleet.rs -
|
||||||
|
/// a dependency for a dozen lines would be the wrong trade here.
|
||||||
|
struct Scratch(PathBuf);
|
||||||
|
|
||||||
|
impl Scratch {
|
||||||
|
fn new() -> Self {
|
||||||
|
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||||
|
static NEXT: AtomicUsize = AtomicUsize::new(0);
|
||||||
|
let path = std::env::temp_dir()
|
||||||
|
.join(format!("continuum-import-{}-{}", std::process::id(), NEXT.fetch_add(1, Ordering::Relaxed)));
|
||||||
|
fs::create_dir_all(&path).unwrap();
|
||||||
|
Self(path)
|
||||||
|
}
|
||||||
|
fn path(&self, rel: &str) -> PathBuf {
|
||||||
|
self.0.join(rel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for Scratch {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
let _ = fs::remove_dir_all(&self.0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn zip_of(entries: &[(&str, &[u8])], method: zip::CompressionMethod) -> Vec<u8> {
|
||||||
|
let mut w = zip::ZipWriter::new(io::Cursor::new(Vec::new()));
|
||||||
|
let opts = zip::write::SimpleFileOptions::default().compression_method(method);
|
||||||
|
for (name, body) in entries {
|
||||||
|
w.start_file(*name, opts).unwrap();
|
||||||
|
w.write_all(body).unwrap();
|
||||||
|
}
|
||||||
|
w.finish().unwrap().into_inner()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn stored(entries: &[(&str, &[u8])]) -> Vec<u8> {
|
||||||
|
zip_of(entries, zip::CompressionMethod::Stored)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn good_bundle(config: &str) -> Vec<u8> {
|
||||||
|
let hash = format!("{} config.toml\n", sha256_hex(config.as_bytes()));
|
||||||
|
stored(&[(CONFIG_ENTRY, config.as_bytes()), (HASH_ENTRY, hash.as_bytes())])
|
||||||
|
}
|
||||||
|
|
||||||
|
fn rejection(archive: &[u8]) -> String {
|
||||||
|
verify_bundle(archive).err().expect("bundle should be rejected").to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_bundle_whose_hash_matches_is_accepted() {
|
||||||
|
let verified = verify_bundle(&good_bundle(GOOD)).unwrap();
|
||||||
|
assert_eq!(verified.bytes, GOOD.as_bytes());
|
||||||
|
assert_eq!(verified.sha256, sha256_hex(GOOD.as_bytes()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_bare_hex_digest_with_no_filename_is_also_accepted() {
|
||||||
|
let hash = sha256_hex(GOOD.as_bytes());
|
||||||
|
let archive = stored(&[(CONFIG_ENTRY, GOOD.as_bytes()), (HASH_ENTRY, hash.as_bytes())]);
|
||||||
|
assert!(verify_bundle(&archive).is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_config_edited_after_its_hash_was_made_is_rejected_as_a_mismatch() {
|
||||||
|
let hash = format!("{} config.toml", sha256_hex(GOOD.as_bytes()));
|
||||||
|
let edited = GOOD.replace("\"g\"", "\"other\"");
|
||||||
|
let archive = stored(&[(CONFIG_ENTRY, edited.as_bytes()), (HASH_ENTRY, hash.as_bytes())]);
|
||||||
|
assert!(rejection(&archive).contains("hash mismatch"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_hash_file_that_names_some_other_file_is_rejected() {
|
||||||
|
let hash = format!("{} other.toml", sha256_hex(GOOD.as_bytes()));
|
||||||
|
let archive = stored(&[(CONFIG_ENTRY, GOOD.as_bytes()), (HASH_ENTRY, hash.as_bytes())]);
|
||||||
|
assert!(rejection(&archive).contains("other.toml"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_hash_that_is_not_sixty_four_hex_characters_is_rejected() {
|
||||||
|
let archive = stored(&[(CONFIG_ENTRY, GOOD.as_bytes()), (HASH_ENTRY, b"deadbeef")]);
|
||||||
|
assert!(rejection(&archive).contains("malformed"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_bundle_missing_its_hash_is_rejected_rather_than_trusted() {
|
||||||
|
let archive = stored(&[(CONFIG_ENTRY, GOOD.as_bytes())]);
|
||||||
|
assert!(rejection(&archive).contains("missing entry"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_correctly_hashed_config_that_is_not_valid_toml_is_rejected() {
|
||||||
|
assert!(rejection(&good_bundle("this is = = not toml")).contains("not valid"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_correctly_hashed_config_missing_required_settings_is_rejected_so_it_cannot_brick_the_next_boot() {
|
||||||
|
assert!(rejection(&good_bundle("uplink_url = \"wss://x\"\n")).contains("gateway_id"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_correctly_hashed_config_with_a_misspelled_key_is_rejected() {
|
||||||
|
let config = format!("{GOOD}uplink_ulr = \"x\"\n");
|
||||||
|
assert!(rejection(&good_bundle(&config)).contains("uplink_ulr"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_file_that_is_not_a_zip_is_rejected() {
|
||||||
|
assert!(rejection(b"definitely not a zip").contains("not a readable ZIP"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn entry_names_that_could_escape_the_target_directory_are_all_unsafe() {
|
||||||
|
for name in ["/etc/passwd", "../evil", "a/../../evil", "a/..", "..\\evil", "\\abs", "C:\\evil", "C:evil", ""] {
|
||||||
|
assert!(!entry_name_is_safe(name), "{name:?} should be unsafe");
|
||||||
|
}
|
||||||
|
for name in ["config.toml", "dir/config.toml", "..config", "a..b/c", "__MACOSX/._x"] {
|
||||||
|
assert!(entry_name_is_safe(name), "{name:?} should be safe");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_zip_slip_entry_rejects_the_whole_bundle_even_when_the_rest_is_valid() {
|
||||||
|
let hash = format!("{} config.toml", sha256_hex(GOOD.as_bytes()));
|
||||||
|
let archive = stored(&[
|
||||||
|
(CONFIG_ENTRY, GOOD.as_bytes()),
|
||||||
|
(HASH_ENTRY, hash.as_bytes()),
|
||||||
|
("../../../../tmp/evil.toml", b"pwned"),
|
||||||
|
]);
|
||||||
|
assert!(rejection(&archive).contains("unsafe entry"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_absolute_path_entry_rejects_the_bundle() {
|
||||||
|
let archive = stored(&[("/etc/cron.d/evil", b"x")]);
|
||||||
|
assert!(rejection(&archive).contains("unsafe entry"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn importing_a_zip_slip_bundle_writes_nothing_outside_the_target_and_keeps_the_old_config() {
|
||||||
|
let scratch = Scratch::new();
|
||||||
|
let installed = scratch.path("box/config.toml");
|
||||||
|
fs::create_dir_all(installed.parent().unwrap()).unwrap();
|
||||||
|
fs::write(&installed, GOOD).unwrap();
|
||||||
|
|
||||||
|
let hash = format!("{} config.toml", sha256_hex(b"new"));
|
||||||
|
let bundle = scratch.path("stick.zip");
|
||||||
|
fs::write(&bundle, stored(&[(CONFIG_ENTRY, b"new"), (HASH_ENTRY, hash.as_bytes()), ("../evil.toml", b"pwned")])).unwrap();
|
||||||
|
|
||||||
|
assert!(matches!(import_bundle(&bundle, &installed), Outcome::Rejected { .. }));
|
||||||
|
assert!(!scratch.path("evil.toml").exists(), "a file escaped the target directory");
|
||||||
|
assert_eq!(fs::read_to_string(&installed).unwrap(), GOOD);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_bundle_with_too_many_entries_is_rejected() {
|
||||||
|
let names: Vec<String> = (0..=MAX_ENTRIES).map(|i| format!("junk{i}")).collect();
|
||||||
|
let entries: Vec<(&str, &[u8])> = names.iter().map(|n| (n.as_str(), &b"x"[..])).collect();
|
||||||
|
assert!(rejection(&stored(&entries)).contains("entries"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_highly_compressed_entry_that_would_inflate_past_the_cap_is_rejected_before_it_is_inflated() {
|
||||||
|
let bomb = vec![0u8; (MAX_ENTRY_BYTES as usize) * 40];
|
||||||
|
let archive = zip_of(&[(CONFIG_ENTRY, &bomb), (HASH_ENTRY, b"x")], zip::CompressionMethod::Deflated);
|
||||||
|
assert!(archive.len() < 64 * 1024, "the fixture should be small on disk: {}", archive.len());
|
||||||
|
assert!(rejection(&archive).contains("at most"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_bounded_reader_stops_an_endless_source_instead_of_filling_memory() {
|
||||||
|
// The cap that survives a header which lies about the size.
|
||||||
|
assert_eq!(read_bounded(io::repeat(0), 1000).unwrap(), None);
|
||||||
|
assert_eq!(read_bounded(&b"abc"[..], 1000).unwrap(), Some(b"abc".to_vec()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_oversized_file_on_the_stick_is_refused_without_being_read_whole() {
|
||||||
|
let scratch = Scratch::new();
|
||||||
|
let bundle = scratch.path("big.zip");
|
||||||
|
fs::write(&bundle, vec![0u8; MAX_BUNDLE_BYTES as usize + 1]).unwrap();
|
||||||
|
assert!(matches!(import_bundle(&bundle, &scratch.path("config.toml")), Outcome::Rejected { .. }));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_new_config_is_installed_when_none_exists_yet() {
|
||||||
|
let scratch = Scratch::new();
|
||||||
|
let bundle = scratch.path("stick.zip");
|
||||||
|
fs::write(&bundle, good_bundle(GOOD)).unwrap();
|
||||||
|
let installed = scratch.path("var/lib/continuum/config.toml");
|
||||||
|
|
||||||
|
assert!(matches!(import_bundle(&bundle, &installed), Outcome::Installed { .. }));
|
||||||
|
assert_eq!(fs::read_to_string(&installed).unwrap(), GOOD);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
#[test]
|
||||||
|
fn the_installed_config_is_readable_only_by_its_owner() {
|
||||||
|
use std::os::unix::fs::PermissionsExt;
|
||||||
|
let scratch = Scratch::new();
|
||||||
|
let installed = scratch.path("config.toml");
|
||||||
|
install_atomically(&installed, b"secret").unwrap();
|
||||||
|
assert_eq!(fs::metadata(&installed).unwrap().permissions().mode() & 0o777, 0o600);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_bundle_identical_to_the_installed_config_changes_nothing() {
|
||||||
|
let scratch = Scratch::new();
|
||||||
|
let bundle = scratch.path("stick.zip");
|
||||||
|
fs::write(&bundle, good_bundle(GOOD)).unwrap();
|
||||||
|
let installed = scratch.path("config.toml");
|
||||||
|
fs::write(&installed, GOOD).unwrap();
|
||||||
|
let before = fs::metadata(&installed).unwrap().modified().unwrap();
|
||||||
|
|
||||||
|
assert!(matches!(import_bundle(&bundle, &installed), Outcome::Unchanged { .. }));
|
||||||
|
assert_eq!(fs::metadata(&installed).unwrap().modified().unwrap(), before, "an unchanged config was rewritten");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_config_hand_edited_on_the_box_is_restored_by_the_next_stick() {
|
||||||
|
let scratch = Scratch::new();
|
||||||
|
let bundle = scratch.path("stick.zip");
|
||||||
|
fs::write(&bundle, good_bundle(GOOD)).unwrap();
|
||||||
|
let installed = scratch.path("config.toml");
|
||||||
|
fs::write(&installed, format!("{GOOD}uplink_url = \"wss://tampered\"\n")).unwrap();
|
||||||
|
|
||||||
|
assert!(matches!(import_bundle(&bundle, &installed), Outcome::Installed { .. }));
|
||||||
|
assert_eq!(fs::read_to_string(&installed).unwrap(), GOOD);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_pendrive_yanked_mid_copy_leaves_the_old_config_intact() {
|
||||||
|
let scratch = Scratch::new();
|
||||||
|
let installed = scratch.path("config.toml");
|
||||||
|
fs::write(&installed, "old config").unwrap();
|
||||||
|
|
||||||
|
let result = install_atomically_with(&installed, b"the whole new config", |file, bytes| {
|
||||||
|
file.write_all(&bytes[..5])?;
|
||||||
|
Err(io::Error::other("stick removed"))
|
||||||
|
});
|
||||||
|
|
||||||
|
assert!(result.is_err());
|
||||||
|
assert_eq!(fs::read_to_string(&installed).unwrap(), "old config");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_failed_install_leaves_no_temp_file_behind() {
|
||||||
|
let scratch = Scratch::new();
|
||||||
|
let installed = scratch.path("config.toml");
|
||||||
|
let _ = install_atomically_with(&installed, b"new", |_, _| Err(io::Error::other("disk full")));
|
||||||
|
|
||||||
|
let leftovers: Vec<_> = fs::read_dir(&scratch.0).unwrap().flatten().collect();
|
||||||
|
assert!(leftovers.is_empty(), "left behind: {leftovers:?}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_rejected_bundle_never_touches_the_installed_config() {
|
||||||
|
let scratch = Scratch::new();
|
||||||
|
let installed = scratch.path("config.toml");
|
||||||
|
fs::write(&installed, GOOD).unwrap();
|
||||||
|
let bundle = scratch.path("stick.zip");
|
||||||
|
fs::write(&bundle, good_bundle("not = = toml")).unwrap();
|
||||||
|
|
||||||
|
assert!(matches!(import_bundle(&bundle, &installed), Outcome::Rejected { .. }));
|
||||||
|
assert_eq!(fs::read_to_string(&installed).unwrap(), GOOD);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn box_with_stick(scratch: &Scratch, mode: Mode) -> Bootstrap {
|
||||||
|
let stick = scratch.path("media/USB");
|
||||||
|
fs::create_dir_all(&stick).unwrap();
|
||||||
|
fs::write(stick.join(BUNDLE_NAME), good_bundle(GOOD)).unwrap();
|
||||||
|
Bootstrap {
|
||||||
|
mode,
|
||||||
|
config_path: scratch.path("installed.toml"),
|
||||||
|
config_path_is_explicit: true,
|
||||||
|
pendrive_dirs: vec![scratch.path("media")],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_proxy_box_finds_a_stick_mounted_one_level_down_and_installs_from_it() {
|
||||||
|
let scratch = Scratch::new();
|
||||||
|
let bootstrap = box_with_stick(&scratch, Mode::ProxyBox);
|
||||||
|
let outcomes = import_from_pendrives(&bootstrap);
|
||||||
|
assert!(matches!(outcomes.as_slice(), [Outcome::Installed { .. }]), "{outcomes:?}");
|
||||||
|
assert_eq!(fs::read_to_string(scratch.path("installed.toml")).unwrap(), GOOD);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_proxy_box_finds_a_stick_at_the_media_user_label_depth() {
|
||||||
|
let scratch = Scratch::new();
|
||||||
|
let deep = scratch.path("media/pi/LABEL");
|
||||||
|
fs::create_dir_all(&deep).unwrap();
|
||||||
|
fs::write(deep.join(BUNDLE_NAME), good_bundle(GOOD)).unwrap();
|
||||||
|
let bootstrap = Bootstrap {
|
||||||
|
mode: Mode::ProxyBox,
|
||||||
|
config_path: scratch.path("installed.toml"),
|
||||||
|
config_path_is_explicit: true,
|
||||||
|
pendrive_dirs: vec![scratch.path("media")],
|
||||||
|
};
|
||||||
|
assert!(matches!(import_from_pendrives(&bootstrap).as_slice(), [Outcome::Installed { .. }]));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pc_mode_ignores_a_pendrive_even_when_one_with_a_valid_bundle_is_mounted() {
|
||||||
|
let scratch = Scratch::new();
|
||||||
|
let mut bootstrap = box_with_stick(&scratch, Mode::Pc);
|
||||||
|
// Force the dirs back in, as if some future code path forgot that
|
||||||
|
// `Bootstrap` already empties them: the mode check must hold alone.
|
||||||
|
bootstrap.pendrive_dirs = vec![scratch.path("media")];
|
||||||
|
|
||||||
|
assert_eq!(import_from_pendrives(&bootstrap), vec![Outcome::Disabled]);
|
||||||
|
assert!(!scratch.path("installed.toml").exists(), "PC mode imported from removable media");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_empty_pendrive_directory_is_not_an_error() {
|
||||||
|
let scratch = Scratch::new();
|
||||||
|
fs::create_dir_all(scratch.path("media")).unwrap();
|
||||||
|
let bootstrap = Bootstrap {
|
||||||
|
mode: Mode::ProxyBox,
|
||||||
|
config_path: scratch.path("installed.toml"),
|
||||||
|
config_path_is_explicit: true,
|
||||||
|
pendrive_dirs: vec![scratch.path("media")],
|
||||||
|
};
|
||||||
|
assert_eq!(import_from_pendrives(&bootstrap), vec![Outcome::NoBundle]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
#[test]
|
||||||
|
fn a_bundle_that_is_a_symlink_is_not_followed() {
|
||||||
|
let scratch = Scratch::new();
|
||||||
|
let real = scratch.path("real.zip");
|
||||||
|
fs::write(&real, good_bundle(GOOD)).unwrap();
|
||||||
|
let link = scratch.path("link.zip");
|
||||||
|
std::os::unix::fs::symlink(&real, &link).unwrap();
|
||||||
|
assert!(matches!(import_bundle(&link, &scratch.path("c.toml")), Outcome::Rejected { .. }));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_bad_stick_is_reported_and_the_box_keeps_the_config_it_had() {
|
||||||
|
let scratch = Scratch::new();
|
||||||
|
let bootstrap = box_with_stick(&scratch, Mode::ProxyBox);
|
||||||
|
fs::write(&bootstrap.config_path, "previous").unwrap();
|
||||||
|
fs::write(scratch.path("media/USB").join(BUNDLE_NAME), b"garbage").unwrap();
|
||||||
|
|
||||||
|
let outcomes = import_from_pendrives(&bootstrap);
|
||||||
|
assert!(matches!(outcomes.as_slice(), [Outcome::Rejected { .. }]));
|
||||||
|
assert_eq!(fs::read_to_string(&bootstrap.config_path).unwrap(), "previous");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,9 +8,11 @@
|
|||||||
//! should serve is not - that is `camera`, which reads the fleet and is worth
|
//! should serve is not - that is `camera`, which reads the fleet and is worth
|
||||||
//! testing, so it lives here.
|
//! testing, so it lives here.
|
||||||
|
|
||||||
|
pub mod bootstrap;
|
||||||
pub mod camera;
|
pub mod camera;
|
||||||
pub mod completion;
|
pub mod completion;
|
||||||
pub mod config;
|
pub mod config;
|
||||||
|
pub mod config_import;
|
||||||
pub mod discovery;
|
pub mod discovery;
|
||||||
pub mod fleet;
|
pub mod fleet;
|
||||||
pub mod printer;
|
pub mod printer;
|
||||||
|
|||||||
+30
-1
@@ -9,7 +9,9 @@ mod go2rtc;
|
|||||||
use tracing::info;
|
use tracing::info;
|
||||||
use tracing_subscriber::EnvFilter;
|
use tracing_subscriber::EnvFilter;
|
||||||
|
|
||||||
|
use continuum_proxy::bootstrap::Bootstrap;
|
||||||
use continuum_proxy::config::Config;
|
use continuum_proxy::config::Config;
|
||||||
|
use continuum_proxy::config_import;
|
||||||
use continuum_proxy::printer::{GenericPrinter, PrinterHandle};
|
use continuum_proxy::printer::{GenericPrinter, PrinterHandle};
|
||||||
use continuum_proxy::{camera, completion, fleet, uplink};
|
use continuum_proxy::{camera, completion, fleet, uplink};
|
||||||
|
|
||||||
@@ -20,7 +22,25 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
.init();
|
.init();
|
||||||
|
|
||||||
dotenvy::dotenv().ok();
|
dotenvy::dotenv().ok();
|
||||||
let config = Config::from_env()?;
|
|
||||||
|
// 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.
|
||||||
|
let bootstrap = Bootstrap::discover()?;
|
||||||
|
for outcome in config_import::import_from_pendrives(&bootstrap) {
|
||||||
|
config_import::log_outcome(&outcome);
|
||||||
|
}
|
||||||
|
let file = bootstrap.load_config_file()?;
|
||||||
|
let config = Config::from_file_and_env(file.as_ref())?;
|
||||||
|
info!(mode = ?bootstrap.mode, config_file = ?file.as_ref().map(|_| &bootstrap.config_path), "configuration loaded");
|
||||||
|
|
||||||
|
// BOUNDARY: network settings are parsed and validated, and stop here.
|
||||||
|
// Applying them (static IP, DNS, wifi) is the appliance image's job, not
|
||||||
|
// this process's - see the module docs of `config`. Logged so that a box
|
||||||
|
// given a network block does not look like it acted on it.
|
||||||
|
if let Some(network) = file.as_ref().and_then(|f| f.network.as_ref()) {
|
||||||
|
tracing::warn!(?network, "network settings are present but this build does NOT apply them");
|
||||||
|
}
|
||||||
|
|
||||||
tracing::info!(gateway_id = %config.gateway_id, farm_id = %config.farm_id, "starting continuum-proxy");
|
tracing::info!(gateway_id = %config.gateway_id, farm_id = %config.farm_id, "starting continuum-proxy");
|
||||||
|
|
||||||
@@ -86,6 +106,15 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
restart_go2rtc.clone(),
|
restart_go2rtc.clone(),
|
||||||
) => {}
|
) => {}
|
||||||
_ = completion::run(fleet.clone()) => {}
|
_ = completion::run(fleet.clone()) => {}
|
||||||
|
// Returns only after installing a new config from a stick inserted
|
||||||
|
// while running. Settings are read once at startup, so the process
|
||||||
|
// ends here and relies on its supervisor (systemd `Restart=always`
|
||||||
|
// on a box) to start it again on the new config. Not exercised end
|
||||||
|
// to end - there is no supervisor or real stick in the dev
|
||||||
|
// environment.
|
||||||
|
_ = config_import::watch(bootstrap.clone()) => {
|
||||||
|
info!("exiting so the supervisor restarts the proxy on the newly installed config");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|||||||
Reference in New Issue
Block a user