Skip to main content

hatchery_server/
config.rs

1//! Server configuration: an optional TOML file (`HATCHERY_SERVER_CONFIG`) with
2//! environment variables taking precedence. Addresses, binary paths and key
3//! variable names are never compiled in beyond loopback defaults; secrets are
4//! never stored here, only the *names* of the variables that hold them.
5
6use crate::error::ServerError;
7use serde::Deserialize;
8use std::net::SocketAddr;
9use std::path::PathBuf;
10
11pub const CONFIG_ENV: &str = "HATCHERY_SERVER_CONFIG";
12pub const CONTROL_LISTEN_ENV: &str = "HATCHERY_SERVER_CONTROL_LISTEN";
13pub const CONTROL_TOKEN_ENV: &str = "HATCHERY_SERVER_CONTROL_TOKEN";
14pub const DATA_DIR_ENV: &str = "HATCHERY_SERVER_DATA_DIR";
15pub const HARNESS_BIN_ENV: &str = "HATCHERY_HARNESS_BIN";
16pub const HARNESS_ARGS_ENV: &str = "HATCHERY_HARNESS_ARGS";
17pub const MEMORY_BIN_ENV: &str = "HATCHERY_MEMORY_BIN";
18pub const M4A_MASTER_KEY_VAR_ENV: &str = "HATCHERY_M4A_MASTER_KEY_VAR";
19
20/// Memory4agent listens only inside this range, on loopback.
21pub const MEMORY_PORT_MIN: u16 = 18200;
22pub const MEMORY_PORT_MAX: u16 = 18299;
23const DEFAULT_CONTROL_LISTEN: &str = "127.0.0.1:18340";
24
25#[derive(Clone, Debug, Default, Deserialize, PartialEq, Eq)]
26#[serde(default, deny_unknown_fields)]
27pub struct FileConfig {
28    pub control_listen: Option<String>,
29    pub data_dir: Option<PathBuf>,
30    pub harness: HarnessFile,
31    pub memory: MemoryFile,
32    pub m4a: M4aFile,
33}
34
35#[derive(Clone, Debug, Default, Deserialize, PartialEq, Eq)]
36#[serde(default, deny_unknown_fields)]
37pub struct HarnessFile {
38    pub enabled: bool,
39    pub bin: Option<PathBuf>,
40    pub args: Vec<String>,
41}
42
43#[derive(Clone, Debug, Default, Deserialize, PartialEq, Eq)]
44#[serde(default, deny_unknown_fields)]
45pub struct MemoryFile {
46    pub bin: Option<PathBuf>,
47}
48
49#[derive(Clone, Debug, Default, Deserialize, PartialEq, Eq)]
50#[serde(default, deny_unknown_fields)]
51pub struct M4aFile {
52    pub enabled: bool,
53    /// Name of the environment variable that holds the vault master key.
54    pub master_key_var: Option<String>,
55}
56
57#[derive(Clone, Debug, PartialEq, Eq)]
58pub struct ServerConfig {
59    pub control_listen: SocketAddr,
60    pub control_token: Option<String>,
61    pub data_dir: PathBuf,
62    pub harness_enabled: bool,
63    pub harness_bin: Option<PathBuf>,
64    pub harness_args: Vec<String>,
65    pub memory_bin: PathBuf,
66    pub m4a_enabled: bool,
67    pub m4a_master_key_var: Option<String>,
68}
69
70impl ServerConfig {
71    /// Build from the process environment (and the file it points at).
72    pub fn from_env() -> Result<Self, ServerError> {
73        let file = match std::env::var_os(CONFIG_ENV) {
74            Some(path) => {
75                let text = std::fs::read_to_string(&path)
76                    .map_err(|e| ServerError::Config(format!("{CONFIG_ENV}: {e}")))?;
77                toml::from_str::<FileConfig>(&text)
78                    .map_err(|e| ServerError::Config(format!("{CONFIG_ENV}: {e}")))?
79            }
80            None => FileConfig::default(),
81        };
82        Self::resolve(file, |name| std::env::var(name).ok())
83    }
84
85    /// Pure resolution (tests inject the environment).
86    pub fn resolve(
87        file: FileConfig,
88        env: impl Fn(&str) -> Option<String>,
89    ) -> Result<Self, ServerError> {
90        let listen = env(CONTROL_LISTEN_ENV)
91            .or(file.control_listen)
92            .unwrap_or_else(|| DEFAULT_CONTROL_LISTEN.to_owned());
93        let control_listen: SocketAddr = listen
94            .parse()
95            .map_err(|_| ServerError::Config(format!("{CONTROL_LISTEN_ENV}: not a socket address")))?;
96        if !control_listen.ip().is_loopback() {
97            return Err(ServerError::Config(
98                "control listener must be loopback; publish it through a tunnel, not a bind".into(),
99            ));
100        }
101        let data_dir = env(DATA_DIR_ENV)
102            .map(PathBuf::from)
103            .or(file.data_dir)
104            .ok_or_else(|| ServerError::Config(format!("{DATA_DIR_ENV} is required")))?;
105        if !data_dir.is_absolute() {
106            return Err(ServerError::Config(format!("{DATA_DIR_ENV} must be absolute")));
107        }
108        let harness_bin = env(HARNESS_BIN_ENV).map(PathBuf::from).or(file.harness.bin);
109        let harness_args = match env(HARNESS_ARGS_ENV) {
110            Some(raw) => raw.split_whitespace().map(str::to_owned).collect(),
111            None => file.harness.args,
112        };
113        Ok(Self {
114            control_listen,
115            control_token: env(CONTROL_TOKEN_ENV).filter(|v| !v.is_empty()),
116            data_dir,
117            harness_enabled: file.harness.enabled || harness_bin.is_some(),
118            harness_bin,
119            harness_args,
120            memory_bin: env(MEMORY_BIN_ENV)
121                .map(PathBuf::from)
122                .or(file.memory.bin)
123                .unwrap_or_else(|| PathBuf::from("memory4agent-server")),
124            m4a_enabled: file.m4a.enabled || env(M4A_MASTER_KEY_VAR_ENV).is_some(),
125            m4a_master_key_var: env(M4A_MASTER_KEY_VAR_ENV).or(file.m4a.master_key_var),
126        })
127    }
128}
129
130#[cfg(test)]
131mod tests {
132    use super::*;
133    use std::collections::HashMap;
134
135    fn env_of(pairs: &[(&str, &str)]) -> impl Fn(&str) -> Option<String> {
136        let map: HashMap<String, String> =
137            pairs.iter().map(|(k, v)| ((*k).to_owned(), (*v).to_owned())).collect();
138        move |name| map.get(name).cloned()
139    }
140
141    #[test]
142    fn data_dir_is_required_and_absolute() {
143        assert!(ServerConfig::resolve(FileConfig::default(), env_of(&[])).is_err());
144        assert!(ServerConfig::resolve(
145            FileConfig::default(),
146            env_of(&[(DATA_DIR_ENV, "relative")])
147        )
148        .is_err());
149        let cfg = ServerConfig::resolve(
150            FileConfig::default(),
151            env_of(&[(DATA_DIR_ENV, "/var/lib/hatchery")]),
152        )
153        .unwrap();
154        assert!(cfg.control_listen.ip().is_loopback());
155        assert!(!cfg.harness_enabled && !cfg.m4a_enabled);
156    }
157
158    #[test]
159    fn control_listener_must_be_loopback() {
160        let err = ServerConfig::resolve(
161            FileConfig::default(),
162            env_of(&[(DATA_DIR_ENV, "/d"), (CONTROL_LISTEN_ENV, "0.0.0.0:18340")]),
163        )
164        .unwrap_err();
165        assert!(err.to_string().contains("loopback"));
166    }
167
168    #[test]
169    fn env_overrides_file_and_enables_subservices() {
170        let file: FileConfig = toml::from_str(
171            "data_dir = \"/from/file\"\n[harness]\nbin = \"/opt/h\"\nargs = [\"--a\"]\n[m4a]\nmaster_key_var = \"FILE_VAR\"\n",
172        )
173        .unwrap();
174        let cfg = ServerConfig::resolve(
175            file,
176            env_of(&[(DATA_DIR_ENV, "/from/env"), (HARNESS_ARGS_ENV, "--x --y")]),
177        )
178        .unwrap();
179        assert_eq!(cfg.data_dir, PathBuf::from("/from/env"));
180        assert_eq!(cfg.harness_args, ["--x", "--y"]);
181        assert!(cfg.harness_enabled);
182        assert_eq!(cfg.m4a_master_key_var.as_deref(), Some("FILE_VAR"));
183    }
184
185    #[test]
186    fn unknown_file_keys_are_rejected() {
187        assert!(toml::from_str::<FileConfig>("bogus = 1").is_err());
188    }
189}