1use std::path::Path;
8use std::sync::atomic::{AtomicU64, Ordering};
9use std::time::{Duration, SystemTime, UNIX_EPOCH};
10
11use super::SetupError;
12
13static SEED_TMP_COUNTER: AtomicU64 = AtomicU64::new(0);
14
15#[derive(Debug)]
21pub struct SeedConfig<'a> {
22 pub hostname: &'a str,
24 pub ssh_pubkey: &'a str,
26 pub nics: Vec<NicConfig>,
28 pub process: Option<ProcessConfig>,
30 pub volumes: Vec<VolumeMountConfig>,
32 pub healthcheck: Option<HealthCheckConfig>,
34 pub extra_hosts: Vec<(String, String)>,
36}
37
38#[derive(Debug, Clone)]
40pub struct NicConfig {
41 pub name: String,
43 pub ip: String,
45 pub gateway: Option<String>,
47}
48
49#[derive(Debug, Clone)]
51pub struct ProcessConfig {
52 pub command: String,
54 pub workdir: Option<String>,
56 pub env: Vec<(String, String)>,
58}
59
60#[derive(Debug, Clone)]
62pub struct VolumeMountConfig {
63 pub tag: String,
65 pub mount_point: String,
67 pub read_only: bool,
69}
70
71#[derive(Debug, Clone)]
73pub struct HealthCheckConfig {
74 pub command: String,
76 pub interval_secs: u32,
78 pub retries: u32,
80}
81
82pub fn create_seed_iso(iso_path: &Path, config: &SeedConfig<'_>) -> Result<(), SetupError> {
96 if !is_safe_hostname(config.hostname) {
97 return Err(SetupError::Config(format!(
98 "invalid hostname '{}'",
99 config.hostname
100 )));
101 }
102
103 let parent = iso_path
104 .parent()
105 .ok_or_else(|| SetupError::Config("no parent directory for ISO path".into()))?;
106 let counter = SEED_TMP_COUNTER.fetch_add(1, Ordering::Relaxed);
107 let timestamp_nanos = SystemTime::now()
108 .duration_since(UNIX_EPOCH)
109 .unwrap_or(Duration::ZERO)
110 .as_nanos();
111 let tmp_dir = parent.join(format!(
112 ".seed-{}-{}-{}-{}",
113 config.hostname,
114 std::process::id(),
115 timestamp_nanos,
116 counter
117 ));
118
119 std::fs::create_dir_all(&tmp_dir).map_err(SetupError::Io)?;
120
121 let result = (|| {
122 let meta_data = format!(
124 "instance-id: {hostname}\nlocal-hostname: {hostname}\n",
125 hostname = config.hostname
126 );
127 std::fs::write(tmp_dir.join("meta-data"), &meta_data).map_err(SetupError::Io)?;
128
129 let user_data = build_user_data(config)?;
131 std::fs::write(tmp_dir.join("user-data"), &user_data).map_err(SetupError::Io)?;
132
133 if !config.nics.is_empty() {
135 let network_config = build_network_config(config)?;
136 std::fs::write(tmp_dir.join("network-config"), &network_config)
137 .map_err(SetupError::Io)?;
138 }
139
140 create_iso_image(iso_path, &tmp_dir)
142 })();
143
144 if let Err(e) = std::fs::remove_dir_all(&tmp_dir) {
145 tracing::warn!(
146 path = %tmp_dir.display(),
147 "failed to clean up seed ISO temp dir: {}",
148 e
149 );
150 }
151
152 result
153}
154
155fn build_user_data(config: &SeedConfig<'_>) -> Result<String, SetupError> {
160 validate_ssh_pubkey(config.ssh_pubkey)?;
161 let mut ud = String::from("#cloud-config\n");
162 ud.push_str("ssh_authorized_keys:\n");
163 ud.push_str(&format!(" - {}\n", config.ssh_pubkey));
164 ud.push_str("disable_root: false\n");
165 ud.push_str("runcmd:\n");
166
167 for vol in &config.volumes {
169 if !is_safe_mount_tag(&vol.tag) {
170 return Err(SetupError::Config(format!(
171 "invalid VirtioFS tag '{}'",
172 vol.tag
173 )));
174 }
175 if !is_safe_mount_point(&vol.mount_point) {
176 return Err(SetupError::Config(format!(
177 "invalid mount point '{}'",
178 vol.mount_point
179 )));
180 }
181 ud.push_str(&format!(
182 " - mkdir -p {mount} && mount -t virtiofs {tag} {mount}{ro}\n",
183 mount = shell_quote(&vol.mount_point),
184 tag = shell_quote(&vol.tag),
185 ro = if vol.read_only { " -o ro" } else { "" },
186 ));
187 }
188
189 for (host, ip) in &config.extra_hosts {
191 if !is_safe_hostname(host) || !is_safe_ip(ip) {
192 return Err(SetupError::Config(format!(
193 "unsafe /etc/hosts entry: {} {}",
194 ip, host
195 )));
196 }
197 ud.push_str(&format!(" - echo '{} {}' >> /etc/hosts\n", ip, host));
198 }
199
200 if let Some(ref proc) = config.process {
202 if !is_safe_shell_fragment(&proc.command) {
203 return Err(SetupError::Config(
204 "process command contains unsafe control characters".into(),
205 ));
206 }
207 for (k, v) in &proc.env {
208 if !is_safe_env_name(k) {
209 return Err(SetupError::Config(format!(
210 "unsafe environment variable name '{}'",
211 k
212 )));
213 }
214 ud.push_str(&format!(" - export {}={}\n", k, shell_quote(v)));
215 }
216 if let Some(ref wd) = proc.workdir {
217 if !is_safe_mount_point(wd) {
218 return Err(SetupError::Config(format!(
219 "invalid working directory '{}'",
220 wd
221 )));
222 }
223 ud.push_str(&format!(
224 " - cd {} && sh -lc {}\n",
225 shell_quote(wd),
226 shell_quote(&proc.command)
227 ));
228 } else {
229 ud.push_str(&format!(" - sh -lc {}\n", shell_quote(&proc.command)));
230 }
231 }
232
233 if let Some(ref hc) = config.healthcheck {
235 if !is_safe_shell_fragment(&hc.command) {
236 return Err(SetupError::Config(
237 "healthcheck command contains unsafe control characters".into(),
238 ));
239 }
240 ud.push_str(&format!(
241 " - while true; do sh -lc {} > /tmp/vmrs-health 2>&1 && \
242 echo 'healthy' >> /tmp/vmrs-health || \
243 echo 'unhealthy' >> /tmp/vmrs-health; sleep {}; done &\n",
244 shell_quote(&hc.command),
245 hc.interval_secs
246 ));
247 }
248
249 let ip_cmd = "hostname -I | awk '{print $1}'";
251 ud.push_str(&format!(
252 " - echo \"{} $({})\"\n",
253 crate::config::READY_MARKER,
254 ip_cmd
255 ));
256
257 Ok(ud)
258}
259
260fn build_network_config(config: &SeedConfig<'_>) -> Result<String, SetupError> {
261 let mut nc = String::from("version: 2\nethernets:\n");
262 for nic in &config.nics {
263 if !is_safe_iface_name(&nic.name) {
264 return Err(SetupError::Config(format!(
265 "invalid interface name '{}'",
266 nic.name
267 )));
268 }
269 if !is_safe_cidr(&nic.ip) {
270 return Err(SetupError::Config(format!(
271 "invalid interface address '{}'",
272 nic.ip
273 )));
274 }
275 nc.push_str(&format!(" {}:\n", nic.name));
276 nc.push_str(&format!(" addresses: [{}]\n", nic.ip));
277 if let Some(ref gw) = nic.gateway {
278 if !is_safe_ip(gw) {
279 return Err(SetupError::Config(format!("invalid gateway '{}'", gw)));
280 }
281 nc.push_str(&format!(
282 " routes:\n - to: default\n via: {}\n",
283 gw
284 ));
285 }
286 }
287 Ok(nc)
288}
289
290fn create_iso_image(
291 #[cfg(any(target_os = "macos", target_os = "linux"))] iso_path: &Path,
292 #[cfg(any(target_os = "macos", target_os = "linux"))] source_dir: &Path,
293 #[cfg(not(any(target_os = "macos", target_os = "linux")))] _iso_path: &Path,
294 #[cfg(not(any(target_os = "macos", target_os = "linux")))] _source_dir: &Path,
295) -> Result<(), SetupError> {
296 #[cfg(target_os = "macos")]
297 {
298 let output = std::process::Command::new("hdiutil")
299 .args(["makehybrid", "-o"])
300 .arg(iso_path)
301 .arg(source_dir)
302 .args(["-joliet", "-iso", "-default-volume-name", "cidata"])
303 .output()
304 .map_err(SetupError::Io)?;
305 if !output.status.success() {
306 let stderr = String::from_utf8_lossy(&output.stderr);
307 return Err(SetupError::IsoCreation(format!(
308 "hdiutil makehybrid failed (exit {}): {}",
309 output.status,
310 stderr.trim()
311 )));
312 }
313 }
314
315 #[cfg(target_os = "linux")]
316 {
317 let result = std::process::Command::new("genisoimage")
319 .args(["-output"])
320 .arg(iso_path)
321 .args(["-volid", "cidata", "-joliet", "-rock"])
322 .arg(source_dir)
323 .output();
324
325 match result {
326 Ok(ref out) if out.status.success() => {}
327 _ => {
328 let output = std::process::Command::new("mkisofs")
329 .args(["-output"])
330 .arg(iso_path)
331 .args(["-volid", "cidata", "-joliet", "-rock"])
332 .arg(source_dir)
333 .output()
334 .map_err(SetupError::Io)?;
335 if !output.status.success() {
336 let stderr = String::from_utf8_lossy(&output.stderr);
337 return Err(SetupError::IsoCreation(format!(
338 "neither genisoimage nor mkisofs succeeded. \
339 Install: apt install genisoimage. Last error: {}",
340 stderr.trim()
341 )));
342 }
343 }
344 }
345 }
346
347 Ok(())
348}
349
350fn shell_quote(s: &str) -> String {
357 let mut out = String::with_capacity(s.len() + 2);
358 out.push('\'');
359 for c in s.chars() {
360 if c == '\'' {
361 out.push_str("'\\''");
362 } else {
363 out.push(c);
364 }
365 }
366 out.push('\'');
367 out
368}
369
370fn is_safe_env_name(s: &str) -> bool {
372 !s.is_empty()
373 && s.len() <= 256
374 && s.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
375 && !s.starts_with(|c: char| c.is_ascii_digit())
376}
377
378fn is_safe_hostname(s: &str) -> bool {
380 !s.is_empty()
381 && s.len() <= 253
382 && s.chars()
383 .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '.')
384}
385
386fn is_safe_ip(s: &str) -> bool {
388 !s.is_empty()
389 && s.len() <= 45
390 && s.chars()
391 .all(|c| c.is_ascii_hexdigit() || c == '.' || c == ':')
392}
393
394fn is_safe_cidr(s: &str) -> bool {
395 let Some((ip, prefix)) = s.split_once('/') else {
396 return false;
397 };
398 if !is_safe_ip(ip) {
399 return false;
400 }
401 matches!(prefix.parse::<u8>(), Ok(bits) if bits <= 128)
402}
403
404fn is_safe_mount_tag(s: &str) -> bool {
405 !s.is_empty()
406 && s.len() <= 128
407 && s.chars()
408 .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.')
409}
410
411fn is_safe_mount_point(s: &str) -> bool {
412 s.starts_with('/')
413 && !s.contains('\0')
414 && !s.contains('\n')
415 && !s.contains('\r')
416 && s.len() <= 1024
417}
418
419fn is_safe_iface_name(s: &str) -> bool {
420 !s.is_empty()
421 && s.len() <= 15
422 && s.chars()
423 .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
424}
425
426fn is_safe_shell_fragment(s: &str) -> bool {
427 !s.contains('\0') && !s.contains('\n') && !s.contains('\r')
428}
429
430fn validate_ssh_pubkey(s: &str) -> Result<(), SetupError> {
431 if s.contains('\0') || s.contains('\n') || s.contains('\r') {
432 return Err(SetupError::Config(
433 "SSH public key must be a single line without NUL bytes".into(),
434 ));
435 }
436
437 let mut parts = s.split_whitespace();
438 let Some(key_type) = parts.next() else {
439 return Err(SetupError::Config("SSH public key is empty".into()));
440 };
441 let Some(key_material) = parts.next() else {
442 return Err(SetupError::Config(
443 "SSH public key is missing key material".into(),
444 ));
445 };
446
447 if !key_type.starts_with("ssh-") && !key_type.starts_with("ecdsa-") {
448 return Err(SetupError::Config(format!(
449 "unsupported SSH public key type '{}'",
450 key_type
451 )));
452 }
453 if key_material.is_empty() {
454 return Err(SetupError::Config(
455 "SSH public key is missing key material".into(),
456 ));
457 }
458
459 Ok(())
460}
461
462#[cfg(test)]
463mod tests {
464 use super::*;
465
466 #[test]
469 fn shell_quote_simple() {
470 assert_eq!(shell_quote("hello"), "'hello'");
471 }
472
473 #[test]
474 fn shell_quote_with_spaces() {
475 assert_eq!(shell_quote("hello world"), "'hello world'");
476 }
477
478 #[test]
479 fn shell_quote_with_single_quote() {
480 assert_eq!(shell_quote("it's"), "'it'\\''s'");
481 }
482
483 #[test]
484 fn shell_quote_with_dollar() {
485 assert_eq!(shell_quote("$(rm -rf /)"), "'$(rm -rf /)'");
487 }
488
489 #[test]
490 fn shell_quote_with_backticks() {
491 assert_eq!(shell_quote("`whoami`"), "'`whoami`'");
492 }
493
494 #[test]
495 fn shell_quote_empty() {
496 assert_eq!(shell_quote(""), "''");
497 }
498
499 #[test]
500 fn shell_quote_with_newline() {
501 assert_eq!(shell_quote("a\nb"), "'a\nb'");
502 }
503
504 #[test]
505 fn shell_quote_with_semicolon() {
506 assert_eq!(shell_quote("a; rm -rf /"), "'a; rm -rf /'");
507 }
508
509 #[test]
512 fn hostname_valid() {
513 assert!(is_safe_hostname("my-host.local"));
514 assert!(is_safe_hostname("a"));
515 assert!(is_safe_hostname("web-01"));
516 }
517
518 #[test]
519 fn hostname_empty() {
520 assert!(!is_safe_hostname(""));
521 }
522
523 #[test]
524 fn hostname_rejects_spaces() {
525 assert!(!is_safe_hostname("my host"));
526 }
527
528 #[test]
529 fn hostname_rejects_shell_chars() {
530 assert!(!is_safe_hostname("host;rm -rf /"));
531 assert!(!is_safe_hostname("host$(whoami)"));
532 assert!(!is_safe_hostname("host'"));
533 }
534
535 #[test]
536 fn hostname_rejects_too_long() {
537 let long = "a".repeat(254);
538 assert!(!is_safe_hostname(&long));
539 }
540
541 #[test]
542 fn create_seed_iso_rejects_invalid_hostname_before_running_tools() {
543 let tmp = tempfile::tempdir().expect("tempdir");
544 let iso_path = tmp.path().join("seed.iso");
545 let config = SeedConfig {
546 hostname: "../bad-host",
547 ssh_pubkey: "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAItest test@test",
548 nics: vec![],
549 process: None,
550 volumes: vec![],
551 healthcheck: None,
552 extra_hosts: vec![],
553 };
554
555 let err = create_seed_iso(&iso_path, &config)
556 .expect_err("invalid hostname should fail before ISO tool invocation");
557 assert!(
558 err.to_string().contains("invalid hostname"),
559 "expected invalid hostname error, got: {}",
560 err
561 );
562 }
563
564 #[test]
567 fn ip_valid_v4() {
568 assert!(is_safe_ip("192.168.1.1"));
569 assert!(is_safe_ip("10.0.0.1"));
570 }
571
572 #[test]
573 fn ip_valid_v6() {
574 assert!(is_safe_ip("::1"));
575 assert!(is_safe_ip("fe80::1"));
576 assert!(is_safe_ip("2001:db8::1"));
577 }
578
579 #[test]
580 fn ip_empty() {
581 assert!(!is_safe_ip(""));
582 }
583
584 #[test]
585 fn ip_rejects_shell_chars() {
586 assert!(!is_safe_ip("1.1.1.1; rm -rf /"));
587 assert!(!is_safe_ip("$(whoami)"));
588 }
589
590 #[test]
593 fn env_name_valid() {
594 assert!(is_safe_env_name("PATH"));
595 assert!(is_safe_env_name("MY_VAR_123"));
596 assert!(is_safe_env_name("_PRIVATE"));
597 }
598
599 #[test]
600 fn env_name_rejects_empty() {
601 assert!(!is_safe_env_name(""));
602 }
603
604 #[test]
605 fn env_name_rejects_leading_digit() {
606 assert!(!is_safe_env_name("1BAD"));
607 }
608
609 #[test]
610 fn env_name_rejects_shell_injection() {
611 assert!(!is_safe_env_name("FOO;rm -rf /"));
612 assert!(!is_safe_env_name("FOO=$(whoami)"));
613 assert!(!is_safe_env_name("FOO BAR"));
614 }
615
616 #[test]
619 fn user_data_basic_structure() {
620 let config = SeedConfig {
621 hostname: "test-vm",
622 ssh_pubkey: "ssh-ed25519 AAAA...",
623 nics: vec![],
624 process: None,
625 volumes: vec![],
626 healthcheck: None,
627 extra_hosts: vec![],
628 };
629 let ud = build_user_data(&config).expect("user-data");
630 assert!(ud.starts_with("#cloud-config\n"));
631 assert!(ud.contains("ssh-ed25519 AAAA..."));
632 assert!(ud.contains("VMRS_READY"));
633 }
634
635 #[test]
636 fn user_data_with_process_env() {
637 let config = SeedConfig {
638 hostname: "test-vm",
639 ssh_pubkey: "ssh-ed25519 AAAA...",
640 nics: vec![],
641 process: Some(ProcessConfig {
642 command: "/bin/app".into(),
643 workdir: Some("/opt/app".into()),
644 env: vec![("PORT".into(), "8080".into())],
645 }),
646 volumes: vec![],
647 healthcheck: None,
648 extra_hosts: vec![],
649 };
650 let ud = build_user_data(&config).expect("user-data");
651 assert!(ud.contains("export PORT='8080'"));
652 assert!(ud.contains("cd '/opt/app' && sh -lc '/bin/app'"));
653 }
654
655 #[test]
656 fn user_data_rejects_bad_env_name() {
657 let config = SeedConfig {
658 hostname: "test-vm",
659 ssh_pubkey: "ssh-ed25519 AAAA...",
660 nics: vec![],
661 process: Some(ProcessConfig {
662 command: "/bin/app".into(),
663 workdir: None,
664 env: vec![
665 ("GOOD".into(), "ok".into()),
666 ("BAD;rm".into(), "evil".into()),
667 ],
668 }),
669 volumes: vec![],
670 healthcheck: None,
671 extra_hosts: vec![],
672 };
673 let err = build_user_data(&config).expect_err("invalid env name should fail");
674 assert!(err.to_string().contains("unsafe environment variable name"));
675 }
676
677 #[test]
680 fn network_config_static_ip() {
681 let config = SeedConfig {
682 hostname: "test-vm",
683 ssh_pubkey: "ssh-ed25519 AAAA...",
684 nics: vec![NicConfig {
685 name: "eth0".into(),
686 ip: "10.0.1.2/24".into(),
687 gateway: Some("10.0.1.1".into()),
688 }],
689 process: None,
690 volumes: vec![],
691 healthcheck: None,
692 extra_hosts: vec![],
693 };
694 let nc = build_network_config(&config).expect("network-config");
695 assert!(nc.contains("version: 2"));
696 assert!(nc.contains("eth0:"));
697 assert!(nc.contains("addresses: [10.0.1.2/24]"));
698 assert!(nc.contains("via: 10.0.1.1"));
699 }
700
701 #[test]
702 fn network_config_no_gateway() {
703 let config = SeedConfig {
704 hostname: "test-vm",
705 ssh_pubkey: "ssh-ed25519 AAAA...",
706 nics: vec![NicConfig {
707 name: "eth0".into(),
708 ip: "10.0.1.2/24".into(),
709 gateway: None,
710 }],
711 process: None,
712 volumes: vec![],
713 healthcheck: None,
714 extra_hosts: vec![],
715 };
716 let nc = build_network_config(&config).expect("network-config");
717 assert!(nc.contains("eth0:"));
718 assert!(!nc.contains("routes:"));
719 }
720}