1use 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";
19pub const PROVISION_C2_ADDR_ENV: &str = "HATCHERY_PROVISION_C2_ADDR";
21pub const PROVISION_C2_TOKEN_VAR_ENV: &str = "HATCHERY_PROVISION_C2_TOKEN_VAR";
23pub const PROVISION_INTERVAL_ENV: &str = "HATCHERY_PROVISION_INTERVAL_SECS";
24pub const M4A_SERVER_REF_ENV: &str = "HATCHERY_M4A_SERVER_REF";
25pub const MAIL_DOMAIN_ENV: &str = "HATCHERY_MAIL_DOMAIN";
26pub const POLICY_FILE_ENV: &str = "HATCHERY_POLICY_FILE";
28
29pub const MEMORY_PORT_MIN: u16 = 18200;
31pub const MEMORY_PORT_MAX: u16 = 18299;
32const DEFAULT_CONTROL_LISTEN: &str = "127.0.0.1:18340";
33
34#[derive(Clone, Debug, Default, Deserialize, PartialEq, Eq)]
35#[serde(default, deny_unknown_fields)]
36pub struct FileConfig {
37 pub control_listen: Option<String>,
38 pub data_dir: Option<PathBuf>,
39 pub harness: HarnessFile,
40 pub memory: MemoryFile,
41 pub m4a: M4aFile,
42}
43
44#[derive(Clone, Debug, Default, Deserialize, PartialEq, Eq)]
45#[serde(default, deny_unknown_fields)]
46pub struct HarnessFile {
47 pub enabled: bool,
48 pub bin: Option<PathBuf>,
49 pub args: Vec<String>,
50}
51
52#[derive(Clone, Debug, Default, Deserialize, PartialEq, Eq)]
53#[serde(default, deny_unknown_fields)]
54pub struct MemoryFile {
55 pub bin: Option<PathBuf>,
56}
57
58#[derive(Clone, Debug, Default, Deserialize, PartialEq, Eq)]
59#[serde(default, deny_unknown_fields)]
60pub struct M4aFile {
61 pub enabled: bool,
62 pub master_key_var: Option<String>,
64}
65
66#[derive(Clone, Debug, PartialEq, Eq)]
67pub struct ServerConfig {
68 pub control_listen: SocketAddr,
69 pub control_token: Option<String>,
70 pub data_dir: PathBuf,
71 pub harness_enabled: bool,
72 pub harness_bin: Option<PathBuf>,
73 pub harness_args: Vec<String>,
74 pub memory_bin: PathBuf,
75 pub m4a_enabled: bool,
76 pub m4a_master_key_var: Option<String>,
77 pub provision_c2_addr: Option<String>,
79 pub provision_c2_token_var: String,
80 pub provision_interval_secs: u64,
81 pub m4a_server_ref: String,
82 pub mail_domain: String,
83 pub policy_path: Option<PathBuf>,
84}
85
86impl ServerConfig {
87 pub fn from_env() -> Result<Self, ServerError> {
89 let file = match std::env::var_os(CONFIG_ENV) {
90 Some(path) => {
91 let text = std::fs::read_to_string(&path)
92 .map_err(|e| ServerError::Config(format!("{CONFIG_ENV}: {e}")))?;
93 toml::from_str::<FileConfig>(&text)
94 .map_err(|e| ServerError::Config(format!("{CONFIG_ENV}: {e}")))?
95 }
96 None => FileConfig::default(),
97 };
98 Self::resolve(file, |name| std::env::var(name).ok())
99 }
100
101 pub fn resolve(
103 file: FileConfig,
104 env: impl Fn(&str) -> Option<String>,
105 ) -> Result<Self, ServerError> {
106 let listen = env(CONTROL_LISTEN_ENV)
107 .or(file.control_listen)
108 .unwrap_or_else(|| DEFAULT_CONTROL_LISTEN.to_owned());
109 let control_listen: SocketAddr = listen
110 .parse()
111 .map_err(|_| ServerError::Config(format!("{CONTROL_LISTEN_ENV}: not a socket address")))?;
112 if !control_listen.ip().is_loopback() {
113 return Err(ServerError::Config(
114 "control listener must be loopback; publish it through a tunnel, not a bind".into(),
115 ));
116 }
117 let data_dir = env(DATA_DIR_ENV)
118 .map(PathBuf::from)
119 .or(file.data_dir)
120 .ok_or_else(|| ServerError::Config(format!("{DATA_DIR_ENV} is required")))?;
121 if !data_dir.is_absolute() {
122 return Err(ServerError::Config(format!("{DATA_DIR_ENV} must be absolute")));
123 }
124 let harness_bin = env(HARNESS_BIN_ENV).map(PathBuf::from).or(file.harness.bin);
125 let harness_args = match env(HARNESS_ARGS_ENV) {
126 Some(raw) => raw.split_whitespace().map(str::to_owned).collect(),
127 None => file.harness.args,
128 };
129 Ok(Self {
130 control_listen,
131 control_token: env(CONTROL_TOKEN_ENV).filter(|v| !v.is_empty()),
132 data_dir,
133 harness_enabled: file.harness.enabled || harness_bin.is_some(),
134 harness_bin,
135 harness_args,
136 memory_bin: env(MEMORY_BIN_ENV)
137 .map(PathBuf::from)
138 .or(file.memory.bin)
139 .unwrap_or_else(|| PathBuf::from("memory4agent-server")),
140 m4a_enabled: file.m4a.enabled || env(M4A_MASTER_KEY_VAR_ENV).is_some(),
141 m4a_master_key_var: env(M4A_MASTER_KEY_VAR_ENV).or(file.m4a.master_key_var),
142 provision_c2_addr: env(PROVISION_C2_ADDR_ENV).filter(|v| !v.is_empty()),
143 provision_c2_token_var: env(PROVISION_C2_TOKEN_VAR_ENV)
144 .unwrap_or_else(|| "HATCHERY_PROVISION_C2_TOKEN".to_owned()),
145 provision_interval_secs: env(PROVISION_INTERVAL_ENV).and_then(|v| v.parse().ok()).unwrap_or(5).max(1),
146 m4a_server_ref: env(M4A_SERVER_REF_ENV).unwrap_or_else(|| "https://messenger.invalid".to_owned()),
147 mail_domain: env(MAIL_DOMAIN_ENV).unwrap_or_else(|| "nodes.invalid".to_owned()),
148 policy_path: env(POLICY_FILE_ENV).filter(|v| !v.is_empty()).map(PathBuf::from),
149 })
150 }
151}
152
153#[cfg(test)]
154mod tests {
155 use super::*;
156 use std::collections::HashMap;
157
158 fn env_of(pairs: &[(&str, &str)]) -> impl Fn(&str) -> Option<String> {
159 let map: HashMap<String, String> =
160 pairs.iter().map(|(k, v)| ((*k).to_owned(), (*v).to_owned())).collect();
161 move |name| map.get(name).cloned()
162 }
163
164 #[test]
165 fn data_dir_is_required_and_absolute() {
166 assert!(ServerConfig::resolve(FileConfig::default(), env_of(&[])).is_err());
167 assert!(ServerConfig::resolve(
168 FileConfig::default(),
169 env_of(&[(DATA_DIR_ENV, "relative")])
170 )
171 .is_err());
172 let cfg = ServerConfig::resolve(
173 FileConfig::default(),
174 env_of(&[(DATA_DIR_ENV, "/var/lib/hatchery")]),
175 )
176 .unwrap();
177 assert!(cfg.control_listen.ip().is_loopback());
178 assert!(!cfg.harness_enabled && !cfg.m4a_enabled);
179 }
180
181 #[test]
182 fn control_listener_must_be_loopback() {
183 let err = ServerConfig::resolve(
184 FileConfig::default(),
185 env_of(&[(DATA_DIR_ENV, "/d"), (CONTROL_LISTEN_ENV, "0.0.0.0:18340")]),
186 )
187 .unwrap_err();
188 assert!(err.to_string().contains("loopback"));
189 }
190
191 #[test]
192 fn env_overrides_file_and_enables_subservices() {
193 let file: FileConfig = toml::from_str(
194 "data_dir = \"/from/file\"\n[harness]\nbin = \"/opt/h\"\nargs = [\"--a\"]\n[m4a]\nmaster_key_var = \"FILE_VAR\"\n",
195 )
196 .unwrap();
197 let cfg = ServerConfig::resolve(
198 file,
199 env_of(&[(DATA_DIR_ENV, "/from/env"), (HARNESS_ARGS_ENV, "--x --y")]),
200 )
201 .unwrap();
202 assert_eq!(cfg.data_dir, PathBuf::from("/from/env"));
203 assert_eq!(cfg.harness_args, ["--x", "--y"]);
204 assert!(cfg.harness_enabled);
205 assert_eq!(cfg.m4a_master_key_var.as_deref(), Some("FILE_VAR"));
206 }
207
208 #[test]
209 fn unknown_file_keys_are_rejected() {
210 assert!(toml::from_str::<FileConfig>("bogus = 1").is_err());
211 }
212}