Skip to main content

vm_rs/setup/
seed.rs

1//! Cloud-init seed ISO generation.
2//!
3//! Creates a seed ISO containing `meta-data`, `user-data`, and `network-config`
4//! for cloud-init's NoCloud datasource. Each VM gets a unique seed ISO with its
5//! SSH keys, network config, startup scripts, and health checks.
6
7use 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// ---------------------------------------------------------------------------
16// Types
17// ---------------------------------------------------------------------------
18
19/// Cloud-init seed configuration for a service VM.
20#[derive(Debug)]
21pub struct SeedConfig<'a> {
22    /// VM hostname.
23    pub hostname: &'a str,
24    /// SSH public key for root access.
25    pub ssh_pubkey: &'a str,
26    /// Network interface configurations.
27    pub nics: Vec<NicConfig>,
28    /// Process to start after boot.
29    pub process: Option<ProcessConfig>,
30    /// Volume mounts (host dirs shared via VirtioFS).
31    pub volumes: Vec<VolumeMountConfig>,
32    /// Health check configuration.
33    pub healthcheck: Option<HealthCheckConfig>,
34    /// Additional /etc/hosts entries.
35    pub extra_hosts: Vec<(String, String)>,
36}
37
38/// Network interface configuration for cloud-init.
39#[derive(Debug, Clone)]
40pub struct NicConfig {
41    /// Interface name (eth0, eth1, ...).
42    pub name: String,
43    /// Static IP address with CIDR (e.g., "10.0.1.2/24").
44    pub ip: String,
45    /// Gateway IP (optional).
46    pub gateway: Option<String>,
47}
48
49/// Process to start inside the VM after boot.
50#[derive(Debug, Clone)]
51pub struct ProcessConfig {
52    /// Command to execute.
53    pub command: String,
54    /// Working directory.
55    pub workdir: Option<String>,
56    /// Environment variables.
57    pub env: Vec<(String, String)>,
58}
59
60/// Volume mount configuration for cloud-init.
61#[derive(Debug, Clone)]
62pub struct VolumeMountConfig {
63    /// VirtioFS tag (matches SharedDir.tag).
64    pub tag: String,
65    /// Mount point inside the VM.
66    pub mount_point: String,
67    /// Read-only mount.
68    pub read_only: bool,
69}
70
71/// Health check configuration.
72#[derive(Debug, Clone)]
73pub struct HealthCheckConfig {
74    /// Command to run for health check.
75    pub command: String,
76    /// Check interval in seconds.
77    pub interval_secs: u32,
78    /// Number of retries before marking unhealthy.
79    pub retries: u32,
80}
81
82// ---------------------------------------------------------------------------
83// Public API
84// ---------------------------------------------------------------------------
85
86/// Create a cloud-init seed ISO for a service VM.
87///
88/// The ISO contains:
89/// - `meta-data`: instance-id and hostname
90/// - `user-data`: SSH keys, package config, startup scripts
91/// - `network-config`: static IP assignment for each NIC
92///
93/// On macOS: uses `hdiutil makehybrid` to create the ISO.
94/// On Linux: uses `genisoimage` or `mkisofs`.
95pub 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        // Write meta-data
123        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        // Write user-data
130        let user_data = build_user_data(config)?;
131        std::fs::write(tmp_dir.join("user-data"), &user_data).map_err(SetupError::Io)?;
132
133        // Write network-config (v2)
134        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
141        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
155// ---------------------------------------------------------------------------
156// Internal helpers
157// ---------------------------------------------------------------------------
158
159fn 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    // Mount VirtioFS volumes
168    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    // Extra hosts — validate to prevent shell injection
190    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    // Start process
201    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    // Health check
234    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    // Readiness marker — VmManager watches the console log for this
250    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        // Try genisoimage first, fall back to mkisofs
318        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
350/// Quote a string for safe embedding in a shell command.
351///
352/// Uses single quotes, which prevent ALL shell interpretation. The only
353/// character that needs escaping inside single quotes is the single quote
354/// itself, handled via the standard `'\''` idiom (end quote, escaped quote,
355/// restart quote).
356fn 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
370/// Validate an environment variable name (POSIX: uppercase letters, digits, underscore; must not start with digit).
371fn 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
378/// Validate a hostname contains only safe characters (alphanumeric, hyphens, dots).
379fn 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
386/// Validate an IP address contains only safe characters (digits, dots, colons for IPv6).
387fn 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    // ── shell_quote ──────────────────────────────────────────────────────
467
468    #[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        // Single quotes prevent $() expansion
486        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    // ── is_safe_hostname ─────────────────────────────────────────────────
510
511    #[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    // ── is_safe_ip ───────────────────────────────────────────────────────
565
566    #[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    // ── is_safe_env_name ─────────────────────────────────────────────────
591
592    #[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    // ── build_user_data ──────────────────────────────────────────────────
617
618    #[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    // ── build_network_config ─────────────────────────────────────────────
678
679    #[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}