diff --git a/proxmox-ve-config/src/firewall/parse.rs b/proxmox-ve-config/src/firewall/parse.rs index 772e081..b02f98d 100644 --- a/proxmox-ve-config/src/firewall/parse.rs +++ b/proxmox-ve-config/src/firewall/parse.rs @@ -52,6 +52,26 @@ pub fn match_non_whitespace(line: &str) -> Option<(&str, &str)> { Some((text, rest)) } } + +/// parses out all digits and returns the remainder +/// +/// returns [`None`] if the digit part would be empty +/// +/// Returns a tuple with the digits and the remainder (not trimmed). +pub fn match_digits(line: &str) -> Option<(&str, &str)> { + let split_position = line.as_bytes().iter().position(|&b| !b.is_ascii_digit()); + + let (digits, rest) = match split_position { + Some(pos) => line.split_at(pos), + None => (line, ""), + }; + + if !digits.is_empty() { + return Some((digits, rest)); + } + + None +} pub fn parse_bool(value: &str) -> Result { Ok( if value == "0" diff --git a/proxmox-ve-config/src/guest/mod.rs b/proxmox-ve-config/src/guest/mod.rs new file mode 100644 index 0000000..74fd8ab --- /dev/null +++ b/proxmox-ve-config/src/guest/mod.rs @@ -0,0 +1,115 @@ +use core::ops::Deref; +use std::collections::HashMap; + +use anyhow::{Context, Error}; +use serde::Deserialize; + +use proxmox_sys::nodename; +use types::Vmid; + +pub mod types; +pub mod vm; + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Deserialize)] +pub enum GuestType { + #[serde(rename = "qemu")] + Vm, + #[serde(rename = "lxc")] + Ct, +} + +impl GuestType { + pub fn iface_prefix(self) -> &'static str { + match self { + GuestType::Vm => "tap", + GuestType::Ct => "veth", + } + } + + fn config_folder(&self) -> &'static str { + match self { + GuestType::Vm => "qemu-server", + GuestType::Ct => "lxc", + } + } +} + +#[derive(Deserialize)] +pub struct GuestEntry { + node: String, + + #[serde(rename = "type")] + ty: GuestType, + + #[serde(rename = "version")] + _version: usize, +} + +impl GuestEntry { + pub fn new(node: String, ty: GuestType) -> Self { + Self { + node, + ty, + _version: Default::default(), + } + } + + pub fn is_local(&self) -> bool { + nodename() == self.node + } + + pub fn ty(&self) -> &GuestType { + &self.ty + } +} + +const VMLIST_CONFIG_PATH: &str = "/etc/pve/.vmlist"; + +#[derive(Deserialize)] +pub struct GuestMap { + #[serde(rename = "version")] + _version: usize, + #[serde(rename = "ids", default)] + guests: HashMap, +} + +impl From> for GuestMap { + fn from(guests: HashMap) -> Self { + Self { + guests, + _version: Default::default(), + } + } +} + +impl Deref for GuestMap { + type Target = HashMap; + + fn deref(&self) -> &Self::Target { + &self.guests + } +} + +impl GuestMap { + pub fn new() -> Result { + let data = std::fs::read(VMLIST_CONFIG_PATH) + .with_context(|| format!("failed to read guest map from {VMLIST_CONFIG_PATH}"))?; + + serde_json::from_slice(&data).with_context(|| "failed to parse guest map".to_owned()) + } + + pub fn firewall_config_path(vmid: &Vmid) -> String { + format!("/etc/pve/firewall/{}.fw", vmid) + } + + /// returns the local configuration path for a given Vmid. + /// + /// The caller must ensure that the given Vmid exists and is local to the node + pub fn config_path(vmid: &Vmid, entry: &GuestEntry) -> String { + format!( + "/etc/pve/local/{}/{}.conf", + entry.ty().config_folder(), + vmid + ) + } +} diff --git a/proxmox-ve-config/src/guest/types.rs b/proxmox-ve-config/src/guest/types.rs new file mode 100644 index 0000000..217c537 --- /dev/null +++ b/proxmox-ve-config/src/guest/types.rs @@ -0,0 +1,38 @@ +use std::fmt; +use std::str::FromStr; + +use anyhow::{format_err, Error}; + +#[derive(Clone, Copy, Debug, Eq, PartialEq, PartialOrd, Ord, Hash)] +pub struct Vmid(u32); + +impl Vmid { + pub fn new(id: u32) -> Self { + Vmid(id) + } +} + +impl From for Vmid { + fn from(value: u32) -> Self { + Self::new(value) + } +} + +impl fmt::Display for Vmid { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + fmt::Display::fmt(&self.0, f) + } +} + +impl FromStr for Vmid { + type Err = Error; + + fn from_str(s: &str) -> Result { + Ok(Self( + s.parse() + .map_err(|_| format_err!("not a valid vmid: {s:?}"))?, + )) + } +} + +serde_plain::derive_deserialize_from_fromstr!(Vmid, "valid vmid"); diff --git a/proxmox-ve-config/src/guest/vm.rs b/proxmox-ve-config/src/guest/vm.rs new file mode 100644 index 0000000..5b5866a --- /dev/null +++ b/proxmox-ve-config/src/guest/vm.rs @@ -0,0 +1,510 @@ +use anyhow::{bail, Error}; +use core::fmt::Display; +use std::io; +use std::str::FromStr; +use std::{collections::HashMap, net::Ipv6Addr}; + +use proxmox_schema::property_string::PropertyIterator; + +use crate::firewall::parse::{match_digits, parse_bool}; +use crate::firewall::types::address::{Ipv4Cidr, Ipv6Cidr}; + +#[derive(Debug)] +#[cfg_attr(test, derive(Eq, PartialEq))] +pub struct MacAddress([u8; 6]); + +static LOCAL_PART: [u8; 8] = [0xFE, 0x80, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00]; +static EUI64_MIDDLE_PART: [u8; 2] = [0xFF, 0xFE]; + +impl MacAddress { + /// generates a link local IPv6-address according to RFC 4291 (Appendix A) + pub fn eui64_link_local_address(&self) -> Ipv6Addr { + let head = &self.0[..3]; + let tail = &self.0[3..]; + + let mut eui64_address: Vec = LOCAL_PART + .iter() + .chain(head.iter()) + .chain(EUI64_MIDDLE_PART.iter()) + .chain(tail.iter()) + .copied() + .collect(); + + // we need to flip the 7th bit of the first eui64 byte + eui64_address[8] ^= 0x02; + + Ipv6Addr::from( + TryInto::<[u8; 16]>::try_into(eui64_address).expect("is an u8 array with 16 entries"), + ) + } +} + +impl FromStr for MacAddress { + type Err = Error; + + fn from_str(s: &str) -> Result { + let split = s.split(':'); + + let parsed = split + .into_iter() + .map(|elem| u8::from_str_radix(elem, 16)) + .collect::, _>>() + .map_err(Error::msg)?; + + if parsed.len() != 6 { + bail!("Invalid amount of elements in MAC address!"); + } + + let address = &parsed.as_slice()[0..6]; + Ok(Self(address.try_into().unwrap())) + } +} + +impl Display for MacAddress { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + f, + "{:<02X}:{:<02X}:{:<02X}:{:<02X}:{:<02X}:{:<02X}", + self.0[0], self.0[1], self.0[2], self.0[3], self.0[4], self.0[5] + ) + } +} + +#[derive(Debug, Clone, Copy)] +#[cfg_attr(test, derive(Eq, PartialEq))] +pub enum NetworkDeviceModel { + VirtIO, + Veth, + E1000, + Vmxnet3, + RTL8139, +} + +impl FromStr for NetworkDeviceModel { + type Err = Error; + + fn from_str(s: &str) -> Result { + match s { + "virtio" => Ok(NetworkDeviceModel::VirtIO), + "e1000" => Ok(NetworkDeviceModel::E1000), + "rtl8139" => Ok(NetworkDeviceModel::RTL8139), + "vmxnet3" => Ok(NetworkDeviceModel::Vmxnet3), + "veth" => Ok(NetworkDeviceModel::Veth), + _ => bail!("Invalid network device model: {s}"), + } + } +} + +#[derive(Debug)] +#[cfg_attr(test, derive(Eq, PartialEq))] +pub struct NetworkDevice { + model: NetworkDeviceModel, + mac_address: MacAddress, + firewall: bool, + ip: Option, + ip6: Option, +} + +impl NetworkDevice { + pub fn model(&self) -> NetworkDeviceModel { + self.model + } + + pub fn mac_address(&self) -> &MacAddress { + &self.mac_address + } + + pub fn ip(&self) -> Option<&Ipv4Cidr> { + self.ip.as_ref() + } + + pub fn ip6(&self) -> Option<&Ipv6Cidr> { + self.ip6.as_ref() + } + + pub fn has_firewall(&self) -> bool { + self.firewall + } +} + +impl FromStr for NetworkDevice { + type Err = Error; + + fn from_str(s: &str) -> Result { + let (mut ty, mut hwaddr, mut firewall, mut ip, mut ip6) = (None, None, true, None, None); + + for entry in PropertyIterator::new(s) { + let (key, value) = entry.unwrap(); + + if let Some(key) = key { + match key { + "type" | "model" => { + ty = Some(NetworkDeviceModel::from_str(&value)?); + } + "hwaddr" | "macaddr" => { + hwaddr = Some(MacAddress::from_str(&value)?); + } + "firewall" => { + firewall = parse_bool(&value)?; + } + "ip" => { + if value == "dhcp" { + continue; + } + + ip = Some(Ipv4Cidr::from_str(&value)?); + } + "ip6" => { + if value == "dhcp" || value == "auto" { + continue; + } + + ip6 = Some(Ipv6Cidr::from_str(&value)?); + } + _ => { + if let Ok(model) = NetworkDeviceModel::from_str(key) { + ty = Some(model); + hwaddr = Some(MacAddress::from_str(&value)?); + } + } + } + } + } + + if let (Some(ty), Some(hwaddr)) = (ty, hwaddr) { + return Ok(NetworkDevice { + model: ty, + mac_address: hwaddr, + firewall, + ip, + ip6, + }); + } + + bail!("No valid network device detected in string {s}"); + } +} + +#[derive(Debug, Default)] +#[cfg_attr(test, derive(Eq, PartialEq))] +pub struct NetworkConfig { + network_devices: HashMap, +} + +impl NetworkConfig { + pub fn new() -> Self { + Self::default() + } + + pub fn index_from_net_key(key: &str) -> Result { + if let Some(digits) = key.strip_prefix("net") { + if let Some((digits, rest)) = match_digits(digits) { + let index: i64 = digits.parse()?; + + if (0..31).contains(&index) && rest.is_empty() { + return Ok(index); + } + } + } + + bail!("No index found in net key string: {key}") + } + + pub fn network_devices(&self) -> &HashMap { + &self.network_devices + } + + pub fn parse(input: R) -> Result { + let mut network_devices = HashMap::new(); + + for line in input.lines() { + let line = line?; + let line = line.trim(); + + if line.is_empty() || line.starts_with('#') { + continue; + } + + if line.starts_with('[') { + break; + } + + if line.starts_with("net") { + log::trace!("parsing net config line: {line}"); + + if let Some((mut key, mut value)) = line.split_once(':') { + if key.is_empty() || value.is_empty() { + continue; + } + + key = key.trim(); + value = value.trim(); + + if let Ok(index) = Self::index_from_net_key(key) { + let network_device = NetworkDevice::from_str(value)?; + + let exists = network_devices.insert(index, network_device); + + if exists.is_some() { + bail!("Duplicated config key detected: {key}"); + } + } else { + bail!("Encountered invalid net key in cfg: {key}"); + } + } + } + } + + Ok(Self { network_devices }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_mac_address() { + for input in [ + "aa:aa:aa:11:22:33", + "AA:BB:FF:11:22:33", + "bc:24:11:AA:bb:Ef", + ] { + let mac_address = input.parse::().expect("valid mac address"); + + assert_eq!(input.to_uppercase(), mac_address.to_string()); + } + + for input in [ + "aa:aa:aa:11:22:33:aa", + "AA:BB:FF:11:22", + "AA:BB:GG:11:22:33", + "AABBGG112233", + "", + ] { + input + .parse::() + .expect_err("invalid mac address"); + } + } + + #[test] + fn test_eui64_link_local_address() { + let mac_address: MacAddress = "BC:24:11:49:8D:75".parse().expect("valid MAC address"); + + let link_local_address = + Ipv6Addr::from_str("fe80::be24:11ff:fe49:8d75").expect("valid IPv6 address"); + + assert_eq!(link_local_address, mac_address.eui64_link_local_address()); + } + + #[test] + fn test_parse_network_device() { + let mut network_device: NetworkDevice = + "virtio=AA:AA:AA:17:19:81,bridge=public,firewall=1,queues=4" + .parse() + .expect("valid network configuration"); + + assert_eq!( + network_device, + NetworkDevice { + model: NetworkDeviceModel::VirtIO, + mac_address: MacAddress([0xAA, 0xAA, 0xAA, 0x17, 0x19, 0x81]), + firewall: true, + ip: None, + ip6: None, + } + ); + + network_device = "model=virtio,macaddr=AA:AA:AA:17:19:81,bridge=public,firewall=1,queues=4" + .parse() + .expect("valid network configuration"); + + assert_eq!( + network_device, + NetworkDevice { + model: NetworkDeviceModel::VirtIO, + mac_address: MacAddress([0xAA, 0xAA, 0xAA, 0x17, 0x19, 0x81]), + firewall: true, + ip: None, + ip6: None, + } + ); + + network_device = + "name=eth0,bridge=public,firewall=0,hwaddr=AA:AA:AA:E2:3E:24,ip=dhcp,type=veth" + .parse() + .expect("valid network configuration"); + + assert_eq!( + network_device, + NetworkDevice { + model: NetworkDeviceModel::Veth, + mac_address: MacAddress([0xAA, 0xAA, 0xAA, 0xE2, 0x3E, 0x24]), + firewall: false, + ip: None, + ip6: None, + } + ); + + "model=virtio" + .parse::() + .expect_err("invalid network configuration"); + + "bridge=public,firewall=0" + .parse::() + .expect_err("invalid network configuration"); + + "".parse::() + .expect_err("invalid network configuration"); + + "name=eth0,bridge=public,firewall=0,hwaddr=AA:AA:AG:E2:3E:24,ip=dhcp,type=veth" + .parse::() + .expect_err("invalid network configuration"); + } + + #[test] + fn test_parse_network_confg() { + let mut guest_config = "\ +boot: order=scsi0;net0 +cores: 4 +cpu: host +memory: 8192 +meta: creation-qemu=8.0.2,ctime=1700141675 +name: hoan-sdn +net0: virtio=AA:BB:CC:F2:FE:75,bridge=public +numa: 0 +ostype: l26 +parent: uwu +scsi0: local-lvm:vm-999-disk-0,discard=on,iothread=1,size=32G +scsihw: virtio-scsi-single +smbios1: uuid=addb0cc6-0393-4269-a504-1eb46604cb8a +sockets: 1 +vmgenid: 13bcbb05-3608-4d74-bf4f-d5d20c3538e8 + +[snapshot] +boot: order=scsi0;ide2;net0 +cores: 4 +cpu: x86-64-v2-AES +ide2: NFS-iso:iso/proxmox-ve_8.0-2.iso,media=cdrom,size=1166488K +memory: 8192 +meta: creation-qemu=8.0.2,ctime=1700141675 +name: test +net2: virtio=AA:AA:AA:F2:FE:75,bridge=public,firewall=1 +numa: 0 +ostype: l26 +parent: pre-SDN +scsi0: local-lvm:vm-999-disk-0,discard=on,iothread=1,size=32G +scsihw: virtio-scsi-single +smbios1: uuid=addb0cc6-0393-4269-a504-1eb46604cb8a +snaptime: 1700143513 +sockets: 1 +vmgenid: 706fbe99-d28b-4047-a9cd-3677c859ca8a + +[snapshott] +boot: order=scsi0;ide2;net0 +cores: 4 +cpu: host +ide2: NFS-iso:iso/proxmox-ve_8.0-2.iso,media=cdrom,size=1166488K +memory: 8192 +meta: creation-qemu=8.0.2,ctime=1700141675 +name: hoan-sdn +net0: virtio=AA:AA:FF:F2:FE:75,bridge=public,firewall=0 +numa: 0 +ostype: l26 +parent: SDN +scsi0: local-lvm:vm-999-disk-0,discard=on,iothread=1,size=32G +scsihw: virtio-scsi-single +smbios1: uuid=addb0cc6-0393-4269-a504-1eb46604cb8a +snaptime: 1700158473 +sockets: 1 +vmgenid: 706fbe99-d28b-4047-a9cd-3677c859ca8a" + .as_bytes(); + + let mut network_config: NetworkConfig = + NetworkConfig::parse(guest_config).expect("valid network configuration"); + + assert_eq!(network_config.network_devices().len(), 1); + + assert_eq!( + network_config.network_devices()[&0], + NetworkDevice { + model: NetworkDeviceModel::VirtIO, + mac_address: MacAddress([0xAA, 0xBB, 0xCC, 0xF2, 0xFE, 0x75]), + firewall: true, + ip: None, + ip6: None, + } + ); + + guest_config = "\ +arch: amd64 +cores: 1 +features: nesting=1 +hostname: dnsct +memory: 512 +net0: name=eth0,bridge=data,firewall=1,hwaddr=BC:24:11:47:83:11,ip=dhcp,type=veth +net2: name=eth0,bridge=data,firewall=0,hwaddr=BC:24:11:47:83:12,ip=123.123.123.123/24,type=veth +net5: name=eth0,bridge=data,firewall=1,hwaddr=BC:24:11:47:83:13,ip6=fd80::1/64,type=veth +ostype: alpine +rootfs: local-lvm:vm-10001-disk-0,size=1G +swap: 512 +unprivileged: 1" + .as_bytes(); + + network_config = NetworkConfig::parse(guest_config).expect("valid network configuration"); + + assert_eq!(network_config.network_devices().len(), 3); + + assert_eq!( + network_config.network_devices()[&0], + NetworkDevice { + model: NetworkDeviceModel::Veth, + mac_address: MacAddress([0xBC, 0x24, 0x11, 0x47, 0x83, 0x11]), + firewall: true, + ip: None, + ip6: None, + } + ); + + assert_eq!( + network_config.network_devices()[&2], + NetworkDevice { + model: NetworkDeviceModel::Veth, + mac_address: MacAddress([0xBC, 0x24, 0x11, 0x47, 0x83, 0x12]), + firewall: false, + ip: Some(Ipv4Cidr::from_str("123.123.123.123/24").expect("valid ipv4")), + ip6: None, + } + ); + + assert_eq!( + network_config.network_devices()[&5], + NetworkDevice { + model: NetworkDeviceModel::Veth, + mac_address: MacAddress([0xBC, 0x24, 0x11, 0x47, 0x83, 0x13]), + firewall: true, + ip: None, + ip6: Some(Ipv6Cidr::from_str("fd80::1/64").expect("valid ipv6")), + } + ); + + NetworkConfig::parse( + "netqwe: name=eth0,bridge=data,firewall=1,hwaddr=BC:24:11:47:83:11,ip=dhcp,type=veth" + .as_bytes(), + ) + .expect_err("invalid net key"); + + NetworkConfig::parse( + "net0 name=eth0,bridge=data,firewall=1,hwaddr=BC:24:11:47:83:11,ip=dhcp,type=veth" + .as_bytes(), + ) + .expect_err("invalid net key"); + + NetworkConfig::parse( + "net33: name=eth0,bridge=data,firewall=1,hwaddr=BC:24:11:47:83:11,ip=dhcp,type=veth" + .as_bytes(), + ) + .expect_err("invalid net key"); + } +} diff --git a/proxmox-ve-config/src/lib.rs b/proxmox-ve-config/src/lib.rs index 2bf9352..856b14f 100644 --- a/proxmox-ve-config/src/lib.rs +++ b/proxmox-ve-config/src/lib.rs @@ -1,2 +1,3 @@ pub mod firewall; +pub mod guest; pub mod host;