hatchery_server/
config.rs1use 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";
17pub const PROVISION_C2_ADDR_ENV: &str = "HATCHERY_PROVISION_C2_ADDR";
19pub 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";
24pub const POLICY_FILE_ENV: &str = "HATCHERY_POLICY_FILE";
26
27pub 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 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 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 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 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}