|
| 1 | +//! Systemd credential injection for QEMU VMs. |
| 2 | +//! |
| 3 | +//! Provides functions for injecting configuration into VMs via systemd credentials |
| 4 | +//! using SMBIOS firmware variables (preferred) or kernel command-line arguments. |
| 5 | +//! Supports SSH keys, mount units, environment configuration, and AF_VSOCK setup. |
| 6 | +
|
| 7 | +use color_eyre::Result; |
| 8 | + |
| 9 | +/// Convert a guest mount path to a systemd unit name. |
| 10 | +/// |
| 11 | +/// Systemd requires mount unit names to match the mount path with: |
| 12 | +/// - Leading slash removed |
| 13 | +/// - All slashes replaced with dashes |
| 14 | +/// - All dashes in path components escaped as `\x2d` |
| 15 | +/// - .mount suffix added |
| 16 | +/// |
| 17 | +/// # Examples |
| 18 | +/// |
| 19 | +/// - `/mnt/data` -> `mnt-data.mount` |
| 20 | +/// - `/var/lib/data` -> `var-lib-data.mount` |
| 21 | +/// - `/data` -> `data.mount` |
| 22 | +/// - `/mnt/test-rw` -> `mnt-test\x2drw.mount` |
| 23 | +pub fn guest_path_to_unit_name(guest_path: &str) -> String { |
| 24 | + let path = guest_path.strip_prefix('/').unwrap_or(guest_path); |
| 25 | + |
| 26 | + // Escape dashes in path components, then replace slashes with dashes |
| 27 | + let escaped = path |
| 28 | + .split('/') |
| 29 | + .map(|component| component.replace('-', "\\x2d")) |
| 30 | + .collect::<Vec<_>>() |
| 31 | + .join("-"); |
| 32 | + |
| 33 | + format!("{}.mount", escaped) |
| 34 | +} |
| 35 | + |
| 36 | +/// Generate a systemd mount unit for virtiofs. |
| 37 | +/// |
| 38 | +/// Creates a systemd mount unit that mounts a virtiofs filesystem at the specified |
| 39 | +/// guest path. The unit is configured to: |
| 40 | +/// - Mount type: virtiofs |
| 41 | +/// - Options: Include readonly flag if specified, plus SELinux context for RO mounts |
| 42 | +/// - Before=remote-fs.target to integrate with standard systemd mount ordering |
| 43 | +/// |
| 44 | +/// We use remote-fs.target rather than local-fs.target because virtiofs is |
| 45 | +/// conceptually similar to a "remote" filesystem - it requires virtio transport |
| 46 | +/// infrastructure to be available, similar to how NFS requires network. |
| 47 | +/// |
| 48 | +/// Returns the complete unit file content as a string. |
| 49 | +pub fn generate_virtiofs_mount_unit( |
| 50 | + virtiofs_tag: &str, |
| 51 | + guest_path: &str, |
| 52 | + readonly: bool, |
| 53 | +) -> String { |
| 54 | + let options = if readonly { |
| 55 | + // Default readonly mounts to usr_t - this helps avoid SELinux |
| 56 | + // issues when accessing them as container storage for example. |
| 57 | + "ro,context=system_u:object_r:usr_t:s0" |
| 58 | + } else { |
| 59 | + "rw" |
| 60 | + }; |
| 61 | + |
| 62 | + format!( |
| 63 | + "[Unit]\n\ |
| 64 | + Description=Mount virtiofs tag {tag} at {path}\n\ |
| 65 | + ConditionPathExists=!/etc/initrd-release\n\ |
| 66 | + Before=remote-fs.target\n\ |
| 67 | + \n\ |
| 68 | + [Mount]\n\ |
| 69 | + What={tag}\n\ |
| 70 | + Where={path}\n\ |
| 71 | + Type=virtiofs\n\ |
| 72 | + Options={options}\n", |
| 73 | + tag = virtiofs_tag, |
| 74 | + path = guest_path, |
| 75 | + options = options |
| 76 | + ) |
| 77 | +} |
| 78 | + |
| 79 | +/// Generate SMBIOS credentials for a systemd mount unit. |
| 80 | +/// |
| 81 | +/// Creates systemd credentials for: |
| 82 | +/// 1. The mount unit itself (via systemd.extra-unit) |
| 83 | +/// 2. A dropin for local-fs.target that wants this mount unit |
| 84 | +/// |
| 85 | +/// Returns a vector of SMBIOS credential strings. |
| 86 | +pub fn smbios_creds_for_mount_unit( |
| 87 | + virtiofs_tag: &str, |
| 88 | + guest_path: &str, |
| 89 | + readonly: bool, |
| 90 | +) -> Result<Vec<String>> { |
| 91 | + let unit_name = guest_path_to_unit_name(guest_path); |
| 92 | + let mount_unit_content = generate_virtiofs_mount_unit(virtiofs_tag, guest_path, readonly); |
| 93 | + let encoded_mount = data_encoding::BASE64.encode(mount_unit_content.as_bytes()); |
| 94 | + |
| 95 | + let mount_cred = |
| 96 | + format!("io.systemd.credential.binary:systemd.extra-unit.{unit_name}={encoded_mount}"); |
| 97 | + |
| 98 | + // Create a dropin for local-fs.target that wants this mount |
| 99 | + let dropin_content = format!( |
| 100 | + "[Unit]\n\ |
| 101 | + Wants={unit_name}\n" |
| 102 | + ); |
| 103 | + let encoded_dropin = data_encoding::BASE64.encode(dropin_content.as_bytes()); |
| 104 | + let dropin_cred = format!( |
| 105 | + "io.systemd.credential.binary:systemd.unit-dropin.local-fs.target~bcvk-mounts={encoded_dropin}" |
| 106 | + ); |
| 107 | + |
| 108 | + Ok(vec![mount_cred, dropin_cred]) |
| 109 | +} |
| 110 | + |
| 111 | +/// Generate SMBIOS credential string for AF_VSOCK systemd notification socket. |
| 112 | +/// |
| 113 | +/// Creates a systemd credential that configures systemd to send notifications |
| 114 | +/// via AF_VSOCK instead of the default Unix socket. This enables host-guest |
| 115 | +/// communication for debugging VM boot sequences. |
| 116 | +/// |
| 117 | +/// Returns a string for use with `qemu -smbios type=11,value="..."` |
| 118 | +pub fn smbios_cred_for_vsock_notify(host_cid: u32, port: u32) -> String { |
| 119 | + format!( |
| 120 | + "io.systemd.credential:vmm.notify_socket=vsock-stream:{}:{}", |
| 121 | + host_cid, port |
| 122 | + ) |
| 123 | +} |
| 124 | + |
| 125 | +/// Generate SMBIOS credentials for STORAGE_OPTS configuration. |
| 126 | +/// |
| 127 | +/// Creates a systemd unit that conditionally appends STORAGE_OPTS to /etc/environment |
| 128 | +/// (for PAM sessions including SSH), plus a dropin to ensure it runs. |
| 129 | +/// |
| 130 | +/// Returns a vector with: |
| 131 | +/// 1. The unit itself (systemd.extra-unit) |
| 132 | +/// 2. A dropin for sysinit.target to pull in the unit |
| 133 | +pub fn smbios_creds_for_storage_opts() -> Result<Vec<String>> { |
| 134 | + // Create systemd unit that conditionally appends to /etc/environment |
| 135 | + let unit_content = r#"[Unit] |
| 136 | +Description=Setup STORAGE_OPTS for bcvk |
| 137 | +DefaultDependencies=no |
| 138 | +Before=systemd-user-sessions.service |
| 139 | +
|
| 140 | +[Service] |
| 141 | +Type=oneshot |
| 142 | +ExecStart=/bin/sh -c 'grep -q STORAGE_OPTS /etc/environment || echo STORAGE_OPTS=additionalimagestore=/run/host-container-storage >> /etc/environment' |
| 143 | +RemainAfterExit=yes |
| 144 | +"#; |
| 145 | + let encoded_unit = data_encoding::BASE64.encode(unit_content.as_bytes()); |
| 146 | + let unit_cred = format!( |
| 147 | + "io.systemd.credential.binary:systemd.extra-unit.bcvk-storage-opts.service={encoded_unit}" |
| 148 | + ); |
| 149 | + |
| 150 | + // Create dropin for sysinit.target to pull in our unit |
| 151 | + let dropin_content = "[Unit]\nWants=bcvk-storage-opts.service\n"; |
| 152 | + let encoded_dropin = data_encoding::BASE64.encode(dropin_content.as_bytes()); |
| 153 | + let dropin_cred = format!( |
| 154 | + "io.systemd.credential.binary:systemd.unit-dropin.sysinit.target~bcvk-storage={encoded_dropin}" |
| 155 | + ); |
| 156 | + |
| 157 | + Ok(vec![unit_cred, dropin_cred]) |
| 158 | +} |
| 159 | + |
| 160 | +/// Generate tmpfiles.d lines for STORAGE_OPTS in systemd contexts. |
| 161 | +/// |
| 162 | +/// Configures STORAGE_OPTS for: |
| 163 | +/// - /etc/environment.d/: systemd user manager and user services |
| 164 | +/// - /etc/systemd/system.conf.d/: system-level systemd services |
| 165 | +pub fn storage_opts_tmpfiles_d_lines() -> String { |
| 166 | + concat!( |
| 167 | + "f /etc/environment.d/90-bcvk-storage.conf 0644 root root - STORAGE_OPTS=additionalimagestore=/run/host-container-storage\n", |
| 168 | + "d /etc/systemd/system.conf.d 0755 root root -\n", |
| 169 | + "f /etc/systemd/system.conf.d/90-bcvk-storage.conf 0644 root root - [Manager]\\nDefaultEnvironment=STORAGE_OPTS=additionalimagestore=/run/host-container-storage\n" |
| 170 | + ).to_string() |
| 171 | +} |
| 172 | + |
| 173 | +/// Generate SMBIOS credential string for root SSH access. |
| 174 | +/// |
| 175 | +/// Creates a systemd credential for QEMU's SMBIOS interface. Preferred method |
| 176 | +/// as it keeps credentials out of kernel command line and boot logs. |
| 177 | +/// |
| 178 | +/// Returns a string for use with `qemu -smbios type=11,value="..."` |
| 179 | +pub fn smbios_cred_for_root_ssh(pubkey: &str) -> Result<String> { |
| 180 | + let k = key_to_root_tmpfiles_d(pubkey); |
| 181 | + let encoded = data_encoding::BASE64.encode(k.as_bytes()); |
| 182 | + let r = format!("io.systemd.credential.binary:tmpfiles.extra={encoded}"); |
| 183 | + Ok(r) |
| 184 | +} |
| 185 | + |
| 186 | +/// Generate kernel command-line argument for root SSH access. |
| 187 | +/// |
| 188 | +/// Creates a systemd credential for kernel command-line delivery. Less secure |
| 189 | +/// than SMBIOS method as credentials are visible in /proc/cmdline and boot logs. |
| 190 | +/// |
| 191 | +/// Returns a string for use in kernel boot parameters. |
| 192 | +#[allow(dead_code)] |
| 193 | +pub fn karg_for_root_ssh(pubkey: &str) -> Result<String> { |
| 194 | + let k = key_to_root_tmpfiles_d(pubkey); |
| 195 | + let encoded = data_encoding::BASE64.encode(k.as_bytes()); |
| 196 | + let r = format!("systemd.set_credential_binary=tmpfiles.extra:{encoded}"); |
| 197 | + Ok(r) |
| 198 | +} |
| 199 | + |
| 200 | +/// Convert SSH public key to systemd tmpfiles.d configuration. |
| 201 | +/// |
| 202 | +/// Generates configuration to create `/root/.ssh` directory (0750) and |
| 203 | +/// `/root/.ssh/authorized_keys` file (700) with the Base64-encoded SSH key. |
| 204 | +/// Uses `f+~` to append to existing authorized_keys files. |
| 205 | +pub fn key_to_root_tmpfiles_d(pubkey: &str) -> String { |
| 206 | + let buf = data_encoding::BASE64.encode(pubkey.as_bytes()); |
| 207 | + format!("d /root/.ssh 0750 - - -\nf+~ /root/.ssh/authorized_keys 700 - - - {buf}\n") |
| 208 | +} |
| 209 | + |
| 210 | +#[cfg(test)] |
| 211 | +mod tests { |
| 212 | + use data_encoding::BASE64; |
| 213 | + |
| 214 | + use super::*; |
| 215 | + |
| 216 | + /// Test SSH public key for validation (truncated for brevity) |
| 217 | + const STUBKEY: &str = "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQC..."; |
| 218 | + |
| 219 | + /// Test tmpfiles.d configuration generation |
| 220 | + #[test] |
| 221 | + fn test_key_to_root_tmpfiles_d() { |
| 222 | + let expected = "d /root/.ssh 0750 - - -\nf+~ /root/.ssh/authorized_keys 700 - - - c3NoLXJzYSBBQUFBQjNOemFDMXljMkVBQUFBREFRQUJBQUFCQVFDLi4u\n"; |
| 223 | + assert_eq!(key_to_root_tmpfiles_d(STUBKEY), expected); |
| 224 | + } |
| 225 | + |
| 226 | + /// Test SMBIOS credential generation and format validation |
| 227 | + #[test] |
| 228 | + fn test_credential_for_root_ssh() { |
| 229 | + let b64_tmpfiles = BASE64.encode(key_to_root_tmpfiles_d(STUBKEY).as_bytes()); |
| 230 | + let expected = format!("io.systemd.credential.binary:tmpfiles.extra={b64_tmpfiles}"); |
| 231 | + |
| 232 | + // Verify credential format by reverse parsing |
| 233 | + let v = expected |
| 234 | + .strip_prefix("io.systemd.credential.binary:") |
| 235 | + .unwrap(); |
| 236 | + let v = v.strip_prefix("tmpfiles.extra=").unwrap(); |
| 237 | + let v = String::from_utf8(BASE64.decode(v.as_bytes()).unwrap()).unwrap(); |
| 238 | + assert_eq!(v, "d /root/.ssh 0750 - - -\nf+~ /root/.ssh/authorized_keys 700 - - - c3NoLXJzYSBBQUFBQjNOemFDMXljMkVBQUFBREFRQUJBQUFCQVFDLi4u\n"); |
| 239 | + |
| 240 | + // Test the actual function output |
| 241 | + assert_eq!(smbios_cred_for_root_ssh(STUBKEY).unwrap(), expected); |
| 242 | + } |
| 243 | + |
| 244 | + #[test] |
| 245 | + fn test_guest_path_to_unit_name() { |
| 246 | + assert_eq!(guest_path_to_unit_name("/mnt/data"), "mnt-data.mount"); |
| 247 | + assert_eq!( |
| 248 | + guest_path_to_unit_name("/var/lib/data"), |
| 249 | + "var-lib-data.mount" |
| 250 | + ); |
| 251 | + assert_eq!(guest_path_to_unit_name("/data"), "data.mount"); |
| 252 | + assert_eq!( |
| 253 | + guest_path_to_unit_name("/mnt/test-rw"), |
| 254 | + "mnt-test\\x2drw.mount" |
| 255 | + ); |
| 256 | + } |
| 257 | +} |
0 commit comments