hennery 0.3.6

Self-hosted cockpit for ACP coding agents
//! `<data>/config.toml` (kernel spec §1, §2): the collector's settings that
//! are not the operator's to edit in the UI. Precedence, per setting:
//! command-line flags, then the environment (`HENNERY_*`), then this file,
//! then the defaults. Flags and the environment are clap's: it takes a
//! flag over its variable. This file fills in what neither gave.
//!
//! ```toml
//! listen = ["127.0.0.1:7117", "100.64.0.7:7117"]
//! public_url = "https://hennery.example"
//! ```
//!
//! An unknown key is refused, so a typo fails the start instead of being
//! ignored. No secret belongs here, but the file says where the collector
//! listens and which origin its setup link names: one that other users can
//! write is refused.

use anyhow::{Context, Result, bail};
use serde::Deserialize;
use std::io::Read;
use std::os::unix::fs::PermissionsExt;
use std::path::Path;

/// The file's name in the collector's data directory.
pub const CONFIG_FILE: &str = "config.toml";

#[derive(Debug, Default, PartialEq, Eq, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct FileConfig {
    /// The addresses to listen on (kernel spec §7).
    pub listen: Option<Vec<String>>,
    /// Where browsers reach the collector, until setup stores its own
    /// (kernel spec §3.1): the setup link names it.
    pub public_url: Option<String>,
    /// Exact peers allowed to assert the fixed address-only proxy header.
    #[serde(default)]
    pub proxy_client_ip_peers: Vec<std::net::IpAddr>,
}

impl FileConfig {
    pub fn parse(text: &str) -> Result<Self> {
        let config: Self = toml::from_str(text)?;
        config.proxy_policy()?;
        Ok(config)
    }

    pub fn proxy_policy(&self) -> Result<hennery_kernel::client_address::ProxyPolicy> {
        hennery_kernel::client_address::ProxyPolicy::new(self.proxy_client_ip_peers.clone())
    }

    /// `dir/config.toml`, or the defaults when there is none. Reading it
    /// creates nothing. A file that its group or others can write is
    /// refused: checked on the file that is read, not on its name.
    pub fn load(dir: &Path) -> Result<Self> {
        let path = dir.join(CONFIG_FILE);
        let mut file = match std::fs::File::open(&path) {
            Ok(file) => file,
            Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(Self::default()),
            Err(err) => return Err(err).with_context(|| format!("read {}", path.display())),
        };
        let mode = file.metadata()?.permissions().mode() & 0o777;
        if mode & 0o022 != 0 {
            bail!(
                "{} is writable by other users (mode {mode:o}); run `chmod go-w {}`",
                path.display(),
                path.display()
            );
        }
        let mut text = String::new();
        file.read_to_string(&mut text)
            .with_context(|| format!("read {}", path.display()))?;
        Self::parse(&text).with_context(|| format!("{}", path.display()))
    }

    /// The listen addresses: `given` (a flag or `HENNERY_LISTEN`) if any,
    /// else the file's. No key at all means the default; an explicit empty
    /// list in the file is refused, symmetric with an empty `HENNERY_LISTEN`
    /// (`main::listen_addresses`) — an operator who wrote `listen = []` most
    /// likely meant something else, not "use the default".
    pub fn listen(&self, given: &[String]) -> Result<Vec<String>> {
        if !given.is_empty() {
            return Ok(given.to_vec());
        }
        match &self.listen {
            None => Ok(Vec::new()),
            Some(addresses) if addresses.is_empty() => {
                bail!("listen in config.toml is empty: give at least one address, or remove the key")
            }
            Some(addresses) => Ok(addresses.clone()),
        }
    }

    /// `public_url`: `given` (a flag or `HENNERY_PUBLIC_URL`) if any, else
    /// the file's.
    pub fn public_url(&self, given: Option<&str>) -> Option<String> {
        given.map(str::to_string).or_else(|| self.public_url.clone())
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn proxy_peers_are_machine_only_exact_bounded_and_not_mistyped() {
        for text in [
            "",
            "proxy_client_ip_peers = []",
            "proxy_client_ip_peers = ['127.0.0.1', '::1']",
        ] {
            assert!(FileConfig::parse(text).is_ok(), "{text}");
        }
        for text in [
            "proxy_client_ip_peers = ['localhost']",
            "proxy_client_ip_peers = ['127.0.0.0/8']",
            "proxy_client_ip_peers = ['127.0.0.1:7117']",
            "proxy_client_ip_peers = ['0.0.0.0']",
            "proxy_client_ip_peers = ['::']",
            "proxy_client_ip_peers = ['224.0.0.1']",
            "proxy_client_ip_peers = ['127.0.0.1', '::ffff:127.0.0.1']",
            "proxy_client_ip_peers = ['127.0.0.1', '127.0.0.2', '127.0.0.3', '127.0.0.4', '127.0.0.5', '127.0.0.6', '127.0.0.7', '127.0.0.8', '127.0.0.9']",
            "proxy_client_ip_peer = ['127.0.0.1']",
        ] {
            assert!(FileConfig::parse(text).is_err(), "{text}");
        }
    }

    #[test]
    fn the_file_holds_listen_and_public_url_and_nothing_else() {
        let config = FileConfig::parse(
            "listen = [\"127.0.0.1:7117\", \"[::1]:7117\"]\npublic_url = \"https://hennery.example\"\n",
        )
        .unwrap();
        assert_eq!(
            config,
            FileConfig {
                listen: Some(vec!["127.0.0.1:7117".into(), "[::1]:7117".into()]),
                public_url: Some("https://hennery.example".into()),
                ..Default::default()
            }
        );
        assert_eq!(FileConfig::parse("").unwrap(), FileConfig::default());
        let typo = FileConfig::parse("listens = [\"127.0.0.1:7117\"]\n").unwrap_err();
        assert!(format!("{typo:#}").contains("unknown field"), "{typo:#}");
        assert!(FileConfig::parse("listen = \"127.0.0.1:7117\"\n").is_err());
    }

    #[test]
    fn a_missing_file_is_the_defaults() {
        let dir = std::env::temp_dir().join(format!("hennery-config-missing-{}", std::process::id()));
        assert_eq!(FileConfig::load(&dir).unwrap(), FileConfig::default());
        assert!(!dir.exists(), "loading made the directory");
    }

    #[test]
    fn a_file_others_can_write_is_refused() {
        // `tempfile`, not a hand-rolled `temp_dir()/…-<pid>`: it removes the
        // directory on drop, a failing assertion below included.
        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join(CONFIG_FILE);
        std::fs::write(&path, "listen = [\"127.0.0.1:7117\"]\n").unwrap();
        std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap();
        assert!(FileConfig::load(dir.path()).unwrap().listen.is_some());
        for mode in [0o664, 0o646] {
            std::fs::set_permissions(&path, std::fs::Permissions::from_mode(mode)).unwrap();
            let err = FileConfig::load(dir.path()).unwrap_err();
            assert!(format!("{err:#}").contains("chmod go-w"), "{mode:o}: {err:#}");
        }
    }

    #[test]
    fn flags_and_the_environment_win_over_the_file() {
        let file = FileConfig {
            listen: Some(vec!["127.0.0.1:1".into()]),
            public_url: Some("https://file.example".into()),
            ..Default::default()
        };
        assert_eq!(file.listen(&[]).unwrap(), ["127.0.0.1:1"]);
        assert_eq!(file.listen(&["127.0.0.1:2".into()]).unwrap(), ["127.0.0.1:2"]);
        assert_eq!(file.public_url(None).as_deref(), Some("https://file.example"));
        assert_eq!(
            file.public_url(Some("https://flag.example")).as_deref(),
            Some("https://flag.example")
        );
        let empty = FileConfig::default();
        assert!(empty.listen(&[]).unwrap().is_empty());
        assert_eq!(empty.public_url(None), None);
    }

    #[test]
    fn an_explicit_empty_listen_in_the_file_is_refused() {
        let empty_list = FileConfig {
            listen: Some(vec![]),
            public_url: None,
            ..Default::default()
        };
        let err = empty_list.listen(&[]).unwrap_err();
        assert!(format!("{err:#}").contains("listen in config.toml is empty"), "{err:#}");
        // A flag or `HENNERY_LISTEN` still wins, same as any other file value.
        assert_eq!(empty_list.listen(&["127.0.0.1:1".into()]).unwrap(), ["127.0.0.1:1"]);
        // No key at all is still the default (empty means "nothing given").
        assert!(FileConfig::default().listen(&[]).unwrap().is_empty());
    }
}