From 6bb74338b42e69ac2ac35e917b02865c5a1bf1fd Mon Sep 17 00:00:00 2001 From: Dietmar Maurer Date: Thu, 30 May 2024 09:23:08 +0200 Subject: [PATCH] network-api: new crate, split out from system-management-api Signed-off-by: Dietmar Maurer --- Cargo.toml | 1 + proxmox-network-api/Cargo.toml | 35 + proxmox-network-api/debian/changelog | 6 + proxmox-network-api/debian/copyright | 18 + proxmox-network-api/debian/debcargo.toml | 7 + proxmox-network-api/src/api_impl.rs | 347 +++++++++ proxmox-network-api/src/api_types.rs | 532 ++++++++++++++ proxmox-network-api/src/config/helper.rs | 232 ++++++ proxmox-network-api/src/config/lexer.rs | 139 ++++ proxmox-network-api/src/config/mod.rs | 723 +++++++++++++++++++ proxmox-network-api/src/config/parser.rs | 862 +++++++++++++++++++++++ proxmox-network-api/src/lib.rs | 12 + 12 files changed, 2914 insertions(+) create mode 100644 proxmox-network-api/Cargo.toml create mode 100644 proxmox-network-api/debian/changelog create mode 100644 proxmox-network-api/debian/copyright create mode 100644 proxmox-network-api/debian/debcargo.toml create mode 100644 proxmox-network-api/src/api_impl.rs create mode 100644 proxmox-network-api/src/api_types.rs create mode 100644 proxmox-network-api/src/config/helper.rs create mode 100644 proxmox-network-api/src/config/lexer.rs create mode 100644 proxmox-network-api/src/config/mod.rs create mode 100644 proxmox-network-api/src/config/parser.rs create mode 100644 proxmox-network-api/src/lib.rs diff --git a/Cargo.toml b/Cargo.toml index 899098f9..43640c66 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,6 +19,7 @@ members = [ "proxmox-ldap", "proxmox-login", "proxmox-metrics", + "proxmox-network-api", "proxmox-notify", "proxmox-openid", "proxmox-product-config", diff --git a/proxmox-network-api/Cargo.toml b/proxmox-network-api/Cargo.toml new file mode 100644 index 00000000..c4065920 --- /dev/null +++ b/proxmox-network-api/Cargo.toml @@ -0,0 +1,35 @@ +[package] +name = "proxmox-network-api" +version = "0.1.0" +authors.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true +exclude.workspace = true +description = "Network Management API implementation" + +[dependencies] +anyhow.workspace = true +const_format.workspace = true +lazy_static.workspace = true +regex.workspace = true + +serde = { workspace = true, features = ["derive"] } +nix = { workspace = true, optional = true } +libc = { workspace = true, optional = true } + +proxmox-sys = { workspace = true, optional = true } +proxmox-schema = { workspace = true, features = ["api-macro", "api-types"] } +proxmox-config-digest = { workspace = true, optional = true } +proxmox-product-config = { workspace = true, optional = true } + +[features] +default = [] +impl = [ + "dep:proxmox-config-digest", + "proxmox-config-digest?/openssl", + "dep:proxmox-product-config", + "dep:nix", + "dep:libc", + "dep:proxmox-sys", +] diff --git a/proxmox-network-api/debian/changelog b/proxmox-network-api/debian/changelog new file mode 100644 index 00000000..573863d4 --- /dev/null +++ b/proxmox-network-api/debian/changelog @@ -0,0 +1,6 @@ +rust-proxmox-network-api (0.1.0-1) bookworm; urgency=medium + + * initial packaging (split out from proxmox-system-management-api) + + -- Proxmox Support Team Thu, 30 May 2024 09:10:54 +0200 + diff --git a/proxmox-network-api/debian/copyright b/proxmox-network-api/debian/copyright new file mode 100644 index 00000000..0d9eab3e --- /dev/null +++ b/proxmox-network-api/debian/copyright @@ -0,0 +1,18 @@ +Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/ + +Files: + * +Copyright: 2019 - 2023 Proxmox Server Solutions GmbH +License: AGPL-3.0-or-later + This program is free software: you can redistribute it and/or modify it under + the terms of the GNU Affero General Public License as published by the Free + Software Foundation, either version 3 of the License, or (at your option) any + later version. + . + This program is distributed in the hope that it will be useful, but WITHOUT + ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS + FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more + details. + . + You should have received a copy of the GNU Affero General Public License along + with this program. If not, see . diff --git a/proxmox-network-api/debian/debcargo.toml b/proxmox-network-api/debian/debcargo.toml new file mode 100644 index 00000000..b7864cdb --- /dev/null +++ b/proxmox-network-api/debian/debcargo.toml @@ -0,0 +1,7 @@ +overlay = "." +crate_src_path = ".." +maintainer = "Proxmox Support Team " + +[source] +vcs_git = "git://git.proxmox.com/git/proxmox.git" +vcs_browser = "https://git.proxmox.com/?p=proxmox.git" diff --git a/proxmox-network-api/src/api_impl.rs b/proxmox-network-api/src/api_impl.rs new file mode 100644 index 00000000..b3b9ec53 --- /dev/null +++ b/proxmox-network-api/src/api_impl.rs @@ -0,0 +1,347 @@ +use anyhow::{bail, Error}; + +use proxmox_config_digest::ConfigDigest; + +use crate::{parse_vlan_id_from_name, parse_vlan_raw_device_from_name}; +use crate::{ + DeletableInterfaceProperty, Interface, InterfaceUpdater, LinuxBondMode, NetworkConfigMethod, + NetworkInterfaceType, +}; + +/// Create network interface configuration. +pub fn create_interface(iface: String, config: InterfaceUpdater) -> Result<(), Error> { + let interface_type = match config.interface_type { + Some(interface_type) => interface_type, + None => bail!("missing parameter 'type'"), + }; + + let _lock = crate::lock_config()?; + + let (mut network_config, _digest) = crate::config()?; + + if network_config.interfaces.contains_key(&iface) { + bail!("interface '{}' already exists", iface); + } + + let mut interface = Interface::new(iface.clone()); + interface.interface_type = interface_type; + + if let Some(autostart) = config.autostart { + interface.autostart = autostart; + } + if config.method.is_some() { + interface.method = config.method; + } + if config.method6.is_some() { + interface.method6 = config.method6; + } + if config.mtu.is_some() { + interface.mtu = config.mtu; + } + if config.comments.is_some() { + interface.comments = config.comments; + } + if config.comments6.is_some() { + interface.comments6 = config.comments6; + } + + if let Some(cidr) = config.cidr { + let (_, _, is_v6) = crate::parse_cidr(&cidr)?; + if is_v6 { + bail!("invalid address type (expected IPv4, got IPv6)"); + } + interface.cidr = Some(cidr); + } + + if let Some(cidr6) = config.cidr6 { + let (_, _, is_v6) = crate::parse_cidr(&cidr6)?; + if !is_v6 { + bail!("invalid address type (expected IPv6, got IPv4)"); + } + interface.cidr6 = Some(cidr6); + } + + if let Some(gateway) = config.gateway { + let is_v6 = gateway.contains(':'); + if is_v6 { + bail!("invalid address type (expected IPv4, got IPv6)"); + } + network_config.check_duplicate_gateway_v4(&iface)?; + interface.gateway = Some(gateway); + } + + if let Some(gateway6) = config.gateway6 { + let is_v6 = gateway6.contains(':'); + if !is_v6 { + bail!("invalid address type (expected IPv6, got IPv4)"); + } + network_config.check_duplicate_gateway_v6(&iface)?; + interface.gateway6 = Some(gateway6); + } + + match interface_type { + NetworkInterfaceType::Bridge => { + if let Some(ports) = &config.bridge_ports { + interface.set_bridge_port_list(ports)?; + } + if config.bridge_vlan_aware.is_some() { + interface.bridge_vlan_aware = config.bridge_vlan_aware; + } + } + NetworkInterfaceType::Bond => { + if let Some(mode) = config.bond_mode { + interface.bond_mode = config.bond_mode; + if config.bond_primary.is_some() { + if mode != LinuxBondMode::ActiveBackup { + bail!("bond-primary is only valid with Active/Backup mode"); + } + interface.bond_primary = config.bond_primary; + } + if config.bond_xmit_hash_policy.is_some() { + if mode != LinuxBondMode::Ieee802_3ad && mode != LinuxBondMode::BalanceXor { + bail!("bond_xmit_hash_policy is only valid with LACP(802.3ad) or balance-xor mode"); + } + interface.bond_xmit_hash_policy = config.bond_xmit_hash_policy; + } + } + if let Some(slaves) = &config.slaves { + interface.set_bond_slave_list(&slaves)?; + } + } + NetworkInterfaceType::Vlan => { + if config.vlan_id.is_none() && parse_vlan_id_from_name(&iface).is_none() { + bail!("vlan-id must be set"); + } + interface.vlan_id = config.vlan_id; + + if let Some(dev) = config + .vlan_raw_device + .as_deref() + .or_else(|| parse_vlan_raw_device_from_name(&iface)) + { + if !network_config.interfaces.contains_key(dev) { + bail!("vlan-raw-device {dev} does not exist"); + } + } else { + bail!("vlan-raw-device must be set"); + } + interface.vlan_raw_device = config.vlan_raw_device; + } + _ => bail!( + "creating network interface type '{:?}' is not supported", + interface_type + ), + } + + if interface.cidr.is_some() || interface.gateway.is_some() { + interface.method = Some(NetworkConfigMethod::Static); + } else if interface.method.is_none() { + interface.method = Some(NetworkConfigMethod::Manual); + } + + if interface.cidr6.is_some() || interface.gateway6.is_some() { + interface.method6 = Some(NetworkConfigMethod::Static); + } else if interface.method6.is_none() { + interface.method6 = Some(NetworkConfigMethod::Manual); + } + + network_config.interfaces.insert(iface, interface); + + crate::save_config(&network_config)?; + + Ok(()) +} + +/// Update network interface config. +pub fn update_interface( + iface: String, + update: InterfaceUpdater, + delete: Option>, + digest: Option, +) -> Result<(), Error> { + let _lock = crate::lock_config()?; + + let (mut network_config, expected_digest) = crate::config()?; + + expected_digest.detect_modification(digest.as_ref())?; + + if update.gateway.is_some() { + network_config.check_duplicate_gateway_v4(&iface)?; + } + if update.gateway6.is_some() { + network_config.check_duplicate_gateway_v6(&iface)?; + } + + if let Some(dev) = update + .vlan_raw_device + .as_deref() + .or_else(|| parse_vlan_raw_device_from_name(&iface)) + { + if !network_config.interfaces.contains_key(dev) { + bail!("vlan-raw-device {dev} does not exist"); + } + } + + let interface = network_config.lookup_mut(&iface)?; + + if let Some(interface_type) = update.interface_type { + if interface_type != interface.interface_type { + bail!( + "got unexpected interface type ({:?} != {:?})", + interface_type, + interface.interface_type + ); + } + } + + if let Some(delete) = delete { + for delete_prop in delete { + match delete_prop { + DeletableInterfaceProperty::Cidr => { + interface.cidr = None; + } + DeletableInterfaceProperty::Cidr6 => { + interface.cidr6 = None; + } + DeletableInterfaceProperty::Gateway => { + interface.gateway = None; + } + DeletableInterfaceProperty::Gateway6 => { + interface.gateway6 = None; + } + DeletableInterfaceProperty::Method => { + interface.method = None; + } + DeletableInterfaceProperty::Method6 => { + interface.method6 = None; + } + DeletableInterfaceProperty::Comments => { + interface.comments = None; + } + DeletableInterfaceProperty::Comments6 => { + interface.comments6 = None; + } + DeletableInterfaceProperty::Mtu => { + interface.mtu = None; + } + DeletableInterfaceProperty::Autostart => { + interface.autostart = false; + } + DeletableInterfaceProperty::BridgePorts => { + interface.set_bridge_ports(Vec::new())?; + } + DeletableInterfaceProperty::BridgeVlanAware => { + interface.bridge_vlan_aware = None; + } + DeletableInterfaceProperty::Slaves => { + interface.set_bond_slaves(Vec::new())?; + } + DeletableInterfaceProperty::BondPrimary => { + interface.bond_primary = None; + } + DeletableInterfaceProperty::BondXmitHashPolicy => { + interface.bond_xmit_hash_policy = None + } + } + } + } + + if let Some(autostart) = update.autostart { + interface.autostart = autostart; + } + if update.method.is_some() { + interface.method = update.method; + } + if update.method6.is_some() { + interface.method6 = update.method6; + } + if update.mtu.is_some() { + interface.mtu = update.mtu; + } + if let Some(ports) = &update.bridge_ports { + interface.set_bridge_port_list(ports)?; + } + if update.bridge_vlan_aware.is_some() { + interface.bridge_vlan_aware = update.bridge_vlan_aware; + } + if let Some(slaves) = &update.slaves { + interface.set_bond_slave_list(slaves)?; + } + if let Some(mode) = update.bond_mode { + interface.bond_mode = update.bond_mode; + if update.bond_primary.is_some() { + if mode != LinuxBondMode::ActiveBackup { + bail!("bond-primary is only valid with Active/Backup mode"); + } + interface.bond_primary = update.bond_primary; + } + if update.bond_xmit_hash_policy.is_some() { + if mode != LinuxBondMode::Ieee802_3ad && mode != LinuxBondMode::BalanceXor { + bail!("bond_xmit_hash_policy is only valid with LACP(802.3ad) or balance-xor mode"); + } + interface.bond_xmit_hash_policy = update.bond_xmit_hash_policy; + } + } + + if let Some(cidr) = update.cidr { + let (_, _, is_v6) = crate::parse_cidr(&cidr)?; + if is_v6 { + bail!("invalid address type (expected IPv4, got IPv6)"); + } + interface.cidr = Some(cidr); + } + + if let Some(cidr6) = update.cidr6 { + let (_, _, is_v6) = crate::parse_cidr(&cidr6)?; + if !is_v6 { + bail!("invalid address type (expected IPv6, got IPv4)"); + } + interface.cidr6 = Some(cidr6); + } + + if let Some(gateway) = update.gateway { + let is_v6 = gateway.contains(':'); + if is_v6 { + bail!("invalid address type (expected IPv4, got IPv6)"); + } + interface.gateway = Some(gateway); + } + + if let Some(gateway6) = update.gateway6 { + let is_v6 = gateway6.contains(':'); + if !is_v6 { + bail!("invalid address type (expected IPv6, got IPv4)"); + } + interface.gateway6 = Some(gateway6); + } + + if update.comments.is_some() { + interface.comments = update.comments; + } + if update.comments6.is_some() { + interface.comments6 = update.comments6; + } + + if interface.cidr.is_some() || interface.gateway.is_some() { + interface.method = Some(NetworkConfigMethod::Static); + } else { + interface.method = Some(NetworkConfigMethod::Manual); + } + + if interface.cidr6.is_some() || interface.gateway6.is_some() { + interface.method6 = Some(NetworkConfigMethod::Static); + } else { + interface.method6 = Some(NetworkConfigMethod::Manual); + } + + if update.vlan_id.is_some() { + interface.vlan_id = update.vlan_id; + } + if update.vlan_raw_device.is_some() { + interface.vlan_raw_device = update.vlan_raw_device; + } + + crate::save_config(&network_config)?; + + Ok(()) +} diff --git a/proxmox-network-api/src/api_types.rs b/proxmox-network-api/src/api_types.rs new file mode 100644 index 00000000..cc90a8f8 --- /dev/null +++ b/proxmox-network-api/src/api_types.rs @@ -0,0 +1,532 @@ +use std::fmt; + +use anyhow::{bail, Error}; +use serde::{Deserialize, Serialize}; + +use lazy_static::lazy_static; +use regex::Regex; + +use proxmox_schema::api; +use proxmox_schema::ApiStringFormat; +use proxmox_schema::ArraySchema; +use proxmox_schema::Schema; +use proxmox_schema::StringSchema; +use proxmox_schema::api_types::SAFE_ID_REGEX; +use proxmox_schema::api_types::{IP_V4_SCHEMA, IP_V6_SCHEMA}; +use proxmox_schema::api_types::{CIDR_V4_SCHEMA, CIDR_V6_SCHEMA}; + +lazy_static! { + pub static ref PHYSICAL_NIC_REGEX: Regex = Regex::new(r"^(?:eth\d+|en[^:.]+|ib\d+)$").unwrap(); + pub static ref VLAN_INTERFACE_REGEX: Regex = + Regex::new(r"^(?P\S+)\.(?P\d+)|vlan(?P\d+)$").unwrap(); +} + +pub const NETWORK_INTERFACE_FORMAT: ApiStringFormat = + ApiStringFormat::Pattern(&SAFE_ID_REGEX); + +#[api()] +#[derive(Debug, Copy, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +/// Interface configuration method +pub enum NetworkConfigMethod { + /// Configuration is done manually using other tools + Manual, + /// Define interfaces with statically allocated addresses. + Static, + /// Obtain an address via DHCP + DHCP, + /// Define the loopback interface. + Loopback, +} + +#[api()] +#[derive(Debug, Copy, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +#[repr(u8)] +/// Linux Bond Mode +pub enum LinuxBondMode { + /// Round-robin policy + BalanceRr = 0, + /// Active-backup policy + ActiveBackup = 1, + /// XOR policy + BalanceXor = 2, + /// Broadcast policy + Broadcast = 3, + /// IEEE 802.3ad Dynamic link aggregation + #[serde(rename = "802.3ad")] + Ieee802_3ad = 4, + /// Adaptive transmit load balancing + BalanceTlb = 5, + /// Adaptive load balancing + BalanceAlb = 6, +} + +impl fmt::Display for LinuxBondMode { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + f.write_str(match self { + LinuxBondMode::BalanceRr => "balance-rr", + LinuxBondMode::ActiveBackup => "active-backup", + LinuxBondMode::BalanceXor => "balance-xor", + LinuxBondMode::Broadcast => "broadcast", + LinuxBondMode::Ieee802_3ad => "802.3ad", + LinuxBondMode::BalanceTlb => "balance-tlb", + LinuxBondMode::BalanceAlb => "balance-alb", + }) + } +} + +#[api()] +#[derive(Debug, Copy, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +#[repr(u8)] +/// Bond Transmit Hash Policy for LACP (802.3ad) +pub enum BondXmitHashPolicy { + /// Layer 2 + Layer2 = 0, + /// Layer 2+3 + #[serde(rename = "layer2+3")] + Layer2_3 = 1, + /// Layer 3+4 + #[serde(rename = "layer3+4")] + Layer3_4 = 2, +} + +impl fmt::Display for BondXmitHashPolicy { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + f.write_str(match self { + BondXmitHashPolicy::Layer2 => "layer2", + BondXmitHashPolicy::Layer2_3 => "layer2+3", + BondXmitHashPolicy::Layer3_4 => "layer3+4", + }) + } +} + +#[api()] +#[derive(Debug, Copy, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +/// Network interface type +pub enum NetworkInterfaceType { + /// Loopback + Loopback, + /// Physical Ethernet device + Eth, + /// Linux Bridge + Bridge, + /// Linux Bond + Bond, + /// Linux VLAN (eth.10) + Vlan, + /// Interface Alias (eth:1) + Alias, + /// Unknown interface type + Unknown, +} + +pub const NETWORK_INTERFACE_NAME_SCHEMA: Schema = StringSchema::new("Network interface name.") + .format(&NETWORK_INTERFACE_FORMAT) + .min_length(1) + .max_length(15) // libc::IFNAMSIZ-1 + .schema(); + +pub const NETWORK_INTERFACE_ARRAY_SCHEMA: Schema = + ArraySchema::new("Network interface list.", &NETWORK_INTERFACE_NAME_SCHEMA).schema(); + +pub const NETWORK_INTERFACE_LIST_SCHEMA: Schema = + StringSchema::new("A list of network devices, comma separated.") + .format(&ApiStringFormat::PropertyString( + &NETWORK_INTERFACE_ARRAY_SCHEMA, + )) + .schema(); + +#[api( + properties: { + name: { + schema: NETWORK_INTERFACE_NAME_SCHEMA, + }, + "type": { + type: NetworkInterfaceType, + }, + method: { + type: NetworkConfigMethod, + optional: true, + }, + method6: { + type: NetworkConfigMethod, + optional: true, + }, + cidr: { + schema: CIDR_V4_SCHEMA, + optional: true, + }, + cidr6: { + schema: CIDR_V6_SCHEMA, + optional: true, + }, + gateway: { + schema: IP_V4_SCHEMA, + optional: true, + }, + gateway6: { + schema: IP_V6_SCHEMA, + optional: true, + }, + options: { + description: "Option list (inet)", + type: Array, + items: { + description: "Optional attribute line.", + type: String, + }, + }, + options6: { + description: "Option list (inet6)", + type: Array, + items: { + description: "Optional attribute line.", + type: String, + }, + }, + comments: { + description: "Comments (inet, may span multiple lines)", + type: String, + optional: true, + }, + comments6: { + description: "Comments (inet6, may span multiple lines)", + type: String, + optional: true, + }, + bridge_ports: { + schema: NETWORK_INTERFACE_ARRAY_SCHEMA, + optional: true, + }, + slaves: { + schema: NETWORK_INTERFACE_ARRAY_SCHEMA, + optional: true, + }, + "vlan-id": { + description: "VLAN ID.", + type: u16, + optional: true, + }, + "vlan-raw-device": { + schema: NETWORK_INTERFACE_NAME_SCHEMA, + optional: true, + }, + bond_mode: { + type: LinuxBondMode, + optional: true, + }, + "bond-primary": { + schema: NETWORK_INTERFACE_NAME_SCHEMA, + optional: true, + }, + bond_xmit_hash_policy: { + type: BondXmitHashPolicy, + optional: true, + }, + } +)] +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +/// Network Interface configuration +pub struct Interface { + /// Autostart interface + pub autostart: bool, + /// Interface is active (UP) + pub active: bool, + /// Interface name + pub name: String, + /// Interface type + #[serde(rename = "type")] + pub interface_type: NetworkInterfaceType, + #[serde(skip_serializing_if = "Option::is_none")] + pub method: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub method6: Option, + #[serde(skip_serializing_if = "Option::is_none")] + /// IPv4 address with netmask + pub cidr: Option, + #[serde(skip_serializing_if = "Option::is_none")] + /// IPv4 gateway + pub gateway: Option, + #[serde(skip_serializing_if = "Option::is_none")] + /// IPv6 address with netmask + pub cidr6: Option, + #[serde(skip_serializing_if = "Option::is_none")] + /// IPv6 gateway + pub gateway6: Option, + + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub options: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub options6: Vec, + + #[serde(skip_serializing_if = "Option::is_none")] + pub comments: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub comments6: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + /// Maximum Transmission Unit + pub mtu: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub bridge_ports: Option>, + /// Enable bridge vlan support. + #[serde(skip_serializing_if = "Option::is_none")] + pub bridge_vlan_aware: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[serde(rename = "vlan-id")] + pub vlan_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[serde(rename = "vlan-raw-device")] + pub vlan_raw_device: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub slaves: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub bond_mode: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[serde(rename = "bond-primary")] + pub bond_primary: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub bond_xmit_hash_policy: Option, +} + +impl Interface { + pub fn new(name: String) -> Self { + Self { + name, + interface_type: NetworkInterfaceType::Unknown, + autostart: false, + active: false, + method: None, + method6: None, + cidr: None, + gateway: None, + cidr6: None, + gateway6: None, + options: Vec::new(), + options6: Vec::new(), + comments: None, + comments6: None, + mtu: None, + bridge_ports: None, + bridge_vlan_aware: None, + vlan_id: None, + vlan_raw_device: None, + slaves: None, + bond_mode: None, + bond_primary: None, + bond_xmit_hash_policy: None, + } + } + + /// Setter for bridge ports (check if interface type is a bridge) + pub fn set_bridge_ports(&mut self, ports: Vec) -> Result<(), Error> { + if self.interface_type != NetworkInterfaceType::Bridge { + bail!( + "interface '{}' is no bridge (type is {:?})", + self.name, + self.interface_type + ); + } + self.bridge_ports = Some(ports); + Ok(()) + } + + /// Setter for bridge ports (check if interface type is a bridge) + pub fn set_bridge_port_list(&mut self, ports: &str) -> Result<(), Error> { + let ports = Self::split_interface_list(ports)?; + self.set_bridge_ports(ports) + } + + /// Setter for bond slaves (check if interface type is a bond) + pub fn set_bond_slaves(&mut self, slaves: Vec) -> Result<(), Error> { + if self.interface_type != NetworkInterfaceType::Bond { + bail!( + "interface '{}' is no bond (type is {:?})", + self.name, + self.interface_type + ); + } + self.slaves = Some(slaves); + Ok(()) + } + + /// Setter for bond slaves (check if interface type is a bond) + pub fn set_bond_slave_list(&mut self, slaves: &str) -> Result<(), Error> { + let slaves = Self::split_interface_list(slaves)?; + self.set_bond_slaves(slaves) + } + + /// Split a network interface list into an array of interface names. + pub fn split_interface_list(list: &str) -> Result, Error> { + let value = NETWORK_INTERFACE_ARRAY_SCHEMA.parse_property_string(list)?; + Ok(value + .as_array() + .unwrap() + .iter() + .map(|v| v.as_str().unwrap().to_string()) + .collect()) + } +} + +#[api()] +#[derive(Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +/// Deletable property name +pub enum DeletableInterfaceProperty { + /// Delete the IPv4 address property. + Cidr, + /// Delete the IPv6 address property. + Cidr6, + /// Delete the IPv4 gateway property. + Gateway, + /// Delete the IPv6 gateway property. + Gateway6, + /// Delete the whole IPv4 configuration entry. + Method, + /// Delete the whole IPv6 configuration entry. + Method6, + /// Delete IPv4 comments + Comments, + /// Delete IPv6 comments + Comments6, + /// Delete mtu. + Mtu, + /// Delete autostart flag + Autostart, + /// Delete bridge ports (set to 'none') + #[serde(rename = "bridge_ports")] + BridgePorts, + /// Delete bridge-vlan-aware flag + #[serde(rename = "bridge_vlan_aware")] + BridgeVlanAware, + /// Delete bond-slaves (set to 'none') + Slaves, + /// Delete bond-primary + BondPrimary, + /// Delete bond transmit hash policy + #[serde(rename = "bond_xmit_hash_policy")] + BondXmitHashPolicy, +} + +#[api( + properties: { + "type": { + type: NetworkInterfaceType, + optional: true, + }, + autostart: { + description: "Autostart interface.", + type: bool, + optional: true, + }, + method: { + type: NetworkConfigMethod, + optional: true, + }, + method6: { + type: NetworkConfigMethod, + optional: true, + }, + comments: { + description: "Comments (inet, may span multiple lines)", + type: String, + optional: true, + }, + comments6: { + description: "Comments (inet5, may span multiple lines)", + type: String, + optional: true, + }, + cidr: { + schema: CIDR_V4_SCHEMA, + optional: true, + }, + cidr6: { + schema: CIDR_V6_SCHEMA, + optional: true, + }, + gateway: { + schema: IP_V4_SCHEMA, + optional: true, + }, + gateway6: { + schema: IP_V6_SCHEMA, + optional: true, + }, + mtu: { + description: "Maximum Transmission Unit.", + optional: true, + minimum: 46, + maximum: 65535, + default: 1500, + }, + bridge_ports: { + schema: NETWORK_INTERFACE_LIST_SCHEMA, + optional: true, + }, + bridge_vlan_aware: { + description: "Enable bridge vlan support.", + type: bool, + optional: true, + }, + "vlan-id": { + description: "VLAN ID.", + type: u16, + optional: true, + }, + "vlan-raw-device": { + schema: NETWORK_INTERFACE_NAME_SCHEMA, + optional: true, + }, + bond_mode: { + type: LinuxBondMode, + optional: true, + }, + "bond-primary": { + schema: NETWORK_INTERFACE_NAME_SCHEMA, + optional: true, + }, + bond_xmit_hash_policy: { + type: BondXmitHashPolicy, + optional: true, + }, + slaves: { + schema: NETWORK_INTERFACE_LIST_SCHEMA, + optional: true, + }, + }, +)] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +/// Update network interface config. +pub struct InterfaceUpdater { + #[serde(rename = "type")] + pub interface_type: Option, + pub autostart: Option, + pub method: Option, + pub method6: Option, + pub comments: Option, + pub comments6: Option, + pub cidr: Option, + pub gateway: Option, + pub cidr6: Option, + pub gateway6: Option, + pub mtu: Option, + #[serde(rename = "bridge_ports")] + pub bridge_ports: Option, + #[serde(rename = "bridge_vlan_aware")] + pub bridge_vlan_aware: Option, + pub vlan_id: Option, + pub vlan_raw_device: Option, + #[serde(rename = "bond_mode")] + pub bond_mode: Option, + pub bond_primary: Option, + #[serde(rename = "bond_xmit_hash_policy")] + pub bond_xmit_hash_policy: Option, + pub slaves: Option, +} diff --git a/proxmox-network-api/src/config/helper.rs b/proxmox-network-api/src/config/helper.rs new file mode 100644 index 00000000..00464384 --- /dev/null +++ b/proxmox-network-api/src/config/helper.rs @@ -0,0 +1,232 @@ +use std::collections::HashMap; +use std::os::unix::io::{AsRawFd, FromRawFd, OwnedFd}; +use std::path::Path; +use std::process::Command; + +use anyhow::{bail, format_err, Error}; +use const_format::concatcp; +use lazy_static::lazy_static; +use nix::ioctl_read_bad; +use nix::sys::socket::{socket, AddressFamily, SockFlag, SockType}; +use regex::Regex; + +use proxmox_schema::api_types::IPV4RE_STR; +use proxmox_schema::api_types::IPV6RE_STR; + +pub static IPV4_REVERSE_MASK: &[&str] = &[ + "0.0.0.0", + "128.0.0.0", + "192.0.0.0", + "224.0.0.0", + "240.0.0.0", + "248.0.0.0", + "252.0.0.0", + "254.0.0.0", + "255.0.0.0", + "255.128.0.0", + "255.192.0.0", + "255.224.0.0", + "255.240.0.0", + "255.248.0.0", + "255.252.0.0", + "255.254.0.0", + "255.255.0.0", + "255.255.128.0", + "255.255.192.0", + "255.255.224.0", + "255.255.240.0", + "255.255.248.0", + "255.255.252.0", + "255.255.254.0", + "255.255.255.0", + "255.255.255.128", + "255.255.255.192", + "255.255.255.224", + "255.255.255.240", + "255.255.255.248", + "255.255.255.252", + "255.255.255.254", + "255.255.255.255", +]; + +lazy_static! { + pub static ref IPV4_MASK_HASH_LOCALNET: HashMap<&'static str, u8> = { + let mut map = HashMap::new(); + #[allow(clippy::needless_range_loop)] + for i in 0..IPV4_REVERSE_MASK.len() { + map.insert(IPV4_REVERSE_MASK[i], i as u8); + } + map + }; +} + +pub fn parse_cidr(cidr: &str) -> Result<(String, u8, bool), Error> { + let (address, mask, is_v6) = parse_address_or_cidr(cidr)?; + if let Some(mask) = mask { + Ok((address, mask, is_v6)) + } else { + bail!("missing netmask in '{}'", cidr); + } +} + +pub(crate) fn check_netmask(mask: u8, is_v6: bool) -> Result<(), Error> { + let (ver, min, max) = if is_v6 { + ("IPv6", 1, 128) + } else { + ("IPv4", 1, 32) + }; + + if !(mask >= min && mask <= max) { + bail!( + "{} mask '{}' is out of range ({}..{}).", + ver, + mask, + min, + max + ); + } + + Ok(()) +} + +// parse ip address with optional cidr mask +pub(crate) fn parse_address_or_cidr(cidr: &str) -> Result<(String, Option, bool), Error> { + // NOTE: This is NOT the same regex as in proxmox-schema as this one has capture groups for + // the addresses vs cidr portions! + lazy_static! { + pub static ref CIDR_V4_REGEX: Regex = + Regex::new(concatcp!(r"^(", IPV4RE_STR, r")(?:/(\d{1,2}))?$")).unwrap(); + pub static ref CIDR_V6_REGEX: Regex = + Regex::new(concatcp!(r"^(", IPV6RE_STR, r")(?:/(\d{1,3}))?$")).unwrap(); + } + + if let Some(caps) = CIDR_V4_REGEX.captures(cidr) { + let address = &caps[1]; + if let Some(mask) = caps.get(2) { + let mask: u8 = mask.as_str().parse()?; + check_netmask(mask, false)?; + Ok((address.to_string(), Some(mask), false)) + } else { + Ok((address.to_string(), None, false)) + } + } else if let Some(caps) = CIDR_V6_REGEX.captures(cidr) { + let address = &caps[1]; + if let Some(mask) = caps.get(2) { + let mask: u8 = mask.as_str().parse()?; + check_netmask(mask, true)?; + Ok((address.to_string(), Some(mask), true)) + } else { + Ok((address.to_string(), None, true)) + } + } else { + bail!("invalid address/mask '{}'", cidr); + } +} + +pub(crate) fn get_network_interfaces() -> Result, Error> { + const PROC_NET_DEV: &str = "/proc/net/dev"; + + #[repr(C)] + pub struct ifreq { + ifr_name: [libc::c_uchar; libc::IFNAMSIZ], + ifru_flags: libc::c_short, + } + + ioctl_read_bad!(get_interface_flags, libc::SIOCGIFFLAGS, ifreq); + + lazy_static! { + static ref IFACE_LINE_REGEX: Regex = Regex::new(r"^\s*([^:\s]+):").unwrap(); + } + let raw = std::fs::read_to_string(PROC_NET_DEV) + .map_err(|err| format_err!("unable to read {} - {}", PROC_NET_DEV, err))?; + + let lines = raw.lines(); + + let sock = unsafe { + OwnedFd::from_raw_fd( + socket( + AddressFamily::Inet, + SockType::Datagram, + SockFlag::empty(), + None, + ) + .or_else(|_| { + socket( + AddressFamily::Inet6, + SockType::Datagram, + SockFlag::empty(), + None, + ) + })?, + ) + }; + + let mut interface_list = HashMap::new(); + + for line in lines { + if let Some(cap) = IFACE_LINE_REGEX.captures(line) { + let ifname = &cap[1]; + + let mut req = ifreq { + ifr_name: *b"0000000000000000", + ifru_flags: 0, + }; + for (i, b) in std::ffi::CString::new(ifname)? + .as_bytes_with_nul() + .iter() + .enumerate() + { + if i < (libc::IFNAMSIZ - 1) { + req.ifr_name[i] = *b as libc::c_uchar; + } + } + let res = unsafe { get_interface_flags(sock.as_raw_fd(), &mut req)? }; + if res != 0 { + bail!( + "ioctl get_interface_flags for '{}' failed ({})", + ifname, + res + ); + } + let is_up = (req.ifru_flags & (libc::IFF_UP as libc::c_short)) != 0; + interface_list.insert(ifname.to_string(), is_up); + } + } + + Ok(interface_list) +} + +pub(crate) fn compute_file_diff(filename: &str, shadow: &str) -> Result { + let output = Command::new("diff") + .arg("-b") + .arg("-u") + .arg(filename) + .arg(shadow) + .output() + .map_err(|err| format_err!("failed to execute diff - {}", err))?; + + let diff = proxmox_sys::command::command_output_as_string(output, Some(|c| c == 0 || c == 1)) + .map_err(|err| format_err!("diff failed: {}", err))?; + + Ok(diff) +} + +pub fn assert_ifupdown2_installed() -> Result<(), Error> { + if !Path::new("/usr/share/ifupdown2").exists() { + bail!("ifupdown2 is not installed."); + } + + Ok(()) +} + +pub fn network_reload() -> Result<(), Error> { + let output = Command::new("ifreload") + .arg("-a") + .output() + .map_err(|err| format_err!("failed to execute 'ifreload' - {}", err))?; + + proxmox_sys::command::command_output(output, None) + .map_err(|err| format_err!("ifreload failed: {}", err))?; + + Ok(()) +} diff --git a/proxmox-network-api/src/config/lexer.rs b/proxmox-network-api/src/config/lexer.rs new file mode 100644 index 00000000..d0b7d8cd --- /dev/null +++ b/proxmox-network-api/src/config/lexer.rs @@ -0,0 +1,139 @@ +use std::collections::{HashMap, VecDeque}; +use std::io::BufRead; +use std::iter::Iterator; + +use lazy_static::lazy_static; + +#[derive(Debug, Copy, Clone, Eq, PartialEq)] +pub enum Token { + Text, + Comment, + DHCP, + Newline, + Address, + Auto, + Gateway, + Inet, + Inet6, + Iface, + Loopback, + Manual, + Netmask, + Static, + Attribute, + MTU, + BridgePorts, + BridgeVlanAware, + VlanId, + VlanRawDevice, + BondSlaves, + BondMode, + BondPrimary, + BondXmitHashPolicy, + EOF, +} + +lazy_static! { + static ref KEYWORDS: HashMap<&'static str, Token> = { + let mut map = HashMap::new(); + map.insert("address", Token::Address); + map.insert("auto", Token::Auto); + map.insert("dhcp", Token::DHCP); + map.insert("gateway", Token::Gateway); + map.insert("inet", Token::Inet); + map.insert("inet6", Token::Inet6); + map.insert("iface", Token::Iface); + map.insert("loopback", Token::Loopback); + map.insert("manual", Token::Manual); + map.insert("netmask", Token::Netmask); + map.insert("static", Token::Static); + map.insert("mtu", Token::MTU); + map.insert("bridge-ports", Token::BridgePorts); + map.insert("bridge_ports", Token::BridgePorts); + map.insert("bridge-vlan-aware", Token::BridgeVlanAware); + map.insert("bridge_vlan_aware", Token::BridgeVlanAware); + map.insert("vlan-id", Token::VlanId); + map.insert("vlan_id", Token::VlanId); + map.insert("vlan-raw-device", Token::VlanRawDevice); + map.insert("vlan_raw_device", Token::VlanRawDevice); + map.insert("bond-slaves", Token::BondSlaves); + map.insert("bond_slaves", Token::BondSlaves); + map.insert("bond-mode", Token::BondMode); + map.insert("bond-primary", Token::BondPrimary); + map.insert("bond_primary", Token::BondPrimary); + map.insert("bond_xmit_hash_policy", Token::BondXmitHashPolicy); + map.insert("bond-xmit-hash-policy", Token::BondXmitHashPolicy); + map + }; +} + +pub struct Lexer { + input: R, + eof_count: usize, + cur_line: Option>, +} + +impl Lexer { + pub fn new(input: R) -> Self { + Self { + input, + eof_count: 0, + cur_line: None, + } + } + + fn split_line(line: &str) -> VecDeque<(Token, String)> { + if let Some(comment) = line.strip_prefix('#') { + let mut res = VecDeque::new(); + res.push_back((Token::Comment, comment.trim().to_string())); + return res; + } + let mut list: VecDeque<(Token, String)> = line + .split_ascii_whitespace() + .map(|text| { + let token = KEYWORDS.get(text).unwrap_or(&Token::Text); + (*token, text.to_string()) + }) + .collect(); + + if line.starts_with(|c: char| c.is_ascii_whitespace() && c != '\n') { + list.push_front((Token::Attribute, String::from("\t"))); + } + list + } +} + +impl Iterator for Lexer { + type Item = Result<(Token, String), std::io::Error>; + + fn next(&mut self) -> Option { + if self.cur_line.is_none() { + let mut line = String::new(); + match self.input.read_line(&mut line) { + Err(err) => return Some(Err(err)), + Ok(0) => { + self.eof_count += 1; + if self.eof_count == 1 { + return Some(Ok((Token::EOF, String::new()))); + } + return None; + } + _ => {} + } + self.cur_line = Some(Self::split_line(&line)); + } + + match self.cur_line { + Some(ref mut cur_line) => { + if cur_line.is_empty() { + self.cur_line = None; + Some(Ok((Token::Newline, String::from("\n")))) + } else { + let (token, text) = cur_line.pop_front().unwrap(); + Some(Ok((token, text))) + } + } + None => None, + } + } +} diff --git a/proxmox-network-api/src/config/mod.rs b/proxmox-network-api/src/config/mod.rs new file mode 100644 index 00000000..b53279e2 --- /dev/null +++ b/proxmox-network-api/src/config/mod.rs @@ -0,0 +1,723 @@ +mod helper; +mod lexer; +mod parser; + +pub use helper::{assert_ifupdown2_installed, network_reload, parse_cidr}; + +use std::collections::{BTreeMap, HashMap, HashSet}; +use std::io::Write; + +use anyhow::{bail, format_err, Error}; +use lazy_static::lazy_static; +use regex::Regex; +use serde::de::{value, Deserialize, IntoDeserializer}; + +use super::{ + BondXmitHashPolicy, Interface, LinuxBondMode, NetworkConfigMethod, NetworkInterfaceType, +}; + +use helper::compute_file_diff; +use helper::get_network_interfaces; +use parser::NetworkParser; + +use proxmox_config_digest::ConfigDigest; +use proxmox_product_config::{open_api_lockfile, replace_system_config, ApiLockGuard}; + +lazy_static! { + static ref PHYSICAL_NIC_REGEX: Regex = Regex::new(r"^(?:eth\d+|en[^:.]+|ib\d+)$").unwrap(); + static ref VLAN_INTERFACE_REGEX: Regex = + Regex::new(r"^(?P\S+)\.(?P\d+)|vlan(?P\d+)$").unwrap(); +} + +pub fn is_physical_nic(iface: &str) -> bool { + PHYSICAL_NIC_REGEX.is_match(iface) +} + +pub fn bond_mode_from_str(s: &str) -> Result { + LinuxBondMode::deserialize(s.into_deserializer()) + .map_err(|_: value::Error| format_err!("invalid bond_mode '{}'", s)) +} + +pub fn bond_xmit_hash_policy_from_str(s: &str) -> Result { + BondXmitHashPolicy::deserialize(s.into_deserializer()) + .map_err(|_: value::Error| format_err!("invalid bond_xmit_hash_policy '{}'", s)) +} + +pub fn parse_vlan_id_from_name(iface_name: &str) -> Option { + VLAN_INTERFACE_REGEX.captures(iface_name).and_then(|cap| { + cap.name("vlan_id") + .or(cap.name("vlan_id2")) + .and_then(|id| id.as_str().parse::().ok()) + }) +} + +pub fn parse_vlan_raw_device_from_name(iface_name: &str) -> Option<&str> { + VLAN_INTERFACE_REGEX + .captures(iface_name) + .and_then(|cap| cap.name("vlan_raw_device")) + .map(Into::into) +} + +// Write attributes not depending on address family +fn write_iface_attributes(iface: &Interface, w: &mut dyn Write) -> Result<(), Error> { + static EMPTY_LIST: Vec = Vec::new(); + + match iface.interface_type { + NetworkInterfaceType::Bridge => { + if let Some(true) = iface.bridge_vlan_aware { + writeln!(w, "\tbridge-vlan-aware yes")?; + } + let ports = iface.bridge_ports.as_ref().unwrap_or(&EMPTY_LIST); + if ports.is_empty() { + writeln!(w, "\tbridge-ports none")?; + } else { + writeln!(w, "\tbridge-ports {}", ports.join(" "))?; + } + } + NetworkInterfaceType::Bond => { + let mode = iface.bond_mode.unwrap_or(LinuxBondMode::BalanceRr); + writeln!(w, "\tbond-mode {mode}")?; + if let Some(primary) = &iface.bond_primary { + if mode == LinuxBondMode::ActiveBackup { + writeln!(w, "\tbond-primary {}", primary)?; + } + } + + if let Some(xmit_policy) = &iface.bond_xmit_hash_policy { + if mode == LinuxBondMode::Ieee802_3ad || mode == LinuxBondMode::BalanceXor { + writeln!(w, "\tbond_xmit_hash_policy {xmit_policy}")?; + } + } + + let slaves = iface.slaves.as_ref().unwrap_or(&EMPTY_LIST); + if slaves.is_empty() { + writeln!(w, "\tbond-slaves none")?; + } else { + writeln!(w, "\tbond-slaves {}", slaves.join(" "))?; + } + } + NetworkInterfaceType::Vlan => { + if let Some(vlan_id) = iface.vlan_id { + writeln!(w, "\tvlan-id {vlan_id}")?; + } + if let Some(vlan_raw_device) = &iface.vlan_raw_device { + writeln!(w, "\tvlan-raw-device {vlan_raw_device}")?; + } + } + _ => {} + } + + if let Some(mtu) = iface.mtu { + writeln!(w, "\tmtu {}", mtu)?; + } + + Ok(()) +} + +// Write attributes depending on address family inet (IPv4) +fn write_iface_attributes_v4( + iface: &Interface, + w: &mut dyn Write, + method: NetworkConfigMethod, +) -> Result<(), Error> { + if method == NetworkConfigMethod::Static { + if let Some(address) = &iface.cidr { + writeln!(w, "\taddress {}", address)?; + } + if let Some(gateway) = &iface.gateway { + writeln!(w, "\tgateway {}", gateway)?; + } + } + + for option in &iface.options { + writeln!(w, "\t{}", option)?; + } + + if let Some(ref comments) = iface.comments { + for comment in comments.lines() { + writeln!(w, "#{}", comment)?; + } + } + + Ok(()) +} + +/// Write attributes depending on address family inet6 (IPv6) +fn write_iface_attributes_v6( + iface: &Interface, + w: &mut dyn Write, + method: NetworkConfigMethod, +) -> Result<(), Error> { + if method == NetworkConfigMethod::Static { + if let Some(address) = &iface.cidr6 { + writeln!(w, "\taddress {}", address)?; + } + if let Some(gateway) = &iface.gateway6 { + writeln!(w, "\tgateway {}", gateway)?; + } + } + + for option in &iface.options6 { + writeln!(w, "\t{}", option)?; + } + + if let Some(ref comments) = iface.comments6 { + for comment in comments.lines() { + writeln!(w, "#{}", comment)?; + } + } + + Ok(()) +} + +fn write_iface(iface: &Interface, w: &mut dyn Write) -> Result<(), Error> { + fn method_to_str(method: NetworkConfigMethod) -> &'static str { + match method { + NetworkConfigMethod::Static => "static", + NetworkConfigMethod::Loopback => "loopback", + NetworkConfigMethod::Manual => "manual", + NetworkConfigMethod::DHCP => "dhcp", + } + } + + if iface.method.is_none() && iface.method6.is_none() { + return Ok(()); + } + + if iface.autostart { + writeln!(w, "auto {}", iface.name)?; + } + + if let Some(method) = iface.method { + writeln!(w, "iface {} inet {}", iface.name, method_to_str(method))?; + write_iface_attributes_v4(iface, w, method)?; + write_iface_attributes(iface, w)?; + writeln!(w)?; + } + + if let Some(method6) = iface.method6 { + let mut skip_v6 = false; // avoid empty inet6 manual entry + if iface.method.is_some() + && method6 == NetworkConfigMethod::Manual + && iface.comments6.is_none() + && iface.options6.is_empty() + { + skip_v6 = true; + } + + if !skip_v6 { + writeln!(w, "iface {} inet6 {}", iface.name, method_to_str(method6))?; + write_iface_attributes_v6(iface, w, method6)?; + if iface.method.is_none() { + // only write common attributes once + write_iface_attributes(iface, w)?; + } + writeln!(w)?; + } + } + + Ok(()) +} + +#[derive(Debug)] +pub(crate) enum NetworkOrderEntry { + Iface(String), + Comment(String), + Option(String), +} + +#[derive(Debug, Default)] +pub struct NetworkConfig { + pub interfaces: BTreeMap, + pub(crate) order: Vec, +} + +impl TryFrom for String { + type Error = Error; + + fn try_from(config: NetworkConfig) -> Result { + let mut output = Vec::new(); + config.write_config(&mut output)?; + let res = String::from_utf8(output)?; + Ok(res) + } +} + +impl NetworkConfig { + pub fn new() -> Self { + Self { + interfaces: BTreeMap::new(), + order: Vec::new(), + } + } + + pub fn lookup(&self, name: &str) -> Result<&Interface, Error> { + let interface = self + .interfaces + .get(name) + .ok_or_else(|| format_err!("interface '{}' does not exist.", name))?; + Ok(interface) + } + + pub fn lookup_mut(&mut self, name: &str) -> Result<&mut Interface, Error> { + let interface = self + .interfaces + .get_mut(name) + .ok_or_else(|| format_err!("interface '{}' does not exist.", name))?; + Ok(interface) + } + + /// Check that there is no other gateway. + /// + /// The gateway property is only allowed on passed 'iface'. This should be + /// called before setting the gateway. + pub fn check_duplicate_gateway_v4(&self, iface: &str) -> Result<(), Error> { + let current_gateway_v4 = self + .interfaces + .iter() + .find(|(_, interface)| interface.gateway.is_some()) + .map(|(name, _)| name.to_string()); + + if let Some(current_gateway_v4) = current_gateway_v4 { + if current_gateway_v4 != iface { + bail!( + "Default IPv4 gateway already exists on interface '{}'", + current_gateway_v4 + ); + } + } + Ok(()) + } + + /// Check that there is no other v6 gateway. + /// + /// The gateway6 property is only allowed on passed 'iface'. This should be + /// called before setting the gateway6. + pub fn check_duplicate_gateway_v6(&self, iface: &str) -> Result<(), Error> { + let current_gateway_v6 = self + .interfaces + .iter() + .find(|(_, interface)| interface.gateway6.is_some()) + .map(|(name, _)| name.to_string()); + + if let Some(current_gateway_v6) = current_gateway_v6 { + if current_gateway_v6 != iface { + bail!( + "Default IPv6 gateway already exists on interface '{}'", + current_gateway_v6 + ); + } + } + Ok(()) + } + /// Check if ports are used only once + fn check_port_usage(&self) -> Result<(), Error> { + let mut used_ports = HashMap::new(); + let mut check_port_usage = |iface, ports: &Vec| { + for port in ports.iter() { + if let Some(prev_iface) = used_ports.get(port) { + bail!( + "iface '{}' port '{}' is already used on interface '{}'", + iface, + port, + prev_iface + ); + } + used_ports.insert(port.to_string(), iface); + } + Ok(()) + }; + + for (iface, interface) in self.interfaces.iter() { + if let Some(ports) = &interface.bridge_ports { + check_port_usage(iface, ports)?; + } + if let Some(slaves) = &interface.slaves { + check_port_usage(iface, slaves)?; + } + } + Ok(()) + } + + /// Check if child mtu is less or equal than parent mtu + fn check_mtu(&self, parent_name: &str, child_name: &str) -> Result<(), Error> { + let parent = self + .interfaces + .get(parent_name) + .ok_or_else(|| format_err!("check_mtu - missing parent interface '{}'", parent_name))?; + let child = self + .interfaces + .get(child_name) + .ok_or_else(|| format_err!("check_mtu - missing child interface '{}'", child_name))?; + + let child_mtu = match child.mtu { + Some(mtu) => mtu, + None => return Ok(()), + }; + + let parent_mtu = match parent.mtu { + Some(mtu) => mtu, + None => { + if parent.interface_type == NetworkInterfaceType::Bond { + child_mtu + } else { + 1500 + } + } + }; + + if parent_mtu < child_mtu { + bail!( + "interface '{}' - mtu {} is lower than '{}' - mtu {}\n", + parent_name, + parent_mtu, + child_name, + child_mtu + ); + } + + Ok(()) + } + + /// Check if bond slaves exists + fn check_bond_slaves(&self) -> Result<(), Error> { + for (iface, interface) in self.interfaces.iter() { + if let Some(slaves) = &interface.slaves { + for slave in slaves.iter() { + match self.interfaces.get(slave) { + Some(entry) => { + if entry.interface_type != NetworkInterfaceType::Eth { + bail!( + "bond '{}' - wrong interface type on slave '{}' ({:?} != {:?})", + iface, + slave, + entry.interface_type, + NetworkInterfaceType::Eth + ); + } + } + None => { + bail!("bond '{}' - unable to find slave '{}'", iface, slave); + } + } + self.check_mtu(iface, slave)?; + } + } + } + Ok(()) + } + + /// Check if bridge ports exists + fn check_bridge_ports(&self) -> Result<(), Error> { + lazy_static! { + static ref VLAN_INTERFACE_REGEX: Regex = Regex::new(r"^(\S+)\.(\d+)$").unwrap(); + } + + for (iface, interface) in self.interfaces.iter() { + if let Some(ports) = &interface.bridge_ports { + for port in ports.iter() { + let captures = VLAN_INTERFACE_REGEX.captures(port); + let port = if let Some(ref caps) = captures { + &caps[1] + } else { + port.as_str() + }; + if !self.interfaces.contains_key(port) { + bail!("bridge '{}' - unable to find port '{}'", iface, port); + } + self.check_mtu(iface, port)?; + } + } + } + Ok(()) + } + + fn write_config(&self, w: &mut dyn Write) -> Result<(), Error> { + self.check_port_usage()?; + self.check_bond_slaves()?; + self.check_bridge_ports()?; + + let mut done = HashSet::new(); + + let mut last_entry_was_comment = false; + + for entry in self.order.iter() { + match entry { + NetworkOrderEntry::Comment(comment) => { + writeln!(w, "#{}", comment)?; + last_entry_was_comment = true; + } + NetworkOrderEntry::Option(option) => { + if last_entry_was_comment { + writeln!(w)?; + } + last_entry_was_comment = false; + writeln!(w, "{}", option)?; + writeln!(w)?; + } + NetworkOrderEntry::Iface(name) => { + let interface = match self.interfaces.get(name) { + Some(interface) => interface, + None => continue, + }; + + if last_entry_was_comment { + writeln!(w)?; + } + last_entry_was_comment = false; + + if done.contains(name) { + continue; + } + done.insert(name); + + write_iface(interface, w)?; + } + } + } + + for (name, interface) in &self.interfaces { + if done.contains(name) { + continue; + } + write_iface(interface, w)?; + } + Ok(()) + } +} + +pub const NETWORK_INTERFACES_FILENAME: &str = "/etc/network/interfaces"; +pub const NETWORK_INTERFACES_NEW_FILENAME: &str = "/etc/network/interfaces.new"; +pub const NETWORK_LOCKFILE: &str = "/var/lock/pve-network.lck"; + +pub fn lock_config() -> Result { + open_api_lockfile(NETWORK_LOCKFILE, None, true) +} + +pub fn config() -> Result<(NetworkConfig, ConfigDigest), Error> { + let content = + match proxmox_sys::fs::file_get_optional_contents(NETWORK_INTERFACES_NEW_FILENAME)? { + Some(content) => content, + None => { + let content = + proxmox_sys::fs::file_get_optional_contents(NETWORK_INTERFACES_FILENAME)?; + content.unwrap_or_default() + } + }; + + let digest = ConfigDigest::from_slice(&content); + + let existing_interfaces = get_network_interfaces()?; + let mut parser = NetworkParser::new(&content[..]); + let data = parser.parse_interfaces(Some(&existing_interfaces))?; + + Ok((data, digest)) +} + +pub fn changes() -> Result { + if !std::path::Path::new(NETWORK_INTERFACES_NEW_FILENAME).exists() { + return Ok(String::new()); + } + + compute_file_diff(NETWORK_INTERFACES_FILENAME, NETWORK_INTERFACES_NEW_FILENAME) +} + +pub fn save_config(config: &NetworkConfig) -> Result<(), Error> { + let mut raw = Vec::new(); + config.write_config(&mut raw)?; + replace_system_config(NETWORK_INTERFACES_NEW_FILENAME, &raw)?; + Ok(()) +} + +// shell completion helper +pub fn complete_interface_name(_arg: &str, _param: &HashMap) -> Vec { + match config() { + Ok((data, _digest)) => data.interfaces.keys().map(|id| id.to_string()).collect(), + Err(_) => Vec::new(), + } +} + +pub fn complete_port_list(arg: &str, _param: &HashMap) -> Vec { + let mut ports = Vec::new(); + match config() { + Ok((data, _digest)) => { + for (iface, interface) in data.interfaces.iter() { + if interface.interface_type == NetworkInterfaceType::Eth { + ports.push(iface.to_string()); + } + } + } + Err(_) => return Vec::new(), + }; + + let arg = arg.trim(); + let prefix = if let Some(idx) = arg.rfind(',') { + &arg[..idx + 1] + } else { + "" + }; + ports + .iter() + .map(|port| format!("{}{}", prefix, port)) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + use NetworkConfigMethod::*; + use NetworkInterfaceType::*; + use NetworkOrderEntry::*; + + #[test] + fn test_write_network_config_manual() { + let iface_name = String::from("enp3s0"); + let mut iface = Interface::new(iface_name.clone()); + iface.interface_type = Eth; + iface.method = Some(Manual); + iface.active = true; + + let nw_config = NetworkConfig { + interfaces: BTreeMap::from([(iface_name.clone(), iface)]), + order: vec![Iface(iface_name.clone())], + }; + + assert_eq!( + String::try_from(nw_config).unwrap().trim(), + r#"iface enp3s0 inet manual"# + ); + } + + #[test] + fn test_write_network_config_static() { + let iface_name = String::from("enp3s0"); + let mut iface = Interface::new(iface_name.clone()); + iface.interface_type = Eth; + iface.method = Some(Static); + iface.cidr = Some(String::from("10.0.0.100/16")); + iface.active = true; + + let nw_config = NetworkConfig { + interfaces: BTreeMap::from([(iface_name.clone(), iface)]), + order: vec![Iface(iface_name.clone())], + }; + assert_eq!( + String::try_from(nw_config).unwrap().trim(), + format!( + r#" +iface enp3s0 inet static + address 10.0.0.100/16"# + ) + .trim() + ); + } + + #[test] + fn test_write_network_config_static_with_gateway() { + let iface_name = String::from("enp3s0"); + let mut iface = Interface::new(iface_name.clone()); + iface.interface_type = Eth; + iface.method = Some(Static); + iface.cidr = Some(String::from("10.0.0.100/16")); + iface.gateway = Some(String::from("10.0.0.1")); + iface.active = true; + + let nw_config = NetworkConfig { + interfaces: BTreeMap::from([(iface_name.clone(), iface)]), + order: vec![Iface(iface_name.clone())], + }; + assert_eq!( + String::try_from(nw_config).unwrap().trim(), + format!( + r#" +iface enp3s0 inet static + address 10.0.0.100/16 + gateway 10.0.0.1"# + ) + .trim() + ); + } + + #[test] + fn test_write_network_config_vlan_id_in_name() { + let iface_name = String::from("vmbr0.100"); + let mut iface = Interface::new(iface_name.clone()); + iface.interface_type = Vlan; + iface.method = Some(Manual); + iface.active = true; + + let nw_config = NetworkConfig { + interfaces: BTreeMap::from([(iface_name.clone(), iface)]), + order: vec![Iface(iface_name.clone())], + }; + assert_eq!( + String::try_from(nw_config).unwrap().trim(), + "iface vmbr0.100 inet manual" + ); + } + + #[test] + fn test_write_network_config_vlan_with_raw_device() { + let iface_name = String::from("vlan100"); + let mut iface = Interface::new(iface_name.clone()); + iface.interface_type = Vlan; + iface.vlan_raw_device = Some(String::from("vmbr0")); + iface.method = Some(Manual); + iface.active = true; + + let nw_config = NetworkConfig { + interfaces: BTreeMap::from([(iface_name.clone(), iface)]), + order: vec![Iface(iface_name.clone())], + }; + assert_eq!( + String::try_from(nw_config).unwrap().trim(), + r#" +iface vlan100 inet manual + vlan-raw-device vmbr0"# + .trim() + ); + } + + #[test] + fn test_write_network_config_vlan_with_individual_name() { + let iface_name = String::from("individual_name"); + let mut iface = Interface::new(iface_name.clone()); + iface.interface_type = Vlan; + iface.vlan_raw_device = Some(String::from("vmbr0")); + iface.vlan_id = Some(100); + iface.method = Some(Manual); + iface.active = true; + + let nw_config = NetworkConfig { + interfaces: BTreeMap::from([(iface_name.clone(), iface)]), + order: vec![Iface(iface_name.clone())], + }; + assert_eq!( + String::try_from(nw_config).unwrap().trim(), + r#" +iface individual_name inet manual + vlan-id 100 + vlan-raw-device vmbr0"# + .trim() + ); + } + + #[test] + fn test_vlan_parse_vlan_id_from_name() { + assert_eq!(parse_vlan_id_from_name("vlan100"), Some(100)); + assert_eq!(parse_vlan_id_from_name("vlan"), None); + assert_eq!(parse_vlan_id_from_name("arbitrary"), None); + assert_eq!(parse_vlan_id_from_name("vmbr0.100"), Some(100)); + assert_eq!(parse_vlan_id_from_name("vmbr0"), None); + // assert_eq!(parse_vlan_id_from_name("vmbr0.1.400"), Some(400)); // NOTE ifupdown2 does actually support this + } + + #[test] + fn test_vlan_parse_vlan_raw_device_from_name() { + assert_eq!(parse_vlan_raw_device_from_name("vlan100"), None); + assert_eq!(parse_vlan_raw_device_from_name("arbitrary"), None); + assert_eq!(parse_vlan_raw_device_from_name("vmbr0"), None); + assert_eq!(parse_vlan_raw_device_from_name("vmbr0.200"), Some("vmbr0")); + } +} diff --git a/proxmox-network-api/src/config/parser.rs b/proxmox-network-api/src/config/parser.rs new file mode 100644 index 00000000..dc8e2d0a --- /dev/null +++ b/proxmox-network-api/src/config/parser.rs @@ -0,0 +1,862 @@ +use crate::VLAN_INTERFACE_REGEX; + +use std::collections::{HashMap, HashSet}; +use std::io::BufRead; +use std::iter::{Iterator, Peekable}; + +use anyhow::{bail, format_err, Error}; +use lazy_static::lazy_static; +use regex::Regex; +use serde::de::{value, Deserialize, IntoDeserializer}; + +use super::helper::*; +use super::lexer::*; + +use super::LinuxBondMode; + +use proxmox_schema::api_types::IP_REGEX; + +use super::{BondXmitHashPolicy, Interface, NetworkConfigMethod, NetworkInterfaceType}; + +use crate::config::NetworkConfig; +use crate::config::NetworkOrderEntry; + +pub fn bond_mode_from_str(s: &str) -> Result { + LinuxBondMode::deserialize(s.into_deserializer()) + .map_err(|_: value::Error| format_err!("invalid bond_mode '{}'", s)) +} + +pub fn bond_xmit_hash_policy_from_str(s: &str) -> Result { + BondXmitHashPolicy::deserialize(s.into_deserializer()) + .map_err(|_: value::Error| format_err!("invalid bond_xmit_hash_policy '{}'", s)) +} + +fn set_method_v4(iface: &mut Interface, method: NetworkConfigMethod) -> Result<(), Error> { + if iface.method.is_none() { + iface.method = Some(method); + } else { + bail!("inet configuration method already set."); + } + Ok(()) +} + +fn set_method_v6(iface: &mut Interface, method: NetworkConfigMethod) -> Result<(), Error> { + if iface.method6.is_none() { + iface.method6 = Some(method); + } else { + bail!("inet6 configuration method already set."); + } + Ok(()) +} + +fn set_cidr_v4(iface: &mut Interface, address: String) -> Result<(), Error> { + if iface.cidr.is_none() { + iface.cidr = Some(address); + } else { + bail!("duplicate IPv4 address."); + } + Ok(()) +} + +fn set_gateway_v4(iface: &mut Interface, gateway: String) -> Result<(), Error> { + if iface.gateway.is_none() { + iface.gateway = Some(gateway); + } else { + bail!("duplicate IPv4 gateway."); + } + Ok(()) +} + +fn set_cidr_v6(iface: &mut Interface, address: String) -> Result<(), Error> { + if iface.cidr6.is_none() { + iface.cidr6 = Some(address); + } else { + bail!("duplicate IPv6 address."); + } + Ok(()) +} + +fn set_gateway_v6(iface: &mut Interface, gateway: String) -> Result<(), Error> { + if iface.gateway6.is_none() { + iface.gateway6 = Some(gateway); + } else { + bail!("duplicate IPv4 gateway."); + } + Ok(()) +} + +fn set_interface_type( + iface: &mut Interface, + interface_type: NetworkInterfaceType, +) -> Result<(), Error> { + if iface.interface_type == NetworkInterfaceType::Unknown { + iface.interface_type = interface_type; + } else if iface.interface_type != interface_type { + bail!( + "interface type already defined - cannot change from {:?} to {:?}", + iface.interface_type, + interface_type + ); + } + Ok(()) +} + +pub struct NetworkParser { + input: Peekable>, + line_nr: usize, +} + +impl NetworkParser { + pub fn new(reader: R) -> Self { + let input = Lexer::new(reader).peekable(); + Self { input, line_nr: 1 } + } + + fn peek(&mut self) -> Result { + match self.input.peek() { + Some(Err(err)) => { + bail!("input error - {}", err); + } + Some(Ok((token, _))) => Ok(*token), + None => { + bail!("got unexpected end of stream (inside peek)"); + } + } + } + + fn next(&mut self) -> Result<(Token, String), Error> { + match self.input.next() { + Some(Err(err)) => { + bail!("input error - {}", err); + } + Some(Ok((token, text))) => { + if token == Token::Newline { + self.line_nr += 1; + } + Ok((token, text)) + } + None => { + bail!("got unexpected end of stream (inside peek)"); + } + } + } + + fn next_text(&mut self) -> Result { + match self.next()? { + (Token::Text, text) => Ok(text), + (unexpected, _) => bail!("got unexpected token {:?} (expecting Text)", unexpected), + } + } + + fn eat(&mut self, expected: Token) -> Result { + let (next, text) = self.next()?; + if next != expected { + bail!("expected {:?}, got {:?}", expected, next); + } + Ok(text) + } + + fn parse_auto(&mut self, auto_flag: &mut HashSet) -> Result<(), Error> { + self.eat(Token::Auto)?; + + loop { + match self.next()? { + (Token::Text, iface) => { + auto_flag.insert(iface.to_string()); + } + (Token::Newline, _) => break, + unexpected => { + bail!("expected {:?}, got {:?}", Token::Text, unexpected); + } + } + } + + Ok(()) + } + + fn parse_netmask(&mut self) -> Result { + self.eat(Token::Netmask)?; + let netmask = self.next_text()?; + + let mask = if let Some(mask) = IPV4_MASK_HASH_LOCALNET.get(netmask.as_str()) { + *mask + } else { + match netmask.as_str().parse::() { + Ok(mask) => mask, + Err(err) => { + bail!("unable to parse netmask '{}' - {}", netmask, err); + } + } + }; + + self.eat(Token::Newline)?; + + Ok(mask) + } + + fn parse_iface_address(&mut self) -> Result<(String, Option, bool), Error> { + self.eat(Token::Address)?; + let cidr = self.next_text()?; + + let (_address, mask, ipv6) = parse_address_or_cidr(&cidr)?; + + self.eat(Token::Newline)?; + + Ok((cidr, mask, ipv6)) + } + + fn parse_iface_gateway(&mut self, interface: &mut Interface) -> Result<(), Error> { + self.eat(Token::Gateway)?; + let gateway = self.next_text()?; + + if IP_REGEX.is_match(&gateway) { + if gateway.contains(':') { + set_gateway_v6(interface, gateway)?; + } else { + set_gateway_v4(interface, gateway)?; + } + } else { + bail!("unable to parse gateway address"); + } + + self.eat(Token::Newline)?; + + Ok(()) + } + + fn parse_iface_mtu(&mut self) -> Result { + self.eat(Token::MTU)?; + + let mtu = self.next_text()?; + let mtu = match mtu.parse::() { + Ok(mtu) => mtu, + Err(err) => { + bail!("unable to parse mtu value '{}' - {}", mtu, err); + } + }; + + self.eat(Token::Newline)?; + + Ok(mtu) + } + + fn parse_yes_no(&mut self) -> Result { + let text = self.next_text()?; + let value = match text.to_lowercase().as_str() { + "yes" => true, + "no" => false, + _ => { + bail!("unable to bool value '{}' - (expected yes/no)", text); + } + }; + + self.eat(Token::Newline)?; + + Ok(value) + } + + fn parse_to_eol(&mut self) -> Result { + let mut line = String::new(); + loop { + match self.next()? { + (Token::Newline, _) => return Ok(line), + (_, text) => { + if !line.is_empty() { + line.push(' '); + } + line.push_str(&text); + } + } + } + } + + fn parse_iface_list(&mut self) -> Result, Error> { + let mut list = Vec::new(); + + loop { + let (token, text) = self.next()?; + match token { + Token::Newline => break, + Token::Text => { + if &text != "none" { + list.push(text); + } + } + _ => bail!( + "unable to parse interface list - unexpected token '{:?}'", + token + ), + } + } + + Ok(list) + } + + fn parse_iface_attributes( + &mut self, + interface: &mut Interface, + address_family_v4: bool, + address_family_v6: bool, + ) -> Result<(), Error> { + let mut netmask = None; + let mut address_list = Vec::new(); + + loop { + match self.peek()? { + Token::Attribute => { + self.eat(Token::Attribute)?; + } + Token::Comment => { + let comment = self.eat(Token::Comment)?; + if !address_family_v4 && address_family_v6 { + let mut comments = interface.comments6.take().unwrap_or_default(); + if !comments.is_empty() { + comments.push('\n'); + } + comments.push_str(&comment); + interface.comments6 = Some(comments); + } else { + let mut comments = interface.comments.take().unwrap_or_default(); + if !comments.is_empty() { + comments.push('\n'); + } + comments.push_str(&comment); + interface.comments = Some(comments); + } + self.eat(Token::Newline)?; + continue; + } + _ => break, + } + + match self.peek()? { + Token::Address => { + let (cidr, mask, is_v6) = self.parse_iface_address()?; + address_list.push((cidr, mask, is_v6)); + } + Token::Gateway => self.parse_iface_gateway(interface)?, + Token::Netmask => { + //Note: netmask is deprecated, but we try to do our best + netmask = Some(self.parse_netmask()?); + } + Token::MTU => { + let mtu = self.parse_iface_mtu()?; + interface.mtu = Some(mtu); + } + Token::BridgeVlanAware => { + self.eat(Token::BridgeVlanAware)?; + let bridge_vlan_aware = self.parse_yes_no()?; + interface.bridge_vlan_aware = Some(bridge_vlan_aware); + } + Token::BridgePorts => { + self.eat(Token::BridgePorts)?; + let ports = self.parse_iface_list()?; + interface.bridge_ports = Some(ports); + set_interface_type(interface, NetworkInterfaceType::Bridge)?; + } + Token::BondSlaves => { + self.eat(Token::BondSlaves)?; + let slaves = self.parse_iface_list()?; + interface.slaves = Some(slaves); + set_interface_type(interface, NetworkInterfaceType::Bond)?; + } + Token::BondMode => { + self.eat(Token::BondMode)?; + let mode = self.next_text()?; + interface.bond_mode = Some(bond_mode_from_str(&mode)?); + self.eat(Token::Newline)?; + } + Token::BondPrimary => { + self.eat(Token::BondPrimary)?; + let primary = self.next_text()?; + interface.bond_primary = Some(primary); + self.eat(Token::Newline)?; + } + Token::BondXmitHashPolicy => { + self.eat(Token::BondXmitHashPolicy)?; + let policy = bond_xmit_hash_policy_from_str(&self.next_text()?)?; + interface.bond_xmit_hash_policy = Some(policy); + self.eat(Token::Newline)?; + } + Token::VlanId => { + self.eat(Token::VlanId)?; + let vlan_id = self.next_text()?.parse()?; + interface.vlan_id = Some(vlan_id); + set_interface_type(interface, NetworkInterfaceType::Vlan)?; + self.eat(Token::Newline)?; + } + Token::VlanRawDevice => { + self.eat(Token::VlanRawDevice)?; + let vlan_raw_device = self.next_text()?; + interface.vlan_raw_device = Some(vlan_raw_device); + set_interface_type(interface, NetworkInterfaceType::Vlan)?; + self.eat(Token::Newline)?; + } + _ => { + // parse addon attributes + let option = self.parse_to_eol()?; + if !option.is_empty() { + if !address_family_v4 && address_family_v6 { + interface.options6.push(option); + } else { + interface.options.push(option); + } + }; + } + } + } + + #[allow(clippy::comparison_chain)] + if let Some(netmask) = netmask { + if address_list.len() > 1 { + bail!("unable to apply netmask to multiple addresses (please use cidr notation)"); + } else if address_list.len() == 1 { + let (mut cidr, mask, is_v6) = address_list.pop().unwrap(); + if mask.is_some() { + // address already has a mask - ignore netmask + } else { + use std::fmt::Write as _; + check_netmask(netmask, is_v6)?; + let _ = write!(cidr, "/{}", netmask); + } + if is_v6 { + set_cidr_v6(interface, cidr)?; + } else { + set_cidr_v4(interface, cidr)?; + } + } else { + // no address - simply ignore useless netmask + } + } else { + for (cidr, mask, is_v6) in address_list { + if mask.is_none() { + bail!("missing netmask in '{}'", cidr); + } + if is_v6 { + set_cidr_v6(interface, cidr)?; + } else { + set_cidr_v4(interface, cidr)?; + } + } + } + + Ok(()) + } + + fn parse_iface(&mut self, config: &mut NetworkConfig) -> Result<(), Error> { + self.eat(Token::Iface)?; + let iface = self.next_text()?; + + let mut address_family_v4 = false; + let mut address_family_v6 = false; + let mut config_method = None; + + loop { + let (token, text) = self.next()?; + match token { + Token::Newline => break, + Token::Inet => address_family_v4 = true, + Token::Inet6 => address_family_v6 = true, + Token::Loopback => config_method = Some(NetworkConfigMethod::Loopback), + Token::Static => config_method = Some(NetworkConfigMethod::Static), + Token::Manual => config_method = Some(NetworkConfigMethod::Manual), + Token::DHCP => config_method = Some(NetworkConfigMethod::DHCP), + _ => bail!("unknown iface option {}", text), + } + } + + let config_method = config_method.unwrap_or(NetworkConfigMethod::Static); + + if !(address_family_v4 || address_family_v6) { + address_family_v4 = true; + address_family_v6 = true; + } + + if let Some(interface) = config.interfaces.get_mut(&iface) { + if address_family_v4 { + set_method_v4(interface, config_method)?; + } + if address_family_v6 { + set_method_v6(interface, config_method)?; + } + + self.parse_iface_attributes(interface, address_family_v4, address_family_v6)?; + } else { + let mut interface = Interface::new(iface.clone()); + if address_family_v4 { + set_method_v4(&mut interface, config_method)?; + } + if address_family_v6 { + set_method_v6(&mut interface, config_method)?; + } + + self.parse_iface_attributes(&mut interface, address_family_v4, address_family_v6)?; + + config.interfaces.insert(interface.name.clone(), interface); + + config.order.push(NetworkOrderEntry::Iface(iface)); + } + + Ok(()) + } + + pub fn parse_interfaces( + &mut self, + existing_interfaces: Option<&HashMap>, + ) -> Result { + self.do_parse_interfaces(existing_interfaces) + .map_err(|err| format_err!("line {}: {}", self.line_nr, err)) + } + + fn do_parse_interfaces( + &mut self, + existing_interfaces: Option<&HashMap>, + ) -> Result { + let mut config = NetworkConfig::new(); + + let mut auto_flag: HashSet = HashSet::new(); + + loop { + match self.peek()? { + Token::EOF => { + break; + } + Token::Newline => { + // skip empty lines + self.eat(Token::Newline)?; + } + Token::Comment => { + let (_, text) = self.next()?; + config.order.push(NetworkOrderEntry::Comment(text)); + self.eat(Token::Newline)?; + } + Token::Auto => { + self.parse_auto(&mut auto_flag)?; + } + Token::Iface => { + self.parse_iface(&mut config)?; + } + _ => { + let option = self.parse_to_eol()?; + if !option.is_empty() { + config.order.push(NetworkOrderEntry::Option(option)); + } + } + } + } + + for iface in auto_flag.iter() { + if let Some(interface) = config.interfaces.get_mut(iface) { + interface.autostart = true; + } + } + + lazy_static! { + static ref INTERFACE_ALIAS_REGEX: Regex = Regex::new(r"^\S+:\d+$").unwrap(); + } + + if let Some(existing_interfaces) = existing_interfaces { + for (iface, active) in existing_interfaces.iter() { + if let Some(interface) = config.interfaces.get_mut(iface) { + interface.active = *active; + if interface.interface_type == NetworkInterfaceType::Unknown + && super::is_physical_nic(iface) + { + interface.interface_type = NetworkInterfaceType::Eth; + } + } else if super::is_physical_nic(iface) { + // also add all physical NICs + let mut interface = Interface::new(iface.clone()); + set_method_v4(&mut interface, NetworkConfigMethod::Manual)?; + interface.interface_type = NetworkInterfaceType::Eth; + interface.active = *active; + config.interfaces.insert(interface.name.clone(), interface); + config + .order + .push(NetworkOrderEntry::Iface(iface.to_string())); + } + } + } + + for (name, interface) in config.interfaces.iter_mut() { + if interface.interface_type != NetworkInterfaceType::Unknown { + continue; + } + if name == "lo" { + interface.interface_type = NetworkInterfaceType::Loopback; + continue; + } + if INTERFACE_ALIAS_REGEX.is_match(name) { + interface.interface_type = NetworkInterfaceType::Alias; + continue; + } + if VLAN_INTERFACE_REGEX.is_match(name) { + interface.interface_type = NetworkInterfaceType::Vlan; + continue; + } + if super::is_physical_nic(name) { + interface.interface_type = NetworkInterfaceType::Eth; + continue; + } + } + + if config.interfaces.get("lo").is_none() { + let mut interface = Interface::new(String::from("lo")); + set_method_v4(&mut interface, NetworkConfigMethod::Loopback)?; + interface.interface_type = NetworkInterfaceType::Loopback; + interface.autostart = true; + config.interfaces.insert(interface.name.clone(), interface); + + // Note: insert 'lo' as first interface after initial comments + let mut new_order = Vec::new(); + let mut added_lo = false; + for entry in config.order { + if added_lo { + new_order.push(entry); + continue; + } // copy the rest + match entry { + NetworkOrderEntry::Comment(_) => { + new_order.push(entry); + } + _ => { + new_order.push(NetworkOrderEntry::Iface(String::from("lo"))); + added_lo = true; + new_order.push(entry); + } + } + } + config.order = new_order; + } + + Ok(config) + } +} + +#[cfg(test)] +mod test { + + use anyhow::Error; + + use super::*; + + #[test] + fn test_network_config_create_lo_1() -> Result<(), Error> { + let input = ""; + + let mut parser = NetworkParser::new(input.as_bytes()); + + let config = parser.parse_interfaces(None)?; + + let output = String::try_from(config)?; + + let expected = "auto lo\niface lo inet loopback\n\n"; + assert_eq!(output, expected); + + // run again using output as input + let mut parser = NetworkParser::new(output.as_bytes()); + + let config = parser.parse_interfaces(None)?; + + let output = String::try_from(config)?; + + assert_eq!(output, expected); + + Ok(()) + } + + #[test] + fn test_network_config_create_lo_2() -> Result<(), Error> { + let input = "#c1\n\n#c2\n\niface test inet manual\n"; + + let mut parser = NetworkParser::new(input.as_bytes()); + + let config = parser.parse_interfaces(None)?; + + let output = String::try_from(config)?; + + // Note: loopback should be added in front of other interfaces + let expected = "#c1\n#c2\n\nauto lo\niface lo inet loopback\n\niface test inet manual\n\n"; + assert_eq!(output, expected); + + Ok(()) + } + + #[test] + fn test_network_config_parser_no_blank_1() -> Result<(), Error> { + let input = "auto lo\n\ + iface lo inet loopback\n\ + iface lo inet6 loopback\n\ + auto ens18\n\ + iface ens18 inet static\n\ + \taddress 192.168.20.144/20\n\ + \tgateway 192.168.16.1\n\ + # comment\n\ + iface ens20 inet static\n\ + \taddress 192.168.20.145/20\n\ + iface ens21 inet manual\n\ + iface ens22 inet manual\n"; + + let mut parser = NetworkParser::new(input.as_bytes()); + + let config = parser.parse_interfaces(None)?; + + let output = String::try_from(config)?; + + let expected = "auto lo\n\ + iface lo inet loopback\n\ + \n\ + iface lo inet6 loopback\n\ + \n\ + auto ens18\n\ + iface ens18 inet static\n\ + \taddress 192.168.20.144/20\n\ + \tgateway 192.168.16.1\n\ + #comment\n\ + \n\ + iface ens20 inet static\n\ + \taddress 192.168.20.145/20\n\ + \n\ + iface ens21 inet manual\n\ + \n\ + iface ens22 inet manual\n\ + \n"; + assert_eq!(output, expected); + + Ok(()) + } + + #[test] + fn test_network_config_parser_no_blank_2() -> Result<(), Error> { + // Adapted from bug 2926 + let input = "### Hetzner Online GmbH installimage\n\ + \n\ + source /etc/network/interfaces.d/*\n\ + \n\ + auto lo\n\ + iface lo inet loopback\n\ + iface lo inet6 loopback\n\ + \n\ + auto enp4s0\n\ + iface enp4s0 inet static\n\ + \taddress 10.10.10.10/24\n\ + \tgateway 10.10.10.1\n\ + \t# route 10.10.20.10/24 via 10.10.20.1\n\ + \tup route add -net 10.10.20.10 netmask 255.255.255.0 gw 10.10.20.1 dev enp4s0\n\ + \n\ + iface enp4s0 inet6 static\n\ + \taddress fe80::5496:35ff:fe99:5a6a/64\n\ + \tgateway fe80::1\n"; + + let mut parser = NetworkParser::new(input.as_bytes()); + + let config = parser.parse_interfaces(None)?; + + let output = String::try_from(config)?; + + let expected = "### Hetzner Online GmbH installimage\n\ + \n\ + source /etc/network/interfaces.d/*\n\ + \n\ + auto lo\n\ + iface lo inet loopback\n\ + \n\ + iface lo inet6 loopback\n\ + \n\ + auto enp4s0\n\ + iface enp4s0 inet static\n\ + \taddress 10.10.10.10/24\n\ + \tgateway 10.10.10.1\n\ + \t# route 10.10.20.10/24 via 10.10.20.1\n\ + \tup route add -net 10.10.20.10 netmask 255.255.255.0 gw 10.10.20.1 dev enp4s0\n\ + \n\ + iface enp4s0 inet6 static\n\ + \taddress fe80::5496:35ff:fe99:5a6a/64\n\ + \tgateway fe80::1\n\ + \n"; + assert_eq!(output, expected); + + Ok(()) + } + + #[test] + fn test_network_config_parser_vlan_id_in_name() { + let input = "iface vmbr0.100 inet static manual"; + let mut parser = NetworkParser::new(input.as_bytes()); + let config = parser.parse_interfaces(None).unwrap(); + + let iface = config.interfaces.get("vmbr0.100").unwrap(); + assert_eq!(iface.interface_type, NetworkInterfaceType::Vlan); + assert_eq!(iface.vlan_raw_device, None); + assert_eq!(iface.vlan_id, None); + } + + #[test] + fn test_network_config_parser_vlan_with_raw_device() { + let input = r#" +iface vlan100 inet manual + vlan-raw-device vmbr0"#; + + let mut parser = NetworkParser::new(input.as_bytes()); + let config = parser.parse_interfaces(None).unwrap(); + + let iface = config.interfaces.get("vlan100").unwrap(); + assert_eq!(iface.interface_type, NetworkInterfaceType::Vlan); + assert_eq!(iface.vlan_raw_device, Some(String::from("vmbr0"))); + assert_eq!(iface.vlan_id, None); + } + + #[test] + fn test_network_config_parser_vlan_with_raw_device_static() { + let input = r#" +iface vlan100 inet static + vlan-raw-device vmbr0 + address 10.0.0.100/16"#; + + let mut parser = NetworkParser::new(input.as_bytes()); + let config = parser.parse_interfaces(None).unwrap(); + + let iface = config.interfaces.get("vlan100").unwrap(); + assert_eq!(iface.interface_type, NetworkInterfaceType::Vlan); + assert_eq!(iface.vlan_raw_device, Some(String::from("vmbr0"))); + assert_eq!(iface.vlan_id, None); + assert_eq!(iface.method, Some(NetworkConfigMethod::Static)); + assert_eq!(iface.cidr, Some(String::from("10.0.0.100/16"))); + } + + #[test] + fn test_network_config_parser_vlan_individual_name() { + let input = r#" +iface individual_name inet manual + vlan-id 100 + vlan-raw-device vmbr0"#; + + let mut parser = NetworkParser::new(input.as_bytes()); + let config = parser.parse_interfaces(None).unwrap(); + + let iface = config.interfaces.get("individual_name").unwrap(); + assert_eq!(iface.interface_type, NetworkInterfaceType::Vlan); + assert_eq!(iface.vlan_raw_device, Some(String::from("vmbr0"))); + assert_eq!(iface.vlan_id, Some(100)); + } + + #[test] + fn test_network_config_parser_vlan_individual_name_static() { + let input = r#" +iface individual_name inet static + vlan-id 100 + vlan-raw-device vmbr0 + address 10.0.0.100/16 +"#; + + let mut parser = NetworkParser::new(input.as_bytes()); + let config = parser.parse_interfaces(None).unwrap(); + + let iface = config.interfaces.get("individual_name").unwrap(); + assert_eq!(iface.interface_type, NetworkInterfaceType::Vlan); + assert_eq!(iface.vlan_raw_device, Some(String::from("vmbr0"))); + assert_eq!(iface.vlan_id, Some(100)); + assert_eq!(iface.method, Some(NetworkConfigMethod::Static)); + assert_eq!(iface.cidr, Some(String::from("10.0.0.100/16"))); + } +} diff --git a/proxmox-network-api/src/lib.rs b/proxmox-network-api/src/lib.rs new file mode 100644 index 00000000..b366772d --- /dev/null +++ b/proxmox-network-api/src/lib.rs @@ -0,0 +1,12 @@ +mod api_types; +pub use api_types::*; + +#[cfg(feature = "impl")] +mod config; +#[cfg(feature = "impl")] +pub use config::*; + +#[cfg(feature = "impl")] +mod api_impl; +#[cfg(feature = "impl")] +pub use api_impl::{create_interface, update_interface};