Skip to main content

vane_core/config/
env.rs

1//! Typed accessors for `VANE_*` deployment-constant env vars
2//! (`spec/crates/core.md` § _Config layers_).
3//!
4//! The [`EnvReader`] trait abstracts the source so unit tests pass a
5//! `HashMap`-backed fake instead of mutating process-global state — Rust
6//! 1.95 marks `std::env::set_var` `unsafe` due to multi-thread races.
7
8use std::path::{Path, PathBuf};
9
10use crate::error::Error;
11
12/// Reads a key → optional string value. The single production
13/// implementation, [`ProcessEnv`], delegates to `std::env::var`. Tests
14/// hand-roll a fake `EnvReader` to keep state local.
15pub trait EnvReader {
16	fn get(&self, key: &str) -> Option<String>;
17}
18
19/// Production [`EnvReader`] — reads from `std::env`.
20pub 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/// Typed snapshot of every `VANE_*` deployment constant the daemon
29/// reads at startup. Defaults match `spec/crates/core.md`
30/// § _Config layers_.
31///
32/// `config_dir` is **not** modeled as a field — the daemon's `--config`
33/// CLI arg is the single source of truth, and [`Env::from_reader`]
34/// takes that path explicitly so derived defaults (`wasm_dir`) follow
35/// it without an extra env var to keep in sync.
36#[derive(Debug, Clone, PartialEq, Eq)]
37#[allow(clippy::struct_excessive_bools)] // each bool maps 1:1 to a documented env var; collapsing them into bitflags would obscure the surface.
38pub struct Env {
39	/// `VANE_WASM_DIR` — WASM plugin source directory scanned at boot.
40	/// Defaults to `<config_dir>/wasm` where `config_dir` is the
41	/// daemon's `--config` argument. See
42	/// `spec/crates/engine-wasm.md` § _Module lifecycle_.
43	pub wasm_dir: PathBuf,
44	/// `VANE_LOG_LEVEL` — `tracing-subscriber` filter directive
45	/// (default `"info"`). Honors the same syntax as `RUST_LOG`
46	/// (per-target overrides like `vane=debug,hyper=warn`). The
47	/// process env `RUST_LOG`, when set, takes precedence so
48	/// operators can override the file value ad-hoc.
49	pub log_level: String,
50	/// `VANE_BIND_IPV4` — listen on 0.0.0.0 for `:N` listen specs (default `true`).
51	pub bind_ipv4: bool,
52	/// `VANE_BIND_IPV6` — listen on `[::]` for `:N` listen specs (default `true`).
53	pub bind_ipv6: bool,
54	/// `VANE_SEC_MAX_HEADER_BYTES` — request-header size cap (default 65536).
55	pub sec_max_header_bytes: u32,
56	/// `VANE_SEC_MAX_HEADERS_COUNT` — request-header count cap (default 100).
57	pub sec_max_headers_count: u32,
58	/// `VANE_SEC_HEADER_TIMEOUT` — header-completion timeout, seconds (default 30).
59	pub sec_header_timeout_secs: u32,
60	/// `VANE_SEC_MAX_CONN_PER_IP` — per-IP concurrent-connection cap (default 100).
61	pub sec_max_conn_per_ip: u32,
62	/// `VANE_SEC_MAX_TOTAL_CONNS` — daemon-wide concurrent-connection cap (default 65536).
63	pub sec_max_total_conns: u32,
64	/// `VANE_BIND_MAX_ATTEMPTS` — bind-retry count per listener address (default 10).
65	pub bind_max_attempts: u32,
66	/// `VANE_BIND_BACKOFF_INITIAL_MS` — initial retry backoff in milliseconds (default 100).
67	pub bind_backoff_initial_ms: u32,
68	/// `VANE_BIND_BACKOFF_MAX_MS` — retry backoff cap in milliseconds (default 5000).
69	pub bind_backoff_max_ms: u32,
70	/// `VANE_FORCE_CANCEL_GRACE_SECS` — secondary grace window after `force_cancel` fires,
71	/// seconds (default 5). Applies to both SIGTERM drain and removed-listener reconcile.
72	pub force_cancel_grace_secs: u32,
73	/// `VANE_DRAIN_TIMEOUT_SECS` — in-flight connection drain budget for reload and SIGTERM,
74	/// seconds (default 30).
75	pub drain_timeout_secs: u32,
76	/// `VANE_BOOT_HEALTH_TIMEOUT_SECS` — budget for all listeners to flip `bind_ready`,
77	/// seconds (default 60). Partial bind (some bound, some failed) stays a warn.
78	pub boot_health_timeout_secs: u32,
79	/// `VANE_MGMT_UNIX` — management Unix socket path. Defaults to
80	/// `$XDG_RUNTIME_DIR/vaned.sock` when that env var is set, then to
81	/// `/run/vaned.sock`. `/tmp/...` is intentionally not the default:
82	/// it's world-writable and survives reboots, both of which make it
83	/// the wrong place for a privileged control socket.
84	pub mgmt_unix: PathBuf,
85	/// `VANE_MGMT_HTTP_PORT` — TCP port for the HTTP management transport.
86	/// `Some(3333)` by default; an explicit empty string disables the
87	/// transport (`None`). Matches `spec/crates/core.md`
88	/// § _Config layers_.
89	pub mgmt_http_port: Option<u16>,
90	/// `VANE_MGMT_HTTP_PUBLIC` — when truthy, bind the HTTP management
91	/// port on the wildcard address (`0.0.0.0` / `[::]`). When falsy
92	/// (default), bind on loopback. Mandatory pairing with
93	/// `mgmt_http_token` is enforced at daemon boot, not here.
94	pub mgmt_http_public: bool,
95	/// `VANE_MGMT_HTTP_TOKEN` — bearer token for the HTTP management
96	/// transport (`None` when unset or empty string).
97	pub mgmt_http_token: Option<String>,
98	/// `VANE_NATIVE_ROOTS_REFRESH_INTERVAL_SECS` — cadence at which
99	/// the daemon re-reads the OS native trust store, in seconds
100	/// (default 21 600 = 6h). The refresh is non-blocking; failures
101	/// preserve the previous snapshot and emit a warn. Operators who
102	/// want a one-shot refresh use the `reload_native_roots` mgmt
103	/// verb instead.
104	pub native_roots_refresh_interval_secs: u32,
105	/// `VANE_ALLOW_INSECURE_UPSTREAM` — master gate for the
106	/// per-upstream `tls.insecure_skip_verify: true` knob. Falsy
107	/// (default) makes the parser reject any config that sets the
108	/// flag, so an accidental `insecure_skip_verify: true` left in a
109	/// production rules file fails the reload instead of silently
110	/// disabling cert verification. Truthy values authorise the
111	/// per-upstream override; the per-upstream flag still has to be
112	/// set explicitly — the env var alone never weakens verification.
113	pub allow_insecure_upstream: bool,
114}
115
116impl Env {
117	/// Read from the actual process environment.
118	///
119	/// `config_dir` is the daemon's resolved `--config` path; it is
120	/// the basis for `wasm_dir`'s default when `VANE_WASM_DIR` is unset.
121	///
122	/// # Errors
123	/// Returns [`Error::compile`] when any `VANE_*` value fails its
124	/// type-specific parse (bool, u32, port).
125	pub fn from_process_env(config_dir: &Path) -> Result<Self, Error> {
126		Self::from_reader(&ProcessEnv, config_dir)
127	}
128
129	/// Read from any [`EnvReader`]. Primary entry point for unit tests.
130	///
131	/// # Errors
132	/// As [`Self::from_process_env`].
133	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
171/// Resolve the default management socket path when no `VANE_MGMT_UNIX`
172/// is set. Preference order:
173///
174/// 1. `$XDG_RUNTIME_DIR/vaned.sock` — per-user transient directory
175///    that systemd manages with 0700 perms; right place for an
176///    unprivileged daemon's control socket.
177/// 2. `/run/vaned.sock` — system-wide transient directory; right
178///    place for a privileged daemon running under PID 1.
179///
180/// `/tmp` is never the default: it's world-writable, world-readable
181/// in many distros, and survives reboots — any one of which makes
182/// it the wrong host for a control socket.
183fn 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
205/// Parse `VANE_MGMT_HTTP_PORT`. Unset → default `Some(3333)`; explicit
206/// empty string → `None` (transport disabled). Anything else parses as
207/// a `u16`.
208fn 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
219/// Boolean env-var parse used for `VANE_MGMT_HTTP_PUBLIC`. Truthy =
220/// `1` / `true` / `yes` / `on` (case-insensitive). Anything else,
221/// including unset / empty / `0` / `false` / `no` / `off`, is falsy.
222fn 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		// `/run/vaned.sock` is the no-XDG_RUNTIME_DIR fallback. `/tmp`
266		// is never the default — see `default_mgmt_unix` for rationale.
267		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		// dotenvy may write `KEY=` with no value — that should not be a
295		// hard error; treat as unset.
296		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		// u32 cannot hold negative; ensure the error path fires cleanly.
338		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}