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";
19
20pub 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 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 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 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}