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.
275 lines
12 KiB
Rust
275 lines
12 KiB
Rust
//! 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());
|
|
}
|
|
}
|