1use std::path::{Path, PathBuf};
9
10use crate::error::Error;
11
12pub trait EnvReader {
16 fn get(&self, key: &str) -> Option<String>;
17}
18
19pub struct ProcessEnv;
21
22impl EnvReader for ProcessEnv {
23 fn get(&self, key: &str) -> Option<String> {
24 std::env::var(key).ok()
25 }
26}
27
28#[derive(Debug, Clone, PartialEq, Eq)]
37#[allow(clippy::struct_excessive_bools)] pub struct Env {
39 pub wasm_dir: PathBuf,
44 pub log_level: String,
50 pub bind_ipv4: bool,
52 pub bind_ipv6: bool,
54 pub sec_max_header_bytes: u32,
56 pub sec_max_headers_count: u32,
58 pub sec_header_timeout_secs: u32,
60 pub sec_max_conn_per_ip: u32,
62 pub sec_max_total_conns: u32,
64 pub bind_max_attempts: u32,
66 pub bind_backoff_initial_ms: u32,
68 pub bind_backoff_max_ms: u32,
70 pub force_cancel_grace_secs: u32,
73 pub drain_timeout_secs: u32,
76 pub boot_health_timeout_secs: u32,
79 pub mgmt_unix: PathBuf,
85 pub mgmt_http_port: Option<u16>,
90 pub mgmt_http_public: bool,
95 pub mgmt_http_token: Option<String>,
98 pub native_roots_refresh_interval_secs: u32,
105 pub allow_insecure_upstream: bool,
114}
115
116impl Env {
117 pub fn from_process_env(config_dir: &Path) -> Result<Self, Error> {
126 Self::from_reader(&ProcessEnv, config_dir)
127 }
128
129 pub fn from_reader<R: EnvReader>(r: &R, config_dir: &Path) -> Result<Self, Error> {
134 let wasm_dir = r.get("VANE_WASM_DIR").map_or_else(|| config_dir.join("wasm"), PathBuf::from);
135 Ok(Self {
136 wasm_dir,
137 log_level: r
138 .get("VANE_LOG_LEVEL")
139 .filter(|s| !s.is_empty())
140 .unwrap_or_else(|| "info".to_string()),
141 bind_ipv4: parse_bool_default_true(r, "VANE_BIND_IPV4")?,
142 bind_ipv6: parse_bool_default_true(r, "VANE_BIND_IPV6")?,
143 sec_max_header_bytes: parse_u32_default(r, "VANE_SEC_MAX_HEADER_BYTES", 65_536)?,
144 sec_max_headers_count: parse_u32_default(r, "VANE_SEC_MAX_HEADERS_COUNT", 100)?,
145 sec_header_timeout_secs: parse_u32_default(r, "VANE_SEC_HEADER_TIMEOUT", 30)?,
146 sec_max_conn_per_ip: parse_u32_default(r, "VANE_SEC_MAX_CONN_PER_IP", 100)?,
147 sec_max_total_conns: parse_u32_default(r, "VANE_SEC_MAX_TOTAL_CONNS", 65_536)?,
148 bind_max_attempts: parse_u32_default(r, "VANE_BIND_MAX_ATTEMPTS", 10)?,
149 bind_backoff_initial_ms: parse_u32_default(r, "VANE_BIND_BACKOFF_INITIAL_MS", 100)?,
150 bind_backoff_max_ms: parse_u32_default(r, "VANE_BIND_BACKOFF_MAX_MS", 5_000)?,
151 force_cancel_grace_secs: parse_u32_default(r, "VANE_FORCE_CANCEL_GRACE_SECS", 5)?,
152 drain_timeout_secs: parse_u32_default(r, "VANE_DRAIN_TIMEOUT_SECS", 30)?,
153 boot_health_timeout_secs: parse_u32_default(r, "VANE_BOOT_HEALTH_TIMEOUT_SECS", 60)?,
154 mgmt_unix: r
155 .get("VANE_MGMT_UNIX")
156 .filter(|s| !s.is_empty())
157 .map_or_else(|| default_mgmt_unix(r), PathBuf::from),
158 mgmt_http_port: parse_http_port(r)?,
159 mgmt_http_public: parse_truthy(r, "VANE_MGMT_HTTP_PUBLIC"),
160 mgmt_http_token: r.get("VANE_MGMT_HTTP_TOKEN").filter(|s| !s.is_empty()),
161 native_roots_refresh_interval_secs: parse_u32_default(
162 r,
163 "VANE_NATIVE_ROOTS_REFRESH_INTERVAL_SECS",
164 21_600,
165 )?,
166 allow_insecure_upstream: parse_truthy(r, "VANE_ALLOW_INSECURE_UPSTREAM"),
167 })
168 }
169}
170
171fn default_mgmt_unix<R: EnvReader>(r: &R) -> PathBuf {
184 if let Some(dir) = r.get("XDG_RUNTIME_DIR").filter(|s| !s.is_empty()) {
185 return PathBuf::from(dir).join("vaned.sock");
186 }
187 PathBuf::from("/run/vaned.sock")
188}
189
190fn parse_bool_default_true<R: EnvReader>(r: &R, key: &str) -> Result<bool, Error> {
191 match r.get(key).as_deref() {
192 None | Some("" | "1") => Ok(true),
193 Some("0") => Ok(false),
194 Some(other) => Err(Error::compile(format!("{key} must be \"0\" or \"1\", got {other:?}"))),
195 }
196}
197
198fn parse_u32_default<R: EnvReader>(r: &R, key: &str, default: u32) -> Result<u32, Error> {
199 match r.get(key).filter(|s| !s.is_empty()) {
200 None => Ok(default),
201 Some(s) => s.parse::<u32>().map_err(|e| Error::compile(format!("{key}: {e} ({s:?})"))),
202 }
203}
204
205fn parse_http_port<R: EnvReader>(r: &R) -> Result<Option<u16>, Error> {
209 match r.get("VANE_MGMT_HTTP_PORT").as_deref() {
210 None => Ok(Some(3333)),
211 Some("") => Ok(None),
212 Some(s) => s
213 .parse::<u16>()
214 .map(Some)
215 .map_err(|e| Error::compile(format!("VANE_MGMT_HTTP_PORT: {e} ({s:?})"))),
216 }
217}
218
219fn parse_truthy<R: EnvReader>(r: &R, key: &str) -> bool {
223 matches!(r.get(key).map(|s| s.to_ascii_lowercase()).as_deref(), Some("1" | "true" | "yes" | "on"),)
224}
225
226#[cfg(test)]
227mod tests {
228 use std::collections::HashMap;
229
230 use super::*;
231
232 struct FakeEnv(HashMap<&'static str, &'static str>);
233
234 impl FakeEnv {
235 fn empty() -> Self {
236 Self(HashMap::new())
237 }
238
239 fn with(pairs: &[(&'static str, &'static str)]) -> Self {
240 Self(pairs.iter().copied().collect())
241 }
242 }
243
244 impl EnvReader for FakeEnv {
245 fn get(&self, key: &str) -> Option<String> {
246 self.0.get(key).map(|s| (*s).to_string())
247 }
248 }
249
250 fn cfg() -> PathBuf {
251 PathBuf::from("/etc/vaned")
252 }
253
254 #[test]
255 fn env_defaults_when_all_unset() {
256 let env = Env::from_reader(&FakeEnv::empty(), &cfg()).expect("defaults");
257 assert_eq!(env.log_level, "info");
258 assert!(env.bind_ipv4);
259 assert!(env.bind_ipv6);
260 assert_eq!(env.sec_max_header_bytes, 65_536);
261 assert_eq!(env.sec_max_headers_count, 100);
262 assert_eq!(env.sec_header_timeout_secs, 30);
263 assert_eq!(env.sec_max_conn_per_ip, 100);
264 assert_eq!(env.sec_max_total_conns, 65_536);
265 assert_eq!(env.mgmt_unix, PathBuf::from("/run/vaned.sock"));
268 assert_eq!(env.mgmt_http_port, Some(3333));
269 assert!(!env.mgmt_http_public);
270 assert!(env.mgmt_http_token.is_none());
271 }
272
273 #[test]
274 fn env_mgmt_unix_prefers_xdg_runtime_dir_when_set() {
275 let env = Env::from_reader(&FakeEnv::with(&[("XDG_RUNTIME_DIR", "/run/user/1000")]), &cfg())
276 .expect("ok");
277 assert_eq!(env.mgmt_unix, PathBuf::from("/run/user/1000/vaned.sock"));
278 }
279
280 #[test]
281 fn env_bind_ipv4_zero_yields_false() {
282 let env = Env::from_reader(&FakeEnv::with(&[("VANE_BIND_IPV4", "0")]), &cfg()).expect("ok");
283 assert!(!env.bind_ipv4);
284 }
285
286 #[test]
287 fn env_bind_ipv4_one_yields_true() {
288 let env = Env::from_reader(&FakeEnv::with(&[("VANE_BIND_IPV4", "1")]), &cfg()).expect("ok");
289 assert!(env.bind_ipv4);
290 }
291
292 #[test]
293 fn env_bind_ipv4_empty_string_falls_back_to_default() {
294 let env = Env::from_reader(&FakeEnv::with(&[("VANE_BIND_IPV4", "")]), &cfg()).expect("ok");
297 assert!(env.bind_ipv4, "empty string falls back to default true");
298 }
299
300 #[test]
301 fn env_bind_ipv4_invalid_returns_compile_error_naming_var() {
302 let err =
303 Env::from_reader(&FakeEnv::with(&[("VANE_BIND_IPV4", "yes")]), &cfg()).expect_err("invalid");
304 let msg = err.to_string();
305 assert!(msg.contains("VANE_BIND_IPV4"), "error names the var: {msg}");
306 assert!(msg.contains("\"yes\""), "error quotes the offending value: {msg}");
307 }
308
309 #[test]
310 fn env_sec_integers_parse() {
311 let env = Env::from_reader(
312 &FakeEnv::with(&[
313 ("VANE_SEC_MAX_HEADER_BYTES", "32768"),
314 ("VANE_SEC_MAX_HEADERS_COUNT", "64"),
315 ("VANE_SEC_HEADER_TIMEOUT", "10"),
316 ("VANE_SEC_MAX_CONN_PER_IP", "500"),
317 ]),
318 &cfg(),
319 )
320 .expect("ok");
321 assert_eq!(env.sec_max_header_bytes, 32_768);
322 assert_eq!(env.sec_max_headers_count, 64);
323 assert_eq!(env.sec_header_timeout_secs, 10);
324 assert_eq!(env.sec_max_conn_per_ip, 500);
325 }
326
327 #[test]
328 fn env_sec_invalid_integer_errors() {
329 let err = Env::from_reader(&FakeEnv::with(&[("VANE_SEC_MAX_HEADER_BYTES", "huge")]), &cfg())
330 .expect_err("non-int rejected");
331 let msg = err.to_string();
332 assert!(msg.contains("VANE_SEC_MAX_HEADER_BYTES"), "{msg}");
333 }
334
335 #[test]
336 fn env_sec_negative_integer_errors() {
337 let err = Env::from_reader(&FakeEnv::with(&[("VANE_SEC_MAX_CONN_PER_IP", "-1")]), &cfg())
339 .expect_err("negative rejected");
340 assert!(err.to_string().contains("VANE_SEC_MAX_CONN_PER_IP"));
341 }
342
343 #[test]
344 fn env_mgmt_http_port_default_is_3333() {
345 let env = Env::from_reader(&FakeEnv::empty(), &cfg()).expect("defaults");
346 assert_eq!(env.mgmt_http_port, Some(3333));
347 }
348
349 #[test]
350 fn env_mgmt_http_port_empty_string_disables_transport() {
351 let env = Env::from_reader(&FakeEnv::with(&[("VANE_MGMT_HTTP_PORT", "")]), &cfg()).expect("ok");
352 assert_eq!(env.mgmt_http_port, None);
353 }
354
355 #[test]
356 fn env_mgmt_http_port_explicit_value_parses() {
357 let env =
358 Env::from_reader(&FakeEnv::with(&[("VANE_MGMT_HTTP_PORT", "9000")]), &cfg()).expect("ok");
359 assert_eq!(env.mgmt_http_port, Some(9000));
360 }
361
362 #[test]
363 fn env_mgmt_http_port_invalid_errors() {
364 let err = Env::from_reader(&FakeEnv::with(&[("VANE_MGMT_HTTP_PORT", "nope")]), &cfg())
365 .expect_err("bad port");
366 let msg = err.to_string();
367 assert!(msg.contains("VANE_MGMT_HTTP_PORT"), "{msg}");
368 assert!(msg.contains("\"nope\""), "{msg}");
369 }
370
371 #[test]
372 fn env_mgmt_http_public_truthy_values() {
373 for v in ["1", "true", "TRUE", "Yes", "on"] {
374 let env =
375 Env::from_reader(&FakeEnv::with(&[("VANE_MGMT_HTTP_PUBLIC", v)]), &cfg()).expect("ok");
376 assert!(env.mgmt_http_public, "{v} should be truthy");
377 }
378 }
379
380 #[test]
381 fn env_mgmt_http_public_falsy_values() {
382 for v in ["", "0", "false", "no", "off"] {
383 let env =
384 Env::from_reader(&FakeEnv::with(&[("VANE_MGMT_HTTP_PUBLIC", v)]), &cfg()).expect("ok");
385 assert!(!env.mgmt_http_public, "{v} should be falsy");
386 }
387 }
388
389 #[test]
390 fn env_mgmt_http_token_empty_string_yields_none() {
391 let env =
392 Env::from_reader(&FakeEnv::with(&[("VANE_MGMT_HTTP_TOKEN", "")]), &cfg()).expect("ok");
393 assert!(env.mgmt_http_token.is_none());
394 }
395
396 #[test]
397 fn env_mgmt_http_token_value_passes_through() {
398 let env =
399 Env::from_reader(&FakeEnv::with(&[("VANE_MGMT_HTTP_TOKEN", "hunter2")]), &cfg()).expect("ok");
400 assert_eq!(env.mgmt_http_token.as_deref(), Some("hunter2"));
401 }
402
403 #[test]
404 fn env_mgmt_unix_default_path() {
405 let env = Env::from_reader(&FakeEnv::empty(), &cfg()).expect("defaults");
406 assert_eq!(env.mgmt_unix, PathBuf::from("/run/vaned.sock"));
407 }
408
409 #[test]
410 fn env_mgmt_unix_override() {
411 let env = Env::from_reader(&FakeEnv::with(&[("VANE_MGMT_UNIX", "/run/vane.sock")]), &cfg())
412 .expect("ok");
413 assert_eq!(env.mgmt_unix, PathBuf::from("/run/vane.sock"));
414 }
415
416 #[test]
417 fn env_log_level_passes_through_verbatim() {
418 for level in ["debug", "warn", "trace", "vane=info,hyper=warn"] {
419 let env = Env::from_reader(&FakeEnv::with(&[("VANE_LOG_LEVEL", level)]), &cfg()).expect("ok");
420 assert_eq!(env.log_level, level);
421 }
422 }
423
424 #[test]
425 fn env_wasm_dir_defaults_to_clap_config_dir_subdir() {
426 let env = Env::from_reader(&FakeEnv::empty(), &cfg()).expect("defaults");
427 assert_eq!(env.wasm_dir, PathBuf::from("/etc/vaned/wasm"));
428
429 let env = Env::from_reader(&FakeEnv::empty(), &PathBuf::from("/srv/vane/etc"))
430 .expect("custom config_dir");
431 assert_eq!(
432 env.wasm_dir,
433 PathBuf::from("/srv/vane/etc/wasm"),
434 "default tracks the supplied config_dir",
435 );
436 }
437
438 #[test]
439 fn env_wasm_dir_explicit_override_wins() {
440 let env =
441 Env::from_reader(&FakeEnv::with(&[("VANE_WASM_DIR", "/var/lib/vane/plugins")]), &cfg())
442 .expect("override");
443 assert_eq!(env.wasm_dir, PathBuf::from("/var/lib/vane/plugins"));
444 }
445}