diff --git a/Cargo.lock b/Cargo.lock index f6969a6f..ca3b5e92 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3253,6 +3253,16 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "libyml" +version = "0.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3302702afa434ffa30847a83305f0a69d6abd74293b6554c18ec85c7ef30c980" +dependencies = [ + "anyhow", + "version_check", +] + [[package]] name = "lightning-invoice" version = "0.34.0" @@ -3372,6 +3382,7 @@ dependencies = [ "russh-sftp", "serde", "serde_json", + "serde_yml", "ssh-key 0.6.7", "tokio", "tokio-tungstenite 0.28.0", @@ -5830,6 +5841,21 @@ dependencies = [ "unsafe-libyaml", ] +[[package]] +name = "serde_yml" +version = "0.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59e2dd588bf1597a252c3b920e0143eb99b0f76e4e082f4c92ce34fbc9e71ddd" +dependencies = [ + "indexmap", + "itoa", + "libyml", + "memchr", + "ryu", + "serde", + "version_check", +] + [[package]] name = "serdect" version = "0.4.3" diff --git a/Cargo.toml b/Cargo.toml index 580892c0..78ceb53f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -28,6 +28,7 @@ log = "0.4" env_logger = "0.11" serde = { version = "1", features = ["derive"] } serde_json = "1" +serde_yml = "0.0.12" axum = { version = "0.8", features = ["ws", "macros"] } tower-http = { version = "0.6", features = ["cors", "set-header"] } config = { version = "0.15", features = ["yaml"] } diff --git a/lnvps_api/Cargo.toml b/lnvps_api/Cargo.toml index c90da7bf..f2029561 100644 --- a/lnvps_api/Cargo.toml +++ b/lnvps_api/Cargo.toml @@ -68,6 +68,7 @@ tokio.workspace = true config.workspace = true serde.workspace = true serde_json.workspace = true +serde_yml.workspace = true axum.workspace = true tower-http.workspace = true hex.workspace = true diff --git a/lnvps_api/src/host/proxmox.rs b/lnvps_api/src/host/proxmox.rs index d088dcfc..574e4347 100644 --- a/lnvps_api/src/host/proxmox.rs +++ b/lnvps_api/src/host/proxmox.rs @@ -363,7 +363,6 @@ impl ProxmoxClient { }; let snippet_filename = "lnvps-vendor.yaml"; - let snippet_content = "#cloud-config\nssh_deletekeys: false\n"; // Snippet storage path depends on the storage type; for the default // `local` storage this is `/var/lib/vz/snippets/`. For other directory- @@ -377,6 +376,11 @@ impl ProxmoxClient { .await .map_err(OpError::Transient)?; + // Force a fixed set of public resolvers into every guest's + // /etc/resolv.conf via cloud-init. Without this, minimal images + // (Alpine, NixOS) silently drop the DNS servers Proxmox provides. + let snippet_content = build_vendor_snippet(GUEST_DNS_SERVERS); + let vol_ref = format!("{storage_name}:snippets/{snippet_filename}"); // Resolve the on-disk path for the snippet volume reference @@ -2229,6 +2233,66 @@ fn parse_storage_from_disk(disk: &str) -> Option { } } +/// DNS resolvers forced into every guest's `/etc/resolv.conf` via the cloud-init +/// vendor snippet (see [`build_vendor_snippet`]). +const GUEST_DNS_SERVERS: &[&str] = &[ + "1.1.1.1", + "8.8.8.8", + "9.9.9.9", + // IPv6 variants of the same providers (Cloudflare, Google, Quad9). + "2606:4700:4700::1111", + "2001:4860:4860::8888", + "2620:fe::fe", +]; + +/// Structured `#cloud-config` vendor-data document written to the host snippet +/// and referenced by every VM's `cicustom` (see [`build_vendor_snippet`]). +#[derive(Debug, Serialize)] +struct CloudInitVendorData { + /// Keep SSH host keys across cloud-init reconfiguration so users don't hit + /// host-key-changed warnings on IP/key updates. + ssh_deletekeys: bool, + /// Force cloud-init to write `/etc/resolv.conf` directly. Only emitted when + /// there are nameservers to set. + #[serde(skip_serializing_if = "Option::is_none")] + manage_resolv_conf: Option, + #[serde(skip_serializing_if = "Option::is_none")] + resolv_conf: Option, +} + +#[derive(Debug, Serialize)] +struct CloudInitResolvConf { + nameservers: Vec, +} + +/// Build the cloud-init vendor-data snippet applied to every VM on the host. +/// +/// Always disables SSH host-key regeneration (`ssh_deletekeys: false`). When +/// `nameservers` is non-empty it also forces cloud-init to write +/// `/etc/resolv.conf` directly (`manage_resolv_conf: true`), which is required +/// for minimal images (e.g. Alpine, NixOS) whose native network renderer does +/// not apply the DNS servers Proxmox hands them (Alpine's busybox ifupdown +/// needs `openresolv`, which the stock cloud image lacks). +/// +/// Serialised to YAML from a typed struct via `serde_yml`, prefixed with the +/// `#cloud-config` header line, so the on-disk snippet is real cloud-config +/// YAML without any fragile hand-built indentation/escaping. +fn build_vendor_snippet(nameservers: &[&str]) -> String { + let has_dns = !nameservers.is_empty(); + let data = CloudInitVendorData { + ssh_deletekeys: false, + manage_resolv_conf: has_dns.then_some(true), + resolv_conf: has_dns.then(|| CloudInitResolvConf { + nameservers: nameservers.iter().map(|s| s.to_string()).collect(), + }), + }; + format!( + "#cloud-config\n{}", + // This struct is always serialisable, so this cannot fail. + serde_yml::to_string(&data).expect("serialize cloud-init vendor data") + ) +} + impl From for i32 { fn from(val: ProxmoxVmId) -> Self { val.0 as i32 + 100 @@ -2840,6 +2904,37 @@ mod tests { use wiremock::matchers::{method, path_regex}; use wiremock::{Mock, MockServer, ResponseTemplate}; + #[test] + fn test_build_vendor_snippet() { + // Every snippet must carry the cloud-config header and parse as YAML. + let header = "#cloud-config\n"; + + // No nameservers -> ssh_deletekeys only, no resolv_conf keys. + let empty = build_vendor_snippet(&[]); + assert!(empty.starts_with(header)); + assert!(!empty.contains("manage_resolv_conf")); + assert!(!empty.contains("resolv_conf")); + let v: serde_yml::Value = serde_yml::from_str(&empty).unwrap(); + assert_eq!(v["ssh_deletekeys"], serde_yml::Value::Bool(false)); + + // With nameservers (incl. IPv6) -> manage_resolv_conf + resolv_conf set. + let s = build_vendor_snippet(&["1.1.1.1", "2606:4700:4700::1111"]); + assert!(s.starts_with(header)); + let v: serde_yml::Value = serde_yml::from_str(&s).unwrap(); + assert_eq!(v["ssh_deletekeys"], serde_yml::Value::Bool(false)); + assert_eq!(v["manage_resolv_conf"], serde_yml::Value::Bool(true)); + assert_eq!(v["resolv_conf"]["nameservers"][0], "1.1.1.1"); + assert_eq!(v["resolv_conf"]["nameservers"][1], "2606:4700:4700::1111"); + + // The production constant covers both IPv4 and IPv6 resolvers, and the + // body must be valid cloud-config YAML. + let prod = build_vendor_snippet(GUEST_DNS_SERVERS); + let v: serde_yml::Value = serde_yml::from_str(&prod).unwrap(); + let ns = v["resolv_conf"]["nameservers"].as_sequence().unwrap(); + assert!(ns.iter().any(|n| n.as_str() == Some("8.8.8.8"))); + assert!(ns.iter().any(|n| n.as_str() == Some("2620:fe::fe"))); + } + #[test] fn test_image_source_checksum_path() { assert_eq!(