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 MEMORY_BIN_ENV: &str = "HATCHERY_MEMORY_BIN";
16pub const M4A_MASTER_KEY_VAR_ENV: &str = "HATCHERY_M4A_MASTER_KEY_VAR";
17/// C2 operator API the provisioner polls (`host:port`, reachable on loopback or a tunnel).
18pub const PROVISION_C2_ADDR_ENV: &str = "HATCHERY_PROVISION_C2_ADDR";
19/// Name of the variable holding the C2 operator bearer (the value is never in config).
20pub const PROVISION_C2_TOKEN_VAR_ENV: &str = "HATCHERY_PROVISION_C2_TOKEN_VAR";
21pub const PROVISION_INTERVAL_ENV: &str = "HATCHERY_PROVISION_INTERVAL_SECS";
22pub const M4A_SERVER_REF_ENV: &str = "HATCHERY_M4A_SERVER_REF";
23pub const MAIL_DOMAIN_ENV: &str = "HATCHERY_MAIL_DOMAIN";
24/// Status -> capability policy (JSON), the same file C2 reads.
25pub const POLICY_FILE_ENV: &str = "HATCHERY_POLICY_FILE";
26
27/// Memory4agent listens only inside this range, on loopback.
28pub const MEMORY_PORT_MIN: u16 = 18200;
29pub const MEMORY_PORT_MAX: u16 = 18299;
30const DEFAULT_CONTROL_LISTEN: &str = "127.0.0.1:18340";
31
32#[derive(Clone, Debug, Default, Deserialize, PartialEq, Eq)]
33#[serde(default, deny_unknown_fields)]
34pub struct FileConfig {
35    pub control_listen: Option<String>,
36    pub data_dir: Option<PathBuf>,
37    pub memory: MemoryFile,
38    pub m4a: M4aFile,
39}
40
41#[derive(Clone, Debug, Default, Deserialize, PartialEq, Eq)]
42#[serde(default, deny_unknown_fields)]
43pub struct MemoryFile {
44    pub bin: Option<PathBuf>,
45}
46
47#[derive(Clone, Debug, Default, Deserialize, PartialEq, Eq)]
48#[serde(default, deny_unknown_fields)]
49pub struct M4aFile {
50    pub enabled: bool,
51    /// Name of the environment variable that holds the vault master key.
52    pub master_key_var: Option<String>,
53}
54
55#[derive(Clone, Debug, PartialEq, Eq)]
56pub struct ServerConfig {
57    pub control_listen: SocketAddr,
58    pub control_token: Option<String>,
59    pub data_dir: PathBuf,
60    pub memory_bin: PathBuf,
61    pub m4a_enabled: bool,
62    pub m4a_master_key_var: Option<String>,
63    /// Provisioning is on only when m4a is enabled and a C2 address is configured.
64    pub provision_c2_addr: Option<String>,
65    pub provision_c2_token_var: String,
66    pub provision_interval_secs: u64,
67    pub m4a_server_ref: String,
68    pub mail_domain: String,
69    pub policy_path: Option<PathBuf>,
70}
71
72impl ServerConfig {
73    /// Build from the process environment (and the file it points at).
74    pub fn from_env() -> Result<Self, ServerError> {
75        let file = match std::env::var_os(CONFIG_ENV) {
76            Some(path) => {
77                let text = std::fs::read_to_string(&path)
78                    .map_err(|e| ServerError::Config(format!("{CONFIG_ENV}: {e}")))?;
79                toml::from_str::<FileConfig>(&text)
80                    .map_err(|e| ServerError::Config(format!("{CONFIG_ENV}: {e}")))?
81            }
82            None => FileConfig::default(),
83        };
84        Self::resolve(file, |name| std::env::var(name).ok())
85    }
86
87    /// Pure resolution (tests inject the environment).
88    pub fn resolve(
89        file: FileConfig,
90        env: impl Fn(&str) -> Option<String>,
91    ) -> Result<Self, ServerError> {
92        let listen = env(CONTROL_LISTEN_ENV)
93            .or(file.control_listen)
94            .unwrap_or_else(|| DEFAULT_CONTROL_LISTEN.to_owned());
95        let control_listen: SocketAddr = listen
96            .parse()
97            .map_err(|_| ServerError::Config(format!("{CONTROL_LISTEN_ENV}: not a socket address")))?;
98        if !control_listen.ip().is_loopback() {
99            return Err(ServerError::Config(
100                "control listener must be loopback; publish it through a tunnel, not a bind".into(),
101            ));
102        }
103        let data_dir = env(DATA_DIR_ENV)
104            .map(PathBuf::from)
105            .or(file.data_dir)
106            .ok_or_else(|| ServerError::Config(format!("{DATA_DIR_ENV} is required")))?;
107        if !data_dir.is_absolute() {
108            return Err(ServerError::Config(format!("{DATA_DIR_ENV} must be absolute")));
109        }
110        Ok(Self {
111            control_listen,
112            control_token: env(CONTROL_TOKEN_ENV).filter(|v| !v.is_empty()),
113            data_dir,
114            memory_bin: env(MEMORY_BIN_ENV)
115                .map(PathBuf::from)
116                .or(file.memory.bin)
117                .unwrap_or_else(|| PathBuf::from("memory4agent-server")),
118            m4a_enabled: file.m4a.enabled || env(M4A_MASTER_KEY_VAR_ENV).is_some(),
119            m4a_master_key_var: env(M4A_MASTER_KEY_VAR_ENV).or(file.m4a.master_key_var),
120            provision_c2_addr: env(PROVISION_C2_ADDR_ENV).filter(|v| !v.is_empty()),
121            provision_c2_token_var: env(PROVISION_C2_TOKEN_VAR_ENV)
122                .unwrap_or_else(|| "HATCHERY_PROVISION_C2_TOKEN".to_owned()),
123            provision_interval_secs: env(PROVISION_INTERVAL_ENV).and_then(|v| v.parse().ok()).unwrap_or(5).max(1),
124            m4a_server_ref: env(M4A_SERVER_REF_ENV).unwrap_or_else(|| "https://messenger.invalid".to_owned()),
125            mail_domain: env(MAIL_DOMAIN_ENV).unwrap_or_else(|| "nodes.invalid".to_owned()),
126            policy_path: env(POLICY_FILE_ENV).filter(|v| !v.is_empty()).map(PathBuf::from),
127        })
128    }
129}
130
131#[cfg(test)]
132mod tests {
133    use super::*;
134    use std::collections::HashMap;
135
136    fn env_of(pairs: &[(&str, &str)]) -> impl Fn(&str) -> Option<String> {
137        let map: HashMap<String, String> =
138            pairs.iter().map(|(k, v)| ((*k).to_owned(), (*v).to_owned())).collect();
139        move |name| map.get(name).cloned()
140    }
141
142    #[test]
143    fn data_dir_is_required_and_absolute() {
144        assert!(ServerConfig::resolve(FileConfig::default(), env_of(&[])).is_err());
145        assert!(ServerConfig::resolve(
146            FileConfig::default(),
147            env_of(&[(DATA_DIR_ENV, "relative")])
148        )
149        .is_err());
150        let cfg = ServerConfig::resolve(
151            FileConfig::default(),
152            env_of(&[(DATA_DIR_ENV, "/var/lib/hatchery")]),
153        )
154        .unwrap();
155        assert!(cfg.control_listen.ip().is_loopback());
156        assert!(!cfg.m4a_enabled);
157    }
158
159    #[test]
160    fn control_listener_must_be_loopback() {
161        let err = ServerConfig::resolve(
162            FileConfig::default(),
163            env_of(&[(DATA_DIR_ENV, "/d"), (CONTROL_LISTEN_ENV, "0.0.0.0:18340")]),
164        )
165        .unwrap_err();
166        assert!(err.to_string().contains("loopback"));
167    }
168
169    #[test]
170    fn env_overrides_file_and_enables_subservices() {
171        let file: FileConfig = toml::from_str(
172            "data_dir = \"/from/file\"\n[m4a]\nmaster_key_var = \"FILE_VAR\"\n",
173        )
174        .unwrap();
175        let cfg = ServerConfig::resolve(
176            file,
177            env_of(&[(DATA_DIR_ENV, "/from/env")]),
178        )
179        .unwrap();
180        assert_eq!(cfg.data_dir, PathBuf::from("/from/env"));
181        assert_eq!(cfg.m4a_master_key_var.as_deref(), Some("FILE_VAR"));
182    }
183
184    #[test]
185    fn unknown_file_keys_are_rejected() {
186        assert!(toml::from_str::<FileConfig>("bogus = 1").is_err());
187    }
188}