//! 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, config_path: Option, pendrive_dirs: Option>, } #[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, } impl Bootstrap { pub fn discover() -> anyhow::Result { 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) -> anyhow::Result { 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 { 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 { 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) -> anyhow::Result { 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> { 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 { None } fn from_text(text: &str, env: &dyn Fn(&str) -> Option) -> anyhow::Result { 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()); } }