/
githubmirror
/
afterburn
Обзор
Документация
Войти
/
githubmirror
/
afterburn
Код
Запросы
0
Пакеты
0
Релизы
0
Аналитика
Безопасность
main
src/providers/mod.rs
538 строк
20 KB
Rolv Apneseth
*: drop maplit dependency
05 авг 2026, 14:39
05 авг 2026, 14:39
84d673e
Код
Авторство
О чём код?
// Copyright 2017 CoreOS, Inc. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //! Providers //! //! These are the providers which Afterburn knows how to retrieve metadata //! from. Internally, they handle the ins and outs of each providers metadata //! services, and externally, they provide a function to fetch that metadata in //! a regular format. //! //! To add a provider, put a `pub mod provider;` line in this file, export a //! function to fetch the metadata, and then add a match line in the top-level //! `fetch_metadata()` function in metadata.rs. pub mod akamai; pub mod aliyun; pub mod aws; pub mod cloudstack; pub mod digitalocean; pub mod exoscale; pub mod gcp; pub mod hetzner; pub mod ibmcloud; pub mod ibmcloud_classic; pub mod kubevirt; pub mod microsoft; pub mod noop; pub mod openstack; pub mod oraclecloud; pub mod packet; pub mod powervs; pub mod proxmoxve; pub mod scaleway; pub mod upcloud; pub mod vmware; pub mod vultr; use crate::ignition::IgnitionConfig; use crate::network::{self, NetDevKind}; use anyhow::{anyhow, Context, Result}; use libsystemd::logging; use nix::unistd; use openssh_keys::PublicKey; use slog_scope::warn; use std::collections::HashMap; use std::fs::{self, File, Permissions}; use std::io::prelude::*; use std::os::unix::fs::PermissionsExt; use std::path::Path; use uzers::{self, User}; /// Message ID markers for authorized-keys entries in journal. const AFTERBURN_SSH_AUTHORIZED_KEYS_ADDED_MESSAGEID: &str = "0f7d7a502f2d433caa1323440a6b4190"; const AFTERBURN_SSH_AUTHORIZED_KEYS_REMOVED_MESSAGEID: &str = "f8b91c53f5544868a3a10d0dcf68e9ea"; fn create_file(filename: &str) -> Result<File> { let file_path = Path::new(&filename); // create the directories if they don't exist let folder = file_path .parent() .ok_or_else(|| anyhow!("could not get parent directory of {:?}", file_path))?; fs::create_dir_all(folder).with_context(|| format!("failed to create directory {folder:?}"))?; // create (or truncate) the file we want to write to File::create(file_path).with_context(|| format!("failed to create file {file_path:?}")) } /// Add a message to the journal logging SSH key additions; this /// will be used by at least Fedora CoreOS to display in the console /// if no ssh keys are present. fn write_ssh_key_journal_entry(log: logging::Priority, name: &str, path: &str, added: bool) { let message = format!( "{} ssh authorized keys file for user: {}", if added { "wrote" } else { "removed" }, name ); let map = HashMap::from([ ("AFTERBURN_USER_NAME", name), ("AFTERBURN_PATH", path), ( "MESSAGE_ID", match added { true => AFTERBURN_SSH_AUTHORIZED_KEYS_ADDED_MESSAGEID, false => AFTERBURN_SSH_AUTHORIZED_KEYS_REMOVED_MESSAGEID, }, ), ]); if let Err(e) = logging::journal_send(log, &message, map.iter()) { warn!("failed to send information to journald: {}", e); } } fn write_ssh_keys(user: User, ssh_keys: Vec<PublicKey>) -> Result<()> { use std::io::ErrorKind::NotFound; use uzers::os::unix::UserExt; // switch users let _guard = uzers::switch::switch_user_group(user.uid(), user.primary_group_id()) .context("failed to switch user/group")?; // get paths let dir_path = user.home_dir().join(".ssh").join("authorized_keys.d"); let file_name = "afterburn"; let file_path = &dir_path.join(file_name); // stringify for logging let username = user.name().to_string_lossy(); let file_path_str = file_path.to_string_lossy(); if !ssh_keys.is_empty() { // ensure directory exists fs::create_dir_all(&dir_path) .with_context(|| format!("failed to create directory {:?}", &dir_path))?; // create temporary file let mut temp_file = tempfile::Builder::new() .prefix(&format!(".{file_name}-")) .tempfile_in(&dir_path) .context("failed to create temporary file")?; // write out keys for key in ssh_keys { writeln!(temp_file, "{key}").with_context(|| { format!("failed to write to file {:?}", temp_file.path().display()) })?; } // sync to disk temp_file .as_file() .sync_all() .with_context(|| format!("failed to sync file {:?}", temp_file.path().display()))?; // atomically rename to destination // don't leak temporary file on error temp_file .persist(file_path) .map_err(|e| { e.file.close().ok(); e.error }) .with_context(|| format!("failed to persist file {:?}", file_path.display()))?; // emit journal entry write_ssh_key_journal_entry(logging::Priority::Info, &username, &file_path_str, true); } else { // delete the file let deleted = match fs::remove_file(file_path) { Err(ref e) if e.kind() == NotFound => Ok(false), Err(e) => Err(e), Ok(()) => Ok(true), } .with_context(|| format!("failed to remove file {:?}", file_path.display()))?; // emit journal entry if deleted { write_ssh_key_journal_entry(logging::Priority::Info, &username, &file_path_str, false); } } // sync parent dir to persist updates match File::open(&dir_path) { Ok(dir_file) => dir_file.sync_all(), Err(ref e) if e.kind() == NotFound => Ok(()), Err(e) => Err(e), } .with_context(|| format!("failed to sync '{}'", dir_path.display()))?; // make clippy happy while fulfilling our interface drop(user); Ok(()) } fn max_hostname_len() -> Result<Option<usize>> { unistd::sysconf(unistd::SysconfVar::HOST_NAME_MAX) .context("querying maximum hostname length")? .map(|l| { l.try_into() .context("overflow querying maximum hostname length") }) .transpose() } /// Truncate `hostname` to the system's maximum hostname length. /// /// If the value exceeds `HOST_NAME_MAX`, it is truncated to the first dot, or to /// the maximum length if there is no dot within that bound. /// See https://github.com/coreos/afterburn/issues/509. fn truncate_hostname(mut hostname: String) -> Result<String> { if let Some(maxlen) = max_hostname_len()? { if hostname.len() > maxlen { slog_scope::info!( "received hostname {:?} longer than {} characters; truncating", hostname, maxlen ); hostname.truncate(maxlen); if let Some(idx) = hostname.find('.') { hostname.truncate(idx); } } } Ok(hostname) } pub trait MetadataProvider { fn attributes(&self) -> Result<HashMap<String, String>> { Ok(HashMap::new()) } fn hostname(&self) -> Result<Option<String>> { Ok(None) } fn admin_username(&self) -> Result<Option<String>> { Ok(None) } fn admin_password_hash(&self) -> Result<Option<String>> { Ok(None) } fn ssh_keys(&self) -> Result<Vec<PublicKey>> { warn!("ssh-keys requested, but not supported on this platform"); Ok(vec![]) } fn networks(&self) -> Result<Vec<network::Interface>> { Ok(vec![]) } fn netplan_config(&self) -> Result<Option<String>> { Ok(None) } fn boot_checkin(&self) -> Result<()> { warn!("boot check-in requested, but not supported on this platform"); Ok(()) } /// Return a list of virtual network devices for this machine. /// /// This is used to setup virtual interfaces, e.g. via [systemd.netdev][netdev] /// configuration fragments. /// /// netdev: https://www.freedesktop.org/software/systemd/man/systemd.netdev.html fn virtual_network_devices(&self) -> Result<Vec<network::VirtualNetDev>> { Ok(vec![]) } /// Return custom initrd network kernel arguments, if any. fn rd_network_kargs(&self) -> Result<Option<String>> { Ok(None) } fn write_attributes(&self, attributes_file_path: String) -> Result<()> { let mut attributes_file = create_file(&attributes_file_path)?; for (k, v) in self.attributes()? { writeln!(&mut attributes_file, "AFTERBURN_{k}={v}").with_context(|| { format!("failed to write attributes to file {attributes_file:?}") })?; } Ok(()) } fn write_ssh_keys(&self, ssh_keys_user: String) -> Result<()> { let ssh_keys = self.ssh_keys()?; let user = uzers::get_user_by_name(&ssh_keys_user) .ok_or_else(|| anyhow!("could not find user with username {:?}", ssh_keys_user))?; write_ssh_keys(user, ssh_keys)?; Ok(()) } fn write_hostname(&self, hostname_file_path: String) -> Result<()> { if let Some(hostname) = self.hostname()? { let hostname = truncate_hostname(hostname)?; let mut hostname_file = create_file(&hostname_file_path)?; writeln!(&mut hostname_file, "{hostname}").with_context(|| { format!("failed to write hostname {hostname:?} to file {hostname_file:?}") })?; slog_scope::info!("wrote hostname {} to {}", hostname, hostname_file_path); } Ok(()) } /// Write an Ignition config fragment that sets the system hostname (sourced /// from this provider) into `output_dir` as `hostname.ign`. Skips writing if /// the provider exposes no hostname. fn write_hostname_ignition_fragment(&self, output_dir: &str) -> Result<()> { let Some(hostname) = self.hostname()? else { warn!("hostname requested, but not available from this provider"); return Ok(()); }; let hostname = truncate_hostname(hostname)?; let path = Path::new(output_dir).join("hostname.ign"); IgnitionConfig::hostname_fragment(&hostname).write_to(&path)?; slog_scope::info!("wrote hostname ignition fragment"; "path" => path.display().to_string()); Ok(()) } /// Write an Ignition config fragment that configures the platform user /// (admin username, SSH keys, and optional password hash, sourced from this /// provider) into `output_dir` as `user.ign`. Skips writing if no admin /// username is available, or if the user would have neither SSH keys nor a /// password. fn write_user_ignition_fragment(&self, output_dir: &str) -> Result<()> { let Some(username) = self .admin_username() .context("failed to query admin username from provider")? else { warn!("platform-user requested, but admin username not available from this provider"); return Ok(()); }; let ssh_keys: Vec<String> = self .ssh_keys() .context("failed to query SSH keys from provider")? .into_iter() .map(|k| k.to_key_format()) .collect(); let password_hash = self .admin_password_hash() .context("failed to query admin password hash from provider")?; if ssh_keys.is_empty() && password_hash.is_none() { warn!("admin username present but no SSH keys or password; skipping user fragment"); return Ok(()); } let path = Path::new(output_dir).join("user.ign"); IgnitionConfig::user_fragment(username, ssh_keys, password_hash).write_to(&path)?; slog_scope::info!("wrote platform-user ignition fragment"; "path" => path.display().to_string()); Ok(()) } fn write_network_units(&self, network_units_dir: String) -> Result<()> { let dir_path = Path::new(&network_units_dir); fs::create_dir_all(dir_path) .with_context(|| format!("failed to create directory {dir_path:?}"))?; // Write `.network` fragments for network interfaces/links. for interface in &self.networks()? { let unit_name = interface.sd_unit_name()?; let file_path = dir_path.join(unit_name); let mut unit_file = File::create(&file_path) .with_context(|| format!("failed to create file {file_path:?}"))?; write!(&mut unit_file, "{}", interface.sd_config()).with_context(|| { format!("failed to write network interface unit file {unit_file:?}") })?; } // Write `.netdev` fragments for virtual network devices. for device in &self.virtual_network_devices()? { let file_path = dir_path.join(device.netdev_unit_name()); let mut unit_file = File::create(&file_path) .with_context(|| format!("failed to create netdev unit file {file_path:?}"))?; write!(&mut unit_file, "{}", device.sd_netdev_config()) .with_context(|| format!("failed to write netdev unit file {unit_file:?}"))?; } Ok(()) } fn write_nm_profiles(&self, nm_profiles_dir: String) -> Result<()> { let dir_path = Path::new(&nm_profiles_dir); fs::create_dir_all(dir_path) .with_context(|| format!("failed to create directory {dir_path:?}"))?; let physical_interfaces = self.networks()?; let virtual_devices = self.virtual_network_devices()?; let mut configs = Vec::with_capacity(virtual_devices.len() + physical_interfaces.len()); // Separate bond master devices - since NetworkManager requires 1 configuration file with // the information from both an `Interface` and `VirtualNetDev` we need to handle them // separately. let (virtual_bond_masters, virtual_devices): (Vec<_>, Vec<_>) = virtual_devices .into_iter() .partition(|v| v.kind == NetDevKind::Bond); let (physical_bond_masters, physical_interfaces): (Vec<_>, Vec<_>) = physical_interfaces.into_iter().partition(|i| { let Some(ref name) = &i.name else { return false; }; virtual_bond_masters.iter().any(|v| &v.name == name) }); // Generate configurations for bond masters for bond_master in virtual_bond_masters { let name = &bond_master.name; let physical_interface = physical_bond_masters .iter() .find(|p| p.name.as_ref().unwrap() == name); configs.push(( name.to_owned(), bond_master.nm_config(physical_interface) .with_context(|| { format!( "failed to generate NetworkManager connection profile for virtual device '{name}'", ) })?) ); } // Generate configurations for remaining devices, including bond slaves for interface in physical_interfaces { let name = interface.name()?; let config = interface.nm_config().with_context(|| { format!( "failed to generate NetworkManager connection profile for interface '{name}'" ) })?; configs.push((name, config)); } for virt_dev in virtual_devices { let name = virt_dev.name.clone(); let config = virt_dev.nm_config(None).with_context(|| { format!( "failed to generate NetworkManager connection profile for virtual device '{name}'", ) })?; configs.push((name, config)); } // Write NetworkManager connection profile for all generated configurations for (name, config) in configs { let file_path = dir_path.join(format!("{name}.nmconnection")); let mut keyfile = File::create(&file_path) .with_context(|| format!("failed to create file {file_path:?}"))?; keyfile.write_all(config.as_ref()).with_context(|| { format!("failed to write NetworkManager profile to {file_path:?}") })?; // 0600 permissions required, since any file writeable or readable by // any user other than root will be ignored. // See: https://networkmanager.dev/docs/api/latest/nm-settings-keyfile.html keyfile .set_permissions(Permissions::from_mode(0o600)) .with_context(|| "failed to set NetworkManager keyfile permissions")?; } Ok(()) } fn write_netplan_config(&self, netplan_config_dir: String) -> Result<()> { let dir_path = Path::new(&netplan_config_dir); fs::create_dir_all(dir_path) .with_context(|| format!("failed to create directory {dir_path:?}"))?; // Write a single afterburn `.yaml` netplan config. if let Some(netplan_config) = &self.netplan_config()? { let file_path = dir_path.join("50-afterburn.yaml"); let mut config_file = File::create(&file_path) .with_context(|| format!("failed to create file {file_path:?}"))?; write!(&mut config_file, "{netplan_config}") .with_context(|| format!("failed to write netplan config file {config_file:?}"))?; } Ok(()) } } #[cfg(test)] mod tests { use super::*; use tempfile::NamedTempFile; struct HostnameMock(String); impl MetadataProvider for HostnameMock { fn hostname(&self) -> Result<Option<String>> { Ok(Some(self.0.clone())) } } // write specified hostname to a file, then read it back fn try_write_hostname(hostname: &str) -> String { let mut temp = NamedTempFile::new().unwrap(); let provider = HostnameMock(hostname.into()); provider .write_hostname(temp.path().to_str().unwrap().into()) .unwrap(); let mut ret = String::new(); temp.read_to_string(&mut ret).unwrap(); ret.trim_end().into() } #[test] fn test_hostname_truncation() { // assume some maximum exists let maxlen = max_hostname_len().unwrap().unwrap(); let long_string = "helloworld" .chars() .cycle() .take(maxlen * 2) .collect::<String>(); // simple hostname assert_eq!(try_write_hostname("hostname7"), "hostname7"); // simple FQDN assert_eq!( try_write_hostname("hostname7.example.com"), "hostname7.example.com" ); // truncated simple hostname assert_eq!( try_write_hostname(&long_string[0..maxlen + 10]), long_string[0..maxlen] ); // truncated FQDN assert_eq!( try_write_hostname(&format!("{}.example.com", &long_string[0..maxlen + 5])), long_string[0..maxlen] ); // truncate to first dot assert_eq!( try_write_hostname(&format!("{}.example.com", &long_string[0..maxlen - 5])), long_string[0..maxlen - 5] ); // truncate to first dot even if we could truncate to second dot assert_eq!( try_write_hostname(&format!("{}.example.com", &long_string[0..maxlen - 10])), long_string[0..maxlen - 10] ); } }