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
16pub 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
33pub 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
130pub 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
146fn 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
162pub 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
234pub 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
242pub 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
248pub 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
277pub fn nixos_iso_version() -> String {
279 channel_release(&nixos_channel()).to_string()
280}
281
282pub fn nixos_channel() -> String {
284 static CHANNEL: OnceLock<String> = OnceLock::new();
285 CHANNEL
286 .get_or_init(|| {
287 if let Some(version) = parse_nixpkgs_version_from_flake() {
289 return format!("nixos-{}", version);
290 }
291
292 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 FALLBACK_TARGET_NIXOS_CHANNEL.to_string()
315 })
316 .clone()
317}
318
319pub 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
326pub 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
335pub fn proxmox_default_iso_storage() -> String {
337 String::new()
338}
339
340pub fn proxmox_default_disk_storage() -> String {
342 String::new()
343}
344
345pub 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
371pub 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#[derive(Clone, Debug)]
403pub struct RuntimeOptions {
404 pub debug: bool,
405 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 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)] mod 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 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 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 #[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 #[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}