Skip to main content

nxd_core/
config.rs

1use std::path::{Path, PathBuf};
2use std::sync::OnceLock;
3
4pub const DEFAULT_SECRETS_REPO: &str = "secrets";
5pub const DEFAULT_SECRETS_SITE_ENV: &str = "DEFAULT_SECRETS_SITE";
6pub const DEFAULT_BUILDER: &str = "";
7
8pub const DEFAULT_NIX_CFG: &str = "nxd-source";
9pub const APP_NAME: &str = "nxd";
10pub const SECRET_INPUT_NAME: &str = "installer-secret";
11pub const CHECKOUT_EXCLUDES: &[&str] =
12	&[".git", "result", ".DS_Store", "target", "apps/nxd/target", "secrets"];
13
14pub const FALLBACK_TARGET_NIXOS_CHANNEL: &str = "nixos-26.05";
15
16// Note: We deliberately pin the default kexec installer channel to a stable, older release
17// (nixos-25.05) rather than matching the target NixOS version (26.05).
18// Newer kexec kernels/initrds can fail to load on older target hosts (e.g. Ubuntu VMs)
19// due to compatibility issues with host-level `kexec-tools`.
20pub const PRIMARY_KEXEC_INSTALLER_CHANNEL: &str = "nixos-25.05";
21
22pub struct HostSopsSource {
23	pub path: PathBuf,
24	pub description: String,
25}
26
27fn is_valid_site(site: &str) -> bool {
28	!site.is_empty()
29		&& site.len() <= 64
30		&& site.bytes().all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_'))
31}
32
33/// Site prefix holding `hostname`'s secrets.
34///
35/// `DEFAULT_SECRETS_SITE` is per-invocation, so it cannot describe a consumer
36/// whose hosts span several sites. When it does not hold the host, discover the
37/// site from the repository layout instead. Ambiguity is refused rather than
38/// guessed: picking the wrong site writes a second identity for a host that
39/// already has one.
40pub fn resolve_secrets_site(hostname: &str) -> Result<String, String> {
41	let repo = get_secrets_repo();
42	let configured = get_runtime_options().secrets_site.clone().unwrap_or_default();
43	if is_valid_site(&configured) && repo.join(&configured).join("hosts").join(hostname).is_dir() {
44		return Ok(configured);
45	}
46
47	let mut found: Vec<String> = std::fs::read_dir(&repo)
48		.map_err(|error| format!("cannot read secrets repository {}: {error}", repo.display()))?
49		.filter_map(Result::ok)
50		.filter_map(|entry| entry.file_name().into_string().ok())
51		.filter(|name| is_valid_site(name))
52		.filter(|name| repo.join(name).join("hosts").join(hostname).is_dir())
53		.collect();
54	found.sort();
55
56	match found.as_slice() {
57		[site] => Ok(site.clone()),
58		[] if is_valid_site(&configured) => Ok(configured),
59		[] => Err(format!("no secrets site in {} holds host {hostname}", repo.display())),
60		sites => Err(format!(
61			"host {hostname} exists under several secrets sites ({}); \
62			 set DEFAULT_SECRETS_SITE or remove the duplicates",
63			sites.join(", ")
64		)),
65	}
66}
67
68pub fn host_sops_path(hostname: &str) -> PathBuf {
69	let site = resolve_secrets_site(hostname).unwrap_or_default();
70	get_secrets_repo().join(site).join("hosts").join(hostname).join(format!("{}.yaml", hostname))
71}
72
73#[derive(serde::Deserialize, Debug, Default, Clone)]
74struct Defines {
75	#[serde(rename = "myRepoName")]
76	my_repo_name: String,
77	#[serde(rename = "mySshAuthKey")]
78	my_ssh_auth_key: String,
79	#[serde(rename = "defaultNetworks")]
80	default_networks: Vec<String>,
81}
82
83fn load_defines() -> &'static Defines {
84	static DEFINES: OnceLock<Defines> = OnceLock::new();
85	DEFINES.get_or_init(|| {
86		let defines_path = if let Some(dir) = get_flake_dir() {
87			dir.join("defines.nix").to_string_lossy().to_string()
88		} else {
89			"./defines.nix".to_string()
90		};
91
92		if !std::path::Path::new(&defines_path).exists() {
93			return Defines::default();
94		}
95
96		let expr = format!(
97			r#"
98            let
99              defs = import {};
100            in
101            {{
102              myRepoName = defs.myRepoName or "";
103              mySshAuthKey = defs.mySshAuthKey or "";
104              defaultNetworks = defs.defaultNetworks or [];
105            }}
106            "#,
107			defines_path
108		);
109
110		let output = crate::process::sanitized_command("nix")
111			.args(["eval", "--json", "--impure", "--expr", &expr])
112			.output();
113		match output {
114			Ok(out) if out.status.success() => serde_json::from_slice(&out.stdout).unwrap_or_default(),
115			_ => Defines::default(),
116		}
117	})
118}
119
120fn define_value(attr: &str) -> Option<String> {
121	let definitions = load_defines();
122	let value = match attr {
123		"myRepoName" => &definitions.my_repo_name,
124		"mySshAuthKey" => &definitions.my_ssh_auth_key,
125		_ => return None,
126	};
127	(!value.is_empty()).then(|| value.clone())
128}
129
130/// Resolves the secrets repository absolute path.
131pub fn get_secrets_repo() -> PathBuf {
132	let opts = get_runtime_options();
133	if let Some(ref secrets_ref) = opts.secrets_repo
134		&& !secrets_ref.is_empty()
135	{
136		return Path::new(secrets_ref).to_path_buf();
137	}
138
139	if let Ok(repo) = std::env::var("DEFAULT_SECRETS_REPO") {
140		Path::new(&repo).to_path_buf()
141	} else {
142		PathBuf::from(DEFAULT_SECRETS_REPO)
143	}
144}
145
146/// Walks up from `start` looking for a `flake.nix`, returning the first
147/// directory that contains one. Returns `None` if the filesystem root is
148/// reached without finding one.
149fn find_flake_root(start: &std::path::Path) -> Option<std::path::PathBuf> {
150	let mut dir = start;
151	loop {
152		if dir.join("flake.nix").exists() {
153			return Some(dir.to_path_buf());
154		}
155		match dir.parent() {
156			Some(parent) => dir = parent,
157			None => return None,
158		}
159	}
160}
161
162/// Resolves the configuration flake directory path.
163pub fn get_flake_dir() -> Option<PathBuf> {
164	let opts = get_runtime_options();
165	if let Some(ref flake_ref) = opts.flake
166		&& !flake_ref.is_empty()
167		&& let Some(local_path) = local_flake_path(flake_ref)
168		&& local_path.exists()
169	{
170		return Some(local_path);
171	}
172
173	if let Ok(repo) = std::env::var("DEFAULT_FLAKE_REPO") {
174		Some(PathBuf::from(repo))
175	} else if let Ok(cwd) = std::env::current_dir()
176		&& let Some(root) = find_flake_root(&cwd)
177	{
178		Some(root)
179	} else {
180		None
181	}
182}
183
184pub fn resolve_flake(flake_arg: Option<&str>) -> String {
185	match flake_arg {
186		Some(val) if !val.is_empty() => val.to_string(),
187		_ => {
188			if let Some(root) = get_flake_dir() {
189				format!("path:{}", root.display())
190			} else {
191				"path:.".to_string()
192			}
193		}
194	}
195}
196
197pub fn flake_uri() -> String {
198	let opts = get_runtime_options();
199	resolve_flake(opts.flake.as_deref())
200}
201
202pub fn is_local_flake(flake_ref: &str) -> bool {
203	flake_ref.starts_with("path:")
204		|| flake_ref.starts_with("git+file:")
205		|| flake_ref == "."
206		|| flake_ref == ".."
207		|| flake_ref.starts_with("./")
208		|| flake_ref.starts_with("../")
209		|| flake_ref.starts_with('/')
210}
211
212pub fn local_flake_path(flake_ref: &str) -> Option<PathBuf> {
213	let path = if let Some(path) = flake_ref.strip_prefix("path:") {
214		path
215	} else if let Some(path) = flake_ref.strip_prefix("git+file:") {
216		path.strip_prefix("//").unwrap_or(path).split('?').next().unwrap_or(path)
217	} else if is_local_flake(flake_ref) {
218		flake_ref
219	} else {
220		return None;
221	};
222	Some(PathBuf::from(path))
223}
224
225pub fn resolve_host_sops_source(hostname: &str) -> Option<HostSopsSource> {
226	resolve_secrets_site(hostname).ok()?;
227	let path = host_sops_path(hostname);
228	path.exists().then(|| HostSopsSource {
229		description: format!("Using configured host secrets: {}", path.display()),
230		path,
231	})
232}
233
234/// Get the configuration repository name.
235pub fn nix_cfg() -> String {
236	static VALUE: OnceLock<String> = OnceLock::new();
237	VALUE
238		.get_or_init(|| define_value("myRepoName").unwrap_or_else(|| DEFAULT_NIX_CFG.to_string()))
239		.clone()
240}
241
242/// Get the SSH authorized key.
243pub fn ssh_auth_key() -> String {
244	static VALUE: OnceLock<String> = OnceLock::new();
245	VALUE.get_or_init(|| define_value("mySshAuthKey").unwrap_or_default()).clone()
246}
247
248/// Retrieve approved scan subnets from defines.nix.
249pub fn default_networks() -> Vec<String> {
250	load_defines().default_networks.clone()
251}
252
253fn parse_nixpkgs_version_from_flake() -> Option<String> {
254	let flake_dir = get_flake_dir()?;
255	let flake_path = flake_dir.join("flake.nix");
256	if let Ok(content) = std::fs::read_to_string(&flake_path) {
257		for line in content.lines() {
258			if let Some(pos) = line.find("github:nixos/nixpkgs/nixos-") {
259				let start = pos + "github:nixos/nixpkgs/nixos-".len();
260				let end_chars = ['"', '\'', ';', ' ', '\t'];
261				if let Some(end_offset) = line[start..].find(|c| end_chars.contains(&c)) {
262					let version = line[start..start + end_offset].trim().to_string();
263					if !version.is_empty() {
264						return Some(version);
265					}
266				}
267			}
268		}
269	}
270	None
271}
272
273fn channel_release(channel: &str) -> &str {
274	channel.strip_prefix("nixos-").unwrap_or(channel)
275}
276
277/// Get the stable NixOS release used in generated ISO artifact names.
278pub fn nixos_iso_version() -> String {
279	channel_release(&nixos_channel()).to_string()
280}
281
282/// Get the NixOS Channel dynamically by querying the flake metadata.
283pub fn nixos_channel() -> String {
284	static CHANNEL: OnceLock<String> = OnceLock::new();
285	CHANNEL
286		.get_or_init(|| {
287			// 1. Try local parser first
288			if let Some(version) = parse_nixpkgs_version_from_flake() {
289				return format!("nixos-{}", version);
290			}
291
292			// 2. Fall back to nix eval --offline
293			let flake_ref = if let Some(local_path) = local_flake_path(&flake_uri()) {
294				format!(
295					"{}#nixosConfigurations.minimal-iso-x86.config.system.nixos.release",
296					local_path.display()
297				)
298			} else {
299				".#nixosConfigurations.minimal-iso-x86.config.system.nixos.release".to_string()
300			};
301			let output = crate::process::sanitized_command("nix")
302				.args(["eval", &flake_ref, "--raw", "--offline"])
303				.output();
304			if let Ok(out) = output
305				&& out.status.success()
306			{
307				let release = String::from_utf8_lossy(&out.stdout).trim().to_string();
308				if !release.is_empty() {
309					return format!("nixos-{}", release);
310				}
311			}
312
313			// 3. Fall back to hardcoded default
314			FALLBACK_TARGET_NIXOS_CHANNEL.to_string()
315		})
316		.clone()
317}
318
319/// Get the NixOS Channel for the kexec installer, prioritizing environment override.
320pub fn kexec_channel() -> String {
321	std::env::var("DEFAULT_KEXEC_INSTALLER_CHANNEL")
322		.or_else(|_| std::env::var("DEFAULT_KEXEC_CHANNEL"))
323		.unwrap_or_else(|_| PRIMARY_KEXEC_INSTALLER_CHANNEL.to_string())
324}
325
326/// Dynamically construct the kexec image download URL based on the configured kexec channel.
327pub fn kexec_url(arch: &str) -> String {
328	let channel = kexec_channel();
329	format!(
330		"https://github.com/nix-community/nixos-images/releases/download/{}/nixos-kexec-installer-noninteractive-{}-linux.tar.gz",
331		channel, arch
332	)
333}
334
335/// Get the default Proxmox ISO storage pool
336pub fn proxmox_default_iso_storage() -> String {
337	String::new()
338}
339
340/// Get the default Proxmox disk storage pool
341pub fn proxmox_default_disk_storage() -> String {
342	String::new()
343}
344
345/// Get the default Proxmox network net0 configuration
346pub fn proxmox_default_network() -> String {
347	String::new()
348}
349
350pub fn local_cache_config() -> Result<Option<(String, String)>, Box<dyn std::error::Error>> {
351	let url = std::env::var("NXD_LOCAL_CACHE_URL").unwrap_or_default();
352	let public_key = std::env::var("NXD_LOCAL_CACHE_PUBLIC_KEY").unwrap_or_default();
353	if url.is_empty() && public_key.is_empty() {
354		return Ok(None);
355	}
356	if url.is_empty() || public_key.is_empty() {
357		return Err(
358			"Local cache requires both NXD_LOCAL_CACHE_URL and NXD_LOCAL_CACHE_PUBLIC_KEY".into(),
359		);
360	}
361	if !url.starts_with("https://")
362		|| url.chars().any(char::is_whitespace)
363		|| public_key.chars().any(char::is_whitespace)
364		|| !public_key.contains(':')
365	{
366		return Err("Local cache URL or public key is malformed".into());
367	}
368	Ok(Some((url, public_key)))
369}
370
371/// Finds a custom own-built NixOS ISO in the current workspace.
372pub fn find_custom_iso(flavor: &str) -> Option<PathBuf> {
373	let workspace_flavor = match flavor {
374		"x86_64" => "x86",
375		"qemu" => "x86",
376		other => other,
377	};
378	let result_link = format!("result-iso-{}", workspace_flavor);
379	let result_path = Path::new(&result_link).join("iso");
380	if result_path.exists()
381		&& let Ok(entries) = std::fs::read_dir(result_path)
382	{
383		for entry in entries.flatten() {
384			let path = entry.path();
385			if path.is_file()
386				&& let Some(ext) = path.extension().and_then(|e| e.to_str())
387				&& ext == "iso"
388			{
389				return Some(path);
390			}
391		}
392	}
393	None
394}
395
396/// Process-level operator options injected once by the CLI composition root.
397///
398/// Production code must not invent values from scattered environment reads for
399/// these fields. The only writer outside tests is `nxd-cli` `main` via
400/// [`set_runtime_options`]. Prefer threading values on `RuntimeContext` when
401/// adding new call paths.
402#[derive(Clone, Debug)]
403pub struct RuntimeOptions {
404	pub debug: bool,
405	/// Request host plane enrollment replace on switch (operator surface: --reenroll).
406	pub reenroll: bool,
407	pub low_mem: Option<bool>,
408	pub builder: Option<String>,
409	pub flake: Option<String>,
410	pub secrets_repo: Option<String>,
411	pub secrets_site: Option<String>,
412	/// Max concurrent independent deployment-target apply workers (and legacy batch ops).
413	pub parallel: usize,
414	pub reactivate: bool,
415}
416
417impl Default for RuntimeOptions {
418	fn default() -> Self {
419		Self {
420			debug: false,
421			reenroll: false,
422			low_mem: None,
423			builder: None,
424			flake: None,
425			secrets_repo: None,
426			secrets_site: std::env::var(DEFAULT_SECRETS_SITE_ENV).ok(),
427			parallel: 5,
428			reactivate: false,
429		}
430	}
431}
432
433#[cfg(not(test))]
434static RUNTIME_OPTIONS: OnceLock<RuntimeOptions> = OnceLock::new();
435
436#[cfg(not(test))]
437pub fn set_runtime_options(opts: RuntimeOptions) {
438	let _ = RUNTIME_OPTIONS.set(opts);
439}
440
441#[cfg(not(test))]
442pub fn get_runtime_options() -> &'static RuntimeOptions {
443	RUNTIME_OPTIONS.get_or_init(RuntimeOptions::default)
444}
445
446#[cfg(test)]
447thread_local! {
448		static TEST_RUNTIME_OPTIONS: std::cell::RefCell<Option<&'static RuntimeOptions>> =
449				const { std::cell::RefCell::new(None) };
450}
451
452#[cfg(test)]
453pub fn set_runtime_options(opts: RuntimeOptions) {
454	let leaked = Box::leak(Box::new(opts));
455	TEST_RUNTIME_OPTIONS.with(|cell| {
456		*cell.borrow_mut() = Some(leaked);
457	});
458}
459
460#[cfg(test)]
461pub fn get_runtime_options() -> &'static RuntimeOptions {
462	TEST_RUNTIME_OPTIONS.with(|cell| {
463		if let Some(opts) = *cell.borrow() {
464			opts
465		} else {
466			static DEFAULT_OPTS: OnceLock<RuntimeOptions> = OnceLock::new();
467			DEFAULT_OPTS.get_or_init(RuntimeOptions::default)
468		}
469	})
470}
471
472pub fn nix_token_args() -> Vec<String> {
473	Vec::new()
474}
475
476pub fn redact_token(input: &str) -> String {
477	crate::progress::redaction::redact_sensitive_text(input)
478}
479
480#[cfg(test)]
481#[allow(unsafe_code)] // Environment override tests are serialized by ENV_MUTEX.
482mod tests {
483	use super::*;
484
485	static ENV_MUTEX: std::sync::Mutex<()> = std::sync::Mutex::new(());
486
487	#[test]
488	fn nxd_never_constructs_secret_bearing_nix_arguments() {
489		assert!(nix_token_args().is_empty());
490	}
491
492	#[test]
493	fn test_env_var_overrides() {
494		let _guard = ENV_MUTEX.lock().unwrap();
495		unsafe {
496			std::env::set_var("DEFAULT_SECRETS_REPO", "/tmp/mock-secrets");
497		}
498		assert_eq!(get_secrets_repo(), PathBuf::from("/tmp/mock-secrets"));
499		unsafe {
500			std::env::remove_var("DEFAULT_SECRETS_REPO");
501		}
502
503		unsafe {
504			std::env::set_var("DEFAULT_FLAKE_REPO", "/tmp/mock-flake");
505		}
506		assert_eq!(get_flake_dir(), Some(PathBuf::from("/tmp/mock-flake")));
507		unsafe {
508			std::env::remove_var("DEFAULT_FLAKE_REPO");
509		}
510	}
511
512	#[test]
513	fn test_parse_nixpkgs_version() {
514		let _guard = ENV_MUTEX.lock().unwrap();
515		let temp_dir = std::env::temp_dir().join("nxd-test-flake");
516		std::fs::create_dir_all(&temp_dir).unwrap();
517		let flake_path = temp_dir.join("flake.nix");
518		std::fs::write(
519			&flake_path,
520			r#"{
521  inputs = {
522    nixpkgs.url = "github:nixos/nixpkgs/nixos-26.05";
523  };
524}"#,
525		)
526		.unwrap();
527
528		unsafe {
529			std::env::set_var("DEFAULT_FLAKE_REPO", temp_dir.to_str().unwrap());
530		}
531		assert_eq!(parse_nixpkgs_version_from_flake(), Some("26.05".to_string()));
532
533		// Test with single quotes and different spaces
534		std::fs::write(
535			&flake_path,
536			r#"{
537  inputs = {
538    nixpkgs.url = 'github:nixos/nixpkgs/nixos-27.05';
539  };
540}"#,
541		)
542		.unwrap();
543		assert_eq!(parse_nixpkgs_version_from_flake(), Some("27.05".to_string()));
544
545		unsafe {
546			std::env::remove_var("DEFAULT_FLAKE_REPO");
547		}
548		let _ = std::fs::remove_dir_all(&temp_dir);
549	}
550
551	#[test]
552	fn test_channel_release_for_iso_artifact_names() {
553		assert_eq!(channel_release("nixos-26.05"), "26.05");
554		assert_eq!(channel_release("26.05"), "26.05");
555	}
556
557	/// Builds a secrets repo containing `<site>/hosts/<host>/` for each pair.
558	fn secrets_repo_fixture(layout: &[(&str, &str)]) -> PathBuf {
559		let root = std::env::temp_dir().join(format!(
560			"nxd-site-{}-{:?}",
561			std::process::id(),
562			std::thread::current().id()
563		));
564		let _ = std::fs::remove_dir_all(&root);
565		for (site, host) in layout {
566			std::fs::create_dir_all(root.join(site).join("hosts").join(host)).unwrap();
567		}
568		root
569	}
570
571	#[test]
572	fn secrets_site_is_discovered_when_the_configured_one_lacks_the_host() {
573		let _guard = ENV_MUTEX.lock().unwrap();
574		let repo = secrets_repo_fixture(&[("bar", "medo"), ("fcm", "fcmbuilder")]);
575		set_runtime_options(RuntimeOptions {
576			secrets_repo: Some(repo.to_string_lossy().into_owned()),
577			secrets_site: Some("bar".to_string()),
578			..Default::default()
579		});
580
581		assert_eq!(resolve_secrets_site("medo").unwrap(), "bar");
582		assert_eq!(resolve_secrets_site("fcmbuilder").unwrap(), "fcm");
583
584		set_runtime_options(RuntimeOptions::default());
585		let _ = std::fs::remove_dir_all(&repo);
586	}
587
588	/// Guessing here would write a second identity for a host that already has
589	/// one, so an ambiguous layout must refuse.
590	#[test]
591	fn a_host_under_several_sites_is_refused() {
592		let _guard = ENV_MUTEX.lock().unwrap();
593		let repo = secrets_repo_fixture(&[("bar", "fcmbuilder"), ("fcm", "fcmbuilder")]);
594		set_runtime_options(RuntimeOptions {
595			secrets_repo: Some(repo.to_string_lossy().into_owned()),
596			secrets_site: Some("nonexistent".to_string()),
597			..Default::default()
598		});
599
600		let error = resolve_secrets_site("fcmbuilder").unwrap_err();
601		assert!(error.contains("bar, fcm"), "{error}");
602
603		set_runtime_options(RuntimeOptions::default());
604		let _ = std::fs::remove_dir_all(&repo);
605	}
606
607	/// A configured site that holds the host wins outright, so an operator can
608	/// still disambiguate the case above.
609	#[test]
610	fn the_configured_site_wins_when_it_holds_the_host() {
611		let _guard = ENV_MUTEX.lock().unwrap();
612		let repo = secrets_repo_fixture(&[("bar", "fcmbuilder"), ("fcm", "fcmbuilder")]);
613		set_runtime_options(RuntimeOptions {
614			secrets_repo: Some(repo.to_string_lossy().into_owned()),
615			secrets_site: Some("fcm".to_string()),
616			..Default::default()
617		});
618
619		assert_eq!(resolve_secrets_site("fcmbuilder").unwrap(), "fcm");
620
621		set_runtime_options(RuntimeOptions::default());
622		let _ = std::fs::remove_dir_all(&repo);
623	}
624
625	#[test]
626	fn test_secrets_repo_cli_override() {
627		set_runtime_options(RuntimeOptions {
628			secrets_repo: Some("/tmp/cli-mock-secrets".to_string()),
629			..Default::default()
630		});
631		assert_eq!(get_secrets_repo(), PathBuf::from("/tmp/cli-mock-secrets"));
632
633		set_runtime_options(RuntimeOptions::default());
634	}
635}