public inbox for pve-devel@lists.proxmox.com
 help / color / mirror / Atom feed
* [pve-devel] [RFC container/firewall/manager/proxmox-firewall/qemu-server 00/37] proxmox firewall nftables implementation
@ 2024-04-02 17:15 Stefan Hanreich
  2024-04-02 17:15 ` [pve-devel] [PATCH proxmox-firewall 01/37] config: add proxmox-ve-config crate Stefan Hanreich
                   ` (41 more replies)
  0 siblings, 42 replies; 67+ messages in thread
From: Stefan Hanreich @ 2024-04-02 17:15 UTC (permalink / raw)
  To: pve-devel

## Introduction
This RFC provides a drop-in replacement for the current pve-firewall package
that is based on Rust and nftables.

It consists of three crates:
* proxmox-ve-config
  for parsing firewall and guest configuration files, as well as some helpers
  to access host configuration (particularly networking)
* proxmox-nftables
  contains bindings for libnftables as well as types that implement the JSON
  schema defined by libnftables-json
* proxmox-firewall
  uses the other two crates to read the firewall configuration and create the
  respective nftables configuration


## Installation
* Build & install all deb packages on your PVE instance
* Enable the nftables firewall by going to
  Web UI > <Host> > Firewall > Options > proxmox-nftables
* Enable the firewall datacenter-wide if you haven't already
* Restarting running VMs/CTs is required so the changes to the fwbr creation
  go into effect

For your convenience I have provided pre-built packages on our share under
`shanreich-proxmox-firewall`.

The source code is also available on my staff repo as `proxmox-firewall`.


## Configuration
The firewall should work as a drop-in replacement for the pve-firewall, so you
should be able to configure the firewall as usual via the Web UI or
configuration files.


## Known Issues
There is currently one major issue that we still need to solve: REJECTing
packets from the guest firewalls is currently not possible for incoming traffic
(it will instead be dropped).

This is due to the fact that we are using the postrouting hook of nftables in a
table with type bridge for incoming traffic. In the bridge table in the
postrouting hook we cannot tell whether the packet has also been sent to other
ports in the bridge (e.g. when a MAC has not yet been learned and the packet
then gets flooded to all bridge ports). If we would then REJECT a packet in the
postrouting hook this can lead to a bug where the firewall rules for one guest
REJECT a packet and send a response (RST for TCP, ICMP port/host-unreachable
otherwise).

This has also been explained in the respective commit introducing the
restriction [1].

We were able to circumvent this restriction in the old firewall due to using
firewall bridges and rejecting in the firewall bridge itself. Doing this leads
to the behavior described above, which has tripped up some of our users before
[2] [3] and which is, frankly, wrong.

I currently see two possible solutions for this, both of which carry downsides.
Your input on this matter would be much appreciated, particularly if you can
think of another solution which I cannot currently see:

1. Only REJECT packets in the prerouting chain of the firewall bridge with the
destination MAC address set to the MAC address of the network device, otherwise
DROP

The downside of this is that we, once again, will have to resort to using
firewall bridges, which we wanted to eliminate. This would also be the sole
reason for still having to resort to using firewall bridges.

2. Only allow DROP in the guest firewall for incoming traffic

This would be quite awkward since, well, rejecting traffic would be quite nice
for a firewall I'd say ;)

I'm happy for all input regarding this matter.


## Useful Commands

You can check if firewall rules got created by running

```
nft list ruleset
```

You can also check that `iptables` rules are not created via
```
iptables-save
```

Further info about the services:
```
systemctl status proxmox-firewall.{service,timer}
```

You can grab the debug output from the new firewall like so:

```
RUST_LOG=trace proxmox-firewall
```

## Upcoming

There are some (very minor) features missing:
* automatically generating an ipfilter based on the link-local IPv6 address
* complete list of ICMP codes

I also have some improvements for the code base in mind, but I wanted to get the
RFC out now, since I feel like the new firewall is already in a decent state and
the architecture is relatively solid. Nevertheless there are still a few
improvements that I will be working on:
* move error handling in the library crates to custom error types / thiserror
* integration tests for the firewall itself

[1] https://git.kernel.org/pub/scm/linux/kernel/git/stable/linux.git/commit/net/bridge/netfilter/nft_reject_bridge.c?h=v6.8.2&id=127917c29a432c3b798e014a1714e9c1af0f87fe
[2] https://bugzilla.proxmox.com/show_bug.cgi?id=4964
[3] https://forum.proxmox.com/threads/proxmox-claiming-mac-address.52601/page-2#post-415493



PS: Since the changestats are broken for patch series including the initial
commit of a repo here is the cloc output for proxmox-firewall instead:

-------------------------------------------------------------------------------
Language                     files          blank        comment           code
-------------------------------------------------------------------------------
Rust                            37           1642             78           7749
JSON                             2              0              0            948
TOML                             3             10              0             59
-------------------------------------------------------------------------------
SUM:                            42           1652             78           8756
-------------------------------------------------------------------------------

proxmox-firewall:

Stefan Hanreich (33):
  config: add proxmox-ve-config crate
  config: firewall: add types for ip addresses
  config: firewall: add types for ports
  config: firewall: add types for log level and rate limit
  config: firewall: add types for aliases
  config: host: add helpers for host network configuration
  config: guest: add helpers for parsing guest network config
  config: firewall: add types for ipsets
  config: firewall: add types for rules
  config: firewall: add types for security groups
  config: firewall: add generic parser for firewall configs
  config: firewall: add cluster-specific config + option types
  config: firewall: add host specific config + option types
  config: firewall: add guest-specific config + option types
  config: firewall: add firewall macros
  config: firewall: add conntrack helper types
  nftables: add crate for libnftables bindings
  nftables: add helpers
  nftables: expression: add types
  nftables: expression: implement conversion traits for firewall config
  nftables: statement: add types
  nftables: statement: add conversion traits for config types
  nftables: commands: add types
  nftables: types: add conversion traits
  nftables: add libnftables bindings
  firewall: add firewall crate
  firewall: add base ruleset
  firewall: add config loader
  firewall: add rule generation logic
  firewall: add object generation logic
  firewall: add ruleset generation logic
  firewall: add proxmox-firewall binary
  firewall: add files for debian packaging


qemu-server:

Stefan Hanreich (1):
  firewall: add handling for new nft firewall

 vm-network-scripts/pve-bridge | 9 +++++++--
 1 file changed, 7 insertions(+), 2 deletions(-)


pve-container:

Stefan Hanreich (1):
  firewall: add handling for new nft firewall

 src/PVE/LXC.pm | 5 +++++
 1 file changed, 5 insertions(+)


pve-firewall:

Stefan Hanreich (1):
  add configuration option for new nftables firewall

 src/PVE/Firewall.pm | 20 ++++++++++++++++----
 1 file changed, 16 insertions(+), 4 deletions(-)


pve-manager:

Stefan Hanreich (1):
  firewall: expose configuration option for new nftables firewall

 www/manager6/grid/FirewallOptions.js | 1 +
 1 file changed, 1 insertion(+)


Summary over all repositories:
  4 files changed, 29 insertions(+), 6 deletions(-)

-- 
Generated by git-murpp 0.6.0



^ permalink raw reply	[flat|nested] 67+ messages in thread

* [pve-devel] [PATCH proxmox-firewall 01/37] config: add proxmox-ve-config crate
  2024-04-02 17:15 [pve-devel] [RFC container/firewall/manager/proxmox-firewall/qemu-server 00/37] proxmox firewall nftables implementation Stefan Hanreich
@ 2024-04-02 17:15 ` Stefan Hanreich
  2024-04-02 17:15 ` [pve-devel] [PATCH proxmox-firewall 02/37] config: firewall: add types for ip addresses Stefan Hanreich
                   ` (40 subsequent siblings)
  41 siblings, 0 replies; 67+ messages in thread
From: Stefan Hanreich @ 2024-04-02 17:15 UTC (permalink / raw)
  To: pve-devel; +Cc: Stefan Hanreich, Wolfgang Bumiller

Co-authored-by: Wolfgang Bumiller<w.bumiller@proxmox.com>
Signed-off-by: Stefan Hanreich <s.hanreich@proxmox.com>
---
 .cargo/config                |  5 +++++
 .gitignore                   |  6 ++++++
 Cargo.toml                   |  4 ++++
 proxmox-ve-config/Cargo.toml | 19 +++++++++++++++++++
 proxmox-ve-config/src/lib.rs |  0
 5 files changed, 34 insertions(+)
 create mode 100644 .cargo/config
 create mode 100644 .gitignore
 create mode 100644 Cargo.toml
 create mode 100644 proxmox-ve-config/Cargo.toml
 create mode 100644 proxmox-ve-config/src/lib.rs

diff --git a/.cargo/config b/.cargo/config
new file mode 100644
index 0000000..3b5b6e4
--- /dev/null
+++ b/.cargo/config
@@ -0,0 +1,5 @@
+[source]
+[source.debian-packages]
+directory = "/usr/share/cargo/registry"
+[source.crates-io]
+replace-with = "debian-packages"
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..3cb8114
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,6 @@
+/target
+/Cargo.lock
+proxmox-firewall-*/
+*.deb
+*.buildinfo
+*.changes
diff --git a/Cargo.toml b/Cargo.toml
new file mode 100644
index 0000000..a8d33ab
--- /dev/null
+++ b/Cargo.toml
@@ -0,0 +1,4 @@
+[workspace]
+members = [
+    "proxmox-ve-config",
+]
diff --git a/proxmox-ve-config/Cargo.toml b/proxmox-ve-config/Cargo.toml
new file mode 100644
index 0000000..80b336a
--- /dev/null
+++ b/proxmox-ve-config/Cargo.toml
@@ -0,0 +1,19 @@
+[package]
+name = "proxmox-ve-config"
+version = "0.1.0"
+edition = "2021"
+authors = [
+    "Wolfgang Bumiller <w.bumiller@proxmox.com>",
+    "Stefan Hanreich <s.hanreich@proxmox.com>",
+    "Proxmox Support Team <support@proxmox.com>",
+]
+description = "Proxmox VE config parsing"
+license = "AGPL-3"
+
+[dependencies]
+log = "0.4"
+anyhow = "1"
+
+serde = { version = "1", features = [ "derive" ] }
+serde_json = "1"
+serde_with = "2.3.3"
diff --git a/proxmox-ve-config/src/lib.rs b/proxmox-ve-config/src/lib.rs
new file mode 100644
index 0000000..e69de29
-- 
2.39.2




^ permalink raw reply	[flat|nested] 67+ messages in thread

* [pve-devel] [PATCH proxmox-firewall 02/37] config: firewall: add types for ip addresses
  2024-04-02 17:15 [pve-devel] [RFC container/firewall/manager/proxmox-firewall/qemu-server 00/37] proxmox firewall nftables implementation Stefan Hanreich
  2024-04-02 17:15 ` [pve-devel] [PATCH proxmox-firewall 01/37] config: add proxmox-ve-config crate Stefan Hanreich
@ 2024-04-02 17:15 ` Stefan Hanreich
  2024-04-03 10:46   ` Max Carrara
  2024-04-02 17:15 ` [pve-devel] [PATCH proxmox-firewall 03/37] config: firewall: add types for ports Stefan Hanreich
                   ` (39 subsequent siblings)
  41 siblings, 1 reply; 67+ messages in thread
From: Stefan Hanreich @ 2024-04-02 17:15 UTC (permalink / raw)
  To: pve-devel; +Cc: Stefan Hanreich, Wolfgang Bumiller

Includes types for all kinds of IP values that can occur in the
firewall config. Additionally, FromStr implementations are available
for parsing from the config files.

Co-authored-by: Wolfgang Bumiller <w.bumiller@proxmox.com>
Signed-off-by: Stefan Hanreich <s.hanreich@proxmox.com>
---
 proxmox-ve-config/src/firewall/mod.rs         |   1 +
 .../src/firewall/types/address.rs             | 624 ++++++++++++++++++
 proxmox-ve-config/src/firewall/types/mod.rs   |   3 +
 proxmox-ve-config/src/lib.rs                  |   1 +
 4 files changed, 629 insertions(+)
 create mode 100644 proxmox-ve-config/src/firewall/mod.rs
 create mode 100644 proxmox-ve-config/src/firewall/types/address.rs
 create mode 100644 proxmox-ve-config/src/firewall/types/mod.rs

diff --git a/proxmox-ve-config/src/firewall/mod.rs b/proxmox-ve-config/src/firewall/mod.rs
new file mode 100644
index 0000000..cd40856
--- /dev/null
+++ b/proxmox-ve-config/src/firewall/mod.rs
@@ -0,0 +1 @@
+pub mod types;
diff --git a/proxmox-ve-config/src/firewall/types/address.rs b/proxmox-ve-config/src/firewall/types/address.rs
new file mode 100644
index 0000000..ce2f1cd
--- /dev/null
+++ b/proxmox-ve-config/src/firewall/types/address.rs
@@ -0,0 +1,624 @@
+use std::fmt;
+use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
+use std::ops::Deref;
+
+use anyhow::{bail, format_err, Error};
+use serde_with::DeserializeFromStr;
+
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+pub enum Family {
+    V4,
+    V6,
+}
+
+impl fmt::Display for Family {
+    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
+        match self {
+            Family::V4 => f.write_str("Ipv4"),
+            Family::V6 => f.write_str("Ipv6"),
+        }
+    }
+}
+
+#[derive(Clone, Debug)]
+#[cfg_attr(test, derive(Eq, PartialEq))]
+pub enum Cidr {
+    Ipv4(Ipv4Cidr),
+    Ipv6(Ipv6Cidr),
+}
+
+impl Cidr {
+    pub fn new_v4(addr: impl Into<Ipv4Addr>, mask: u8) -> Result<Self, Error> {
+        Ok(Cidr::Ipv4(Ipv4Cidr::new(addr, mask)?))
+    }
+
+    pub fn new_v6(addr: impl Into<Ipv6Addr>, mask: u8) -> Result<Self, Error> {
+        Ok(Cidr::Ipv6(Ipv6Cidr::new(addr, mask)?))
+    }
+
+    pub const fn family(&self) -> Family {
+        match self {
+            Cidr::Ipv4(_) => Family::V4,
+            Cidr::Ipv6(_) => Family::V6,
+        }
+    }
+
+    pub fn is_ipv4(&self) -> bool {
+        matches!(self, Cidr::Ipv4(_))
+    }
+
+    pub fn is_ipv6(&self) -> bool {
+        matches!(self, Cidr::Ipv6(_))
+    }
+}
+
+impl fmt::Display for Cidr {
+    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
+        match self {
+            Self::Ipv4(ip) => f.write_str(ip.to_string().as_str()),
+            Self::Ipv6(ip) => f.write_str(ip.to_string().as_str()),
+        }
+    }
+}
+
+impl std::str::FromStr for Cidr {
+    type Err = Error;
+
+    fn from_str(s: &str) -> Result<Self, Error> {
+        if let Ok(ip) = s.parse::<Ipv4Cidr>() {
+            return Ok(Cidr::Ipv4(ip));
+        }
+
+        if let Ok(ip) = s.parse::<Ipv6Cidr>() {
+            return Ok(Cidr::Ipv6(ip));
+        }
+
+        bail!("invalid ip address or CIDR: {s:?}");
+    }
+}
+
+impl From<Ipv4Cidr> for Cidr {
+    fn from(cidr: Ipv4Cidr) -> Self {
+        Cidr::Ipv4(cidr)
+    }
+}
+
+impl From<Ipv6Cidr> for Cidr {
+    fn from(cidr: Ipv6Cidr) -> Self {
+        Cidr::Ipv6(cidr)
+    }
+}
+
+const IPV4_LENGTH: u8 = 32;
+
+#[derive(Clone, Debug)]
+#[cfg_attr(test, derive(Eq, PartialEq))]
+pub struct Ipv4Cidr {
+    addr: Ipv4Addr,
+    mask: u8,
+}
+
+impl Ipv4Cidr {
+    pub fn new(addr: impl Into<Ipv4Addr>, mask: u8) -> Result<Self, Error> {
+        if mask > 32 {
+            bail!("mask out of range for ipv4 cidr ({mask})");
+        }
+
+        Ok(Self {
+            addr: addr.into(),
+            mask,
+        })
+    }
+
+    pub fn contains_address(&self, other: &Ipv4Addr) -> bool {
+        let bits = u32::from_be_bytes(self.addr.octets());
+        let other_bits = u32::from_be_bytes(other.octets());
+
+        let shift_amount: u32 = IPV4_LENGTH.saturating_sub(self.mask).into();
+
+        bits.checked_shr(shift_amount).unwrap_or(0)
+            == other_bits.checked_shr(shift_amount).unwrap_or(0)
+    }
+
+    pub fn address(&self) -> &Ipv4Addr {
+        &self.addr
+    }
+
+    pub fn mask(&self) -> u8 {
+        self.mask
+    }
+}
+
+impl<T: Into<Ipv4Addr>> From<T> for Ipv4Cidr {
+    fn from(value: T) -> Self {
+        Self {
+            addr: value.into(),
+            mask: 32,
+        }
+    }
+}
+
+impl std::str::FromStr for Ipv4Cidr {
+    type Err = Error;
+
+    fn from_str(s: &str) -> Result<Self, Error> {
+        Ok(match s.find('/') {
+            None => Self {
+                addr: s.parse()?,
+                mask: 32,
+            },
+            Some(pos) => {
+                let mask: u8 = s[(pos + 1)..]
+                    .parse()
+                    .map_err(|_| format_err!("invalid mask in ipv4 cidr: {s:?}"))?;
+
+                Self::new(s[..pos].parse::<Ipv4Addr>()?, mask)?
+            }
+        })
+    }
+}
+
+impl fmt::Display for Ipv4Cidr {
+    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
+        write!(f, "{}/{}", &self.addr, self.mask)
+    }
+}
+
+const IPV6_LENGTH: u8 = 128;
+
+#[derive(Clone, Debug)]
+#[cfg_attr(test, derive(Eq, PartialEq))]
+pub struct Ipv6Cidr {
+    addr: Ipv6Addr,
+    mask: u8,
+}
+
+impl Ipv6Cidr {
+    pub fn new(addr: impl Into<Ipv6Addr>, mask: u8) -> Result<Self, Error> {
+        if mask > IPV6_LENGTH {
+            bail!("mask out of range for ipv6 cidr");
+        }
+
+        Ok(Self {
+            addr: addr.into(),
+            mask,
+        })
+    }
+
+    pub fn contains_address(&self, other: &Ipv6Addr) -> bool {
+        let bits = u128::from_be_bytes(self.addr.octets());
+        let other_bits = u128::from_be_bytes(other.octets());
+
+        let shift_amount: u32 = IPV6_LENGTH.saturating_sub(self.mask).into();
+
+        bits.checked_shr(shift_amount).unwrap_or(0)
+            == other_bits.checked_shr(shift_amount).unwrap_or(0)
+    }
+
+    pub fn address(&self) -> &Ipv6Addr {
+        &self.addr
+    }
+
+    pub fn mask(&self) -> u8 {
+        self.mask
+    }
+}
+
+impl std::str::FromStr for Ipv6Cidr {
+    type Err = Error;
+
+    fn from_str(s: &str) -> Result<Self, Error> {
+        Ok(match s.find('/') {
+            None => Self {
+                addr: s.parse()?,
+                mask: 128,
+            },
+            Some(pos) => {
+                let mask: u8 = s[(pos + 1)..]
+                    .parse()
+                    .map_err(|_| format_err!("invalid mask in ipv6 cidr: {s:?}"))?;
+
+                Self::new(s[..pos].parse::<Ipv6Addr>()?, mask)?
+            }
+        })
+    }
+}
+
+impl fmt::Display for Ipv6Cidr {
+    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
+        write!(f, "{}/{}", &self.addr, self.mask)
+    }
+}
+
+impl<T: Into<Ipv6Addr>> From<T> for Ipv6Cidr {
+    fn from(addr: T) -> Self {
+        Self {
+            addr: addr.into(),
+            mask: 128,
+        }
+    }
+}
+
+#[derive(Clone, Debug)]
+#[cfg_attr(test, derive(Eq, PartialEq))]
+pub enum IpEntry {
+    Cidr(Cidr),
+    Range(IpAddr, IpAddr),
+}
+
+impl std::str::FromStr for IpEntry {
+    type Err = Error;
+
+    fn from_str(s: &str) -> Result<Self, Error> {
+        if s.is_empty() {
+            bail!("Empty IP specification!")
+        }
+
+        let entries: Vec<&str> = s
+            .split('-')
+            .take(3) // so we can check whether there are too many
+            .collect();
+
+        match entries.len() {
+            1 => {
+                let cidr = entries.first().expect("Vec contains an element");
+
+                Ok(IpEntry::Cidr(cidr.parse()?))
+            }
+            2 => {
+                let (beg, end) = (
+                    entries.first().expect("Vec contains two elements"),
+                    entries.get(1).expect("Vec contains two elements"),
+                );
+
+                if let Ok(beg) = beg.parse::<Ipv4Addr>() {
+                    if let Ok(end) = end.parse::<Ipv4Addr>() {
+                        if beg < end {
+                            return Ok(IpEntry::Range(beg.into(), end.into()));
+                        }
+
+                        bail!("start address is greater than end address!");
+                    }
+                }
+
+                if let Ok(beg) = beg.parse::<Ipv6Addr>() {
+                    if let Ok(end) = end.parse::<Ipv6Addr>() {
+                        if beg < end {
+                            return Ok(IpEntry::Range(beg.into(), end.into()));
+                        }
+
+                        bail!("start address is greater than end address!");
+                    }
+                }
+
+                bail!("start and end are not valid IP addresses of the same type!")
+            }
+            _ => bail!("Invalid amount of elements in IpEntry!"),
+        }
+    }
+}
+
+impl fmt::Display for IpEntry {
+    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
+        match self {
+            Self::Cidr(ip) => write!(f, "{ip}"),
+            Self::Range(beg, end) => write!(f, "{beg}-{end}"),
+        }
+    }
+}
+
+impl IpEntry {
+    fn family(&self) -> Family {
+        match self {
+            Self::Cidr(cidr) => cidr.family(),
+            Self::Range(start, end) => {
+                if start.is_ipv4() && end.is_ipv4() {
+                    return Family::V4;
+                }
+
+                if start.is_ipv6() && end.is_ipv6() {
+                    return Family::V6;
+                }
+
+                // should never be reached due to constructors validating that
+                // start type == end type
+                unreachable!("invalid IP entry")
+            }
+        }
+    }
+}
+
+impl From<Cidr> for IpEntry {
+    fn from(value: Cidr) -> Self {
+        IpEntry::Cidr(value)
+    }
+}
+
+#[derive(Clone, Debug, DeserializeFromStr)]
+#[cfg_attr(test, derive(Eq, PartialEq))]
+pub struct IpList {
+    // guaranteed to have the same family
+    entries: Vec<IpEntry>,
+    family: Family,
+}
+
+impl Deref for IpList {
+    type Target = Vec<IpEntry>;
+
+    fn deref(&self) -> &Self::Target {
+        &self.entries
+    }
+}
+
+impl<T: Into<IpEntry>> From<T> for IpList {
+    fn from(value: T) -> Self {
+        let entry = value.into();
+
+        Self {
+            family: entry.family(),
+            entries: vec![entry],
+        }
+    }
+}
+
+impl std::str::FromStr for IpList {
+    type Err = Error;
+
+    fn from_str(s: &str) -> Result<Self, Error> {
+        if s.is_empty() {
+            bail!("Empty IP specification!")
+        }
+
+        let mut entries = Vec::new();
+        let mut current_family = None;
+
+        for element in s.split(',') {
+            let entry: IpEntry = element.parse()?;
+
+            if let Some(family) = current_family {
+                if family != entry.family() {
+                    bail!("Incompatible families in IPList!")
+                }
+            } else {
+                current_family = Some(entry.family());
+            }
+
+            entries.push(entry);
+        }
+
+        if entries.is_empty() {
+            bail!("empty ip list")
+        }
+
+        Ok(IpList {
+            entries,
+            family: current_family.unwrap(), // must be set due to length check above
+        })
+    }
+}
+
+impl IpList {
+    pub fn new(entries: Vec<IpEntry>) -> Result<Self, Error> {
+        let family = entries.iter().try_fold(None, |result, entry| {
+            if let Some(family) = result {
+                if entry.family() != family {
+                    bail!("non-matching families in entries list");
+                }
+
+                Ok(Some(family))
+            } else {
+                Ok(Some(entry.family()))
+            }
+        })?;
+
+        if let Some(family) = family {
+            return Ok(Self { entries, family });
+        }
+
+        bail!("no elements in ip list entries");
+    }
+
+    pub fn family(&self) -> Family {
+        self.family
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use std::net::{Ipv4Addr, Ipv6Addr};
+
+    #[test]
+    fn test_v4_cidr() {
+        let mut cidr: Ipv4Cidr = "0.0.0.0/0".parse().expect("valid IPv4 CIDR");
+
+        assert_eq!(cidr.addr, Ipv4Addr::new(0, 0, 0, 0));
+        assert_eq!(cidr.mask, 0);
+
+        assert!(cidr.contains_address(&Ipv4Addr::new(0, 0, 0, 0)));
+        assert!(cidr.contains_address(&Ipv4Addr::new(255, 255, 255, 255)));
+
+        cidr = "192.168.100.1".parse().expect("valid IPv4 CIDR");
+
+        assert_eq!(cidr.addr, Ipv4Addr::new(192, 168, 100, 1));
+        assert_eq!(cidr.mask, 32);
+
+        assert!(cidr.contains_address(&Ipv4Addr::new(192, 168, 100, 1)));
+        assert!(!cidr.contains_address(&Ipv4Addr::new(192, 168, 100, 2)));
+        assert!(!cidr.contains_address(&Ipv4Addr::new(192, 168, 100, 0)));
+
+        cidr = "10.100.5.0/24".parse().expect("valid IPv4 CIDR");
+
+        assert_eq!(cidr.mask, 24);
+
+        assert!(cidr.contains_address(&Ipv4Addr::new(10, 100, 5, 0)));
+        assert!(cidr.contains_address(&Ipv4Addr::new(10, 100, 5, 1)));
+        assert!(cidr.contains_address(&Ipv4Addr::new(10, 100, 5, 100)));
+        assert!(cidr.contains_address(&Ipv4Addr::new(10, 100, 5, 255)));
+        assert!(!cidr.contains_address(&Ipv4Addr::new(10, 100, 4, 255)));
+        assert!(!cidr.contains_address(&Ipv4Addr::new(10, 100, 6, 0)));
+
+        "0.0.0.0/-1".parse::<Ipv4Cidr>().unwrap_err();
+        "0.0.0.0/33".parse::<Ipv4Cidr>().unwrap_err();
+        "256.256.256.256/10".parse::<Ipv4Cidr>().unwrap_err();
+
+        "fe80::1/64".parse::<Ipv4Cidr>().unwrap_err();
+        "qweasd".parse::<Ipv4Cidr>().unwrap_err();
+        "".parse::<Ipv4Cidr>().unwrap_err();
+    }
+
+    #[test]
+    fn test_v6_cidr() {
+        let mut cidr: Ipv6Cidr = "abab::1/64".parse().expect("valid IPv6 CIDR");
+
+        assert_eq!(cidr.addr, Ipv6Addr::new(0xABAB, 0, 0, 0, 0, 0, 0, 1));
+        assert_eq!(cidr.mask, 64);
+
+        assert!(cidr.contains_address(&Ipv6Addr::new(0xABAB, 0, 0, 0, 0, 0, 0, 0)));
+        assert!(cidr.contains_address(&Ipv6Addr::new(
+            0xABAB, 0, 0, 0, 0xAAAA, 0xAAAA, 0xAAAA, 0xAAAA
+        )));
+        assert!(cidr.contains_address(&Ipv6Addr::new(
+            0xABAB, 0, 0, 0, 0xFFFF, 0xFFFF, 0xFFFF, 0xFFFF
+        )));
+        assert!(!cidr.contains_address(&Ipv6Addr::new(0xABAB, 0, 0, 1, 0, 0, 0, 0)));
+        assert!(!cidr.contains_address(&Ipv6Addr::new(
+            0xABAA, 0, 0, 0, 0xFFFF, 0xFFFF, 0xFFFF, 0xFFFF
+        )));
+
+        cidr = "eeee::1".parse().expect("valid IPv6 CIDR");
+
+        assert_eq!(cidr.mask, 128);
+
+        assert!(cidr.contains_address(&Ipv6Addr::new(0xEEEE, 0, 0, 0, 0, 0, 0, 1)));
+        assert!(!cidr.contains_address(&Ipv6Addr::new(
+            0xEEED, 0xFFFF, 0xFFFF, 0xFFFF, 0xFFFF, 0xFFFF, 0xFFFF, 0xFFFF
+        )));
+        assert!(!cidr.contains_address(&Ipv6Addr::new(0xEEEE, 0, 0, 0, 0, 0, 0, 0)));
+
+        "eeee::1/-1".parse::<Ipv6Cidr>().unwrap_err();
+        "eeee::1/129".parse::<Ipv6Cidr>().unwrap_err();
+        "gggg::1/64".parse::<Ipv6Cidr>().unwrap_err();
+
+        "192.168.0.1".parse::<Ipv6Cidr>().unwrap_err();
+        "qweasd".parse::<Ipv6Cidr>().unwrap_err();
+        "".parse::<Ipv6Cidr>().unwrap_err();
+    }
+
+    #[test]
+    fn test_parse_ip_entry() {
+        let mut entry: IpEntry = "10.0.0.1".parse().expect("valid IP entry");
+
+        assert_eq!(entry, Cidr::new_v4([10, 0, 0, 1], 32).unwrap().into());
+
+        entry = "10.0.0.0/16".parse().expect("valid IP entry");
+
+        assert_eq!(entry, Cidr::new_v4([10, 0, 0, 0], 16).unwrap().into());
+
+        entry = "192.168.0.1-192.168.99.255"
+            .parse()
+            .expect("valid IP entry");
+
+        assert_eq!(
+            entry,
+            IpEntry::Range([192, 168, 0, 1].into(), [192, 168, 99, 255].into())
+        );
+
+        entry = "fe80::1".parse().expect("valid IP entry");
+
+        assert_eq!(
+            entry,
+            Cidr::new_v6([0xFE80, 0, 0, 0, 0, 0, 0, 1], 128)
+                .unwrap()
+                .into()
+        );
+
+        entry = "fe80::1/48".parse().expect("valid IP entry");
+
+        assert_eq!(
+            entry,
+            Cidr::new_v6([0xFE80, 0, 0, 0, 0, 0, 0, 1], 48)
+                .unwrap()
+                .into()
+        );
+
+        entry = "fd80::1-fd80::ffff".parse().expect("valid IP entry");
+
+        assert_eq!(
+            entry,
+            IpEntry::Range(
+                [0xFD80, 0, 0, 0, 0, 0, 0, 1].into(),
+                [0xFD80, 0, 0, 0, 0, 0, 0, 0xFFFF].into(),
+            )
+        );
+
+        "192.168.100.0-192.168.99.255"
+            .parse::<IpEntry>()
+            .unwrap_err();
+        "192.168.100.0-fe80::1".parse::<IpEntry>().unwrap_err();
+        "192.168.100.0-192.168.200.0/16"
+            .parse::<IpEntry>()
+            .unwrap_err();
+        "192.168.100.0-192.168.200.0-192.168.250.0"
+            .parse::<IpEntry>()
+            .unwrap_err();
+        "qweasd".parse::<IpEntry>().unwrap_err();
+    }
+
+    #[test]
+    fn test_parse_ip_list() {
+        let mut ip_list: IpList = "192.168.0.1,192.168.100.0/24,172.16.0.0-172.32.255.255"
+            .parse()
+            .expect("valid IP list");
+
+        assert_eq!(
+            ip_list,
+            IpList {
+                entries: vec![
+                    IpEntry::Cidr(Cidr::new_v4([192, 168, 0, 1], 32).unwrap()),
+                    IpEntry::Cidr(Cidr::new_v4([192, 168, 100, 0], 24).unwrap()),
+                    IpEntry::Range([172, 16, 0, 0].into(), [172, 32, 255, 255].into()),
+                ],
+                family: Family::V4,
+            }
+        );
+
+        ip_list = "fe80::1/64".parse().expect("valid IP list");
+
+        assert_eq!(
+            ip_list,
+            IpList {
+                entries: vec![IpEntry::Cidr(
+                    Cidr::new_v6([0xFE80, 0, 0, 0, 0, 0, 0, 1], 64).unwrap()
+                ),],
+                family: Family::V6,
+            }
+        );
+
+        "192.168.0.1,fe80::1".parse::<IpList>().unwrap_err();
+
+        "".parse::<IpList>().unwrap_err();
+        "proxmox".parse::<IpList>().unwrap_err();
+    }
+
+    #[test]
+    fn test_construct_ip_list() {
+        let mut ip_list = IpList::new(vec![Cidr::new_v4([10, 0, 0, 0], 8).unwrap().into()])
+            .expect("valid ip list");
+
+        assert_eq!(ip_list.family(), Family::V4);
+
+        ip_list =
+            IpList::new(vec![Cidr::new_v6([0x000; 8], 8).unwrap().into()]).expect("valid ip list");
+
+        assert_eq!(ip_list.family(), Family::V6);
+
+        IpList::new(vec![]).expect_err("empty ip list is invalid");
+
+        IpList::new(vec![
+            Cidr::new_v4([10, 0, 0, 0], 8).unwrap().into(),
+            Cidr::new_v6([0x0000; 8], 8).unwrap().into(),
+        ])
+        .expect_err("cannot mix ip families in ip list");
+    }
+}
diff --git a/proxmox-ve-config/src/firewall/types/mod.rs b/proxmox-ve-config/src/firewall/types/mod.rs
new file mode 100644
index 0000000..de534b4
--- /dev/null
+++ b/proxmox-ve-config/src/firewall/types/mod.rs
@@ -0,0 +1,3 @@
+pub mod address;
+
+pub use address::Cidr;
diff --git a/proxmox-ve-config/src/lib.rs b/proxmox-ve-config/src/lib.rs
index e69de29..a0734b8 100644
--- a/proxmox-ve-config/src/lib.rs
+++ b/proxmox-ve-config/src/lib.rs
@@ -0,0 +1 @@
+pub mod firewall;
-- 
2.39.2




^ permalink raw reply	[flat|nested] 67+ messages in thread

* [pve-devel] [PATCH proxmox-firewall 03/37] config: firewall: add types for ports
  2024-04-02 17:15 [pve-devel] [RFC container/firewall/manager/proxmox-firewall/qemu-server 00/37] proxmox firewall nftables implementation Stefan Hanreich
  2024-04-02 17:15 ` [pve-devel] [PATCH proxmox-firewall 01/37] config: add proxmox-ve-config crate Stefan Hanreich
  2024-04-02 17:15 ` [pve-devel] [PATCH proxmox-firewall 02/37] config: firewall: add types for ip addresses Stefan Hanreich
@ 2024-04-02 17:15 ` Stefan Hanreich
  2024-04-02 17:15 ` [pve-devel] [PATCH proxmox-firewall 04/37] config: firewall: add types for log level and rate limit Stefan Hanreich
                   ` (38 subsequent siblings)
  41 siblings, 0 replies; 67+ messages in thread
From: Stefan Hanreich @ 2024-04-02 17:15 UTC (permalink / raw)
  To: pve-devel; +Cc: Stefan Hanreich, Wolfgang Bumiller

Adds types for all kinds of port-related values in the firewall config
as well as FromStr implementations for parsing them from the config.

Also adds a helper for parsing the named ports from `/etc/services`.

Co-authored-by: Wolfgang Bumiller <w.bumiller@proxmox.com>
Signed-off-by: Stefan Hanreich <s.hanreich@proxmox.com>
---
 proxmox-ve-config/src/firewall/mod.rs        |   1 +
 proxmox-ve-config/src/firewall/ports.rs      |  78 ++++++++
 proxmox-ve-config/src/firewall/types/mod.rs  |   1 +
 proxmox-ve-config/src/firewall/types/port.rs | 181 +++++++++++++++++++
 4 files changed, 261 insertions(+)
 create mode 100644 proxmox-ve-config/src/firewall/ports.rs
 create mode 100644 proxmox-ve-config/src/firewall/types/port.rs

diff --git a/proxmox-ve-config/src/firewall/mod.rs b/proxmox-ve-config/src/firewall/mod.rs
index cd40856..a9f65bf 100644
--- a/proxmox-ve-config/src/firewall/mod.rs
+++ b/proxmox-ve-config/src/firewall/mod.rs
@@ -1 +1,2 @@
+pub mod ports;
 pub mod types;
diff --git a/proxmox-ve-config/src/firewall/ports.rs b/proxmox-ve-config/src/firewall/ports.rs
new file mode 100644
index 0000000..96527f1
--- /dev/null
+++ b/proxmox-ve-config/src/firewall/ports.rs
@@ -0,0 +1,78 @@
+use anyhow::{format_err, Error};
+use std::sync::OnceLock;
+
+#[derive(Default)]
+struct NamedPorts {
+    ports: std::collections::HashMap<String, u16>,
+}
+
+impl NamedPorts {
+    fn new() -> Self {
+        use std::io::BufRead;
+
+        let mut this = Self::default();
+
+        let file = match std::fs::File::open("/etc/services") {
+            Ok(file) => file,
+            Err(_) => return this,
+        };
+
+        for line in std::io::BufReader::new(file).lines() {
+            let line = match line {
+                Ok(line) => line,
+                Err(_) => break,
+            };
+
+            let line = line.trim_start();
+
+            if line.is_empty() || line.starts_with('#') {
+                continue;
+            }
+
+            let mut parts = line.split_ascii_whitespace();
+
+            let name = match parts.next() {
+                None => continue,
+                Some(name) => name.to_string(),
+            };
+
+            let proto: u16 = match parts.next() {
+                None => continue,
+                Some(proto) => match proto.split('/').next() {
+                    None => continue,
+                    Some(num) => match num.parse() {
+                        Ok(num) => num,
+                        Err(_) => continue,
+                    },
+                },
+            };
+
+            this.ports.insert(name, proto);
+            for alias in parts {
+                if alias.starts_with('#') {
+                    break;
+                }
+                this.ports.insert(alias.to_string(), proto);
+            }
+        }
+
+        this
+    }
+
+    fn find(&self, name: &str) -> Option<u16> {
+        self.ports.get(name).copied()
+    }
+}
+
+fn named_ports() -> &'static NamedPorts {
+    static NAMED_PORTS: OnceLock<NamedPorts> = OnceLock::new();
+
+    NAMED_PORTS.get_or_init(NamedPorts::new)
+}
+
+/// Parse a named port with the help of `/etc/services`.
+pub fn parse_named_port(name: &str) -> Result<u16, Error> {
+    named_ports()
+        .find(name)
+        .ok_or_else(|| format_err!("unknown port name {name:?}"))
+}
diff --git a/proxmox-ve-config/src/firewall/types/mod.rs b/proxmox-ve-config/src/firewall/types/mod.rs
index de534b4..b740e5d 100644
--- a/proxmox-ve-config/src/firewall/types/mod.rs
+++ b/proxmox-ve-config/src/firewall/types/mod.rs
@@ -1,3 +1,4 @@
 pub mod address;
+pub mod port;
 
 pub use address::Cidr;
diff --git a/proxmox-ve-config/src/firewall/types/port.rs b/proxmox-ve-config/src/firewall/types/port.rs
new file mode 100644
index 0000000..c1252d9
--- /dev/null
+++ b/proxmox-ve-config/src/firewall/types/port.rs
@@ -0,0 +1,181 @@
+use std::fmt;
+use std::ops::Deref;
+
+use anyhow::{bail, Error};
+use serde_with::DeserializeFromStr;
+
+use crate::firewall::ports::parse_named_port;
+
+#[derive(Clone, Debug)]
+#[cfg_attr(test, derive(Eq, PartialEq))]
+pub enum PortEntry {
+    Port(u16),
+    Range(u16, u16),
+}
+
+impl fmt::Display for PortEntry {
+    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
+        match self {
+            Self::Port(p) => write!(f, "{p}"),
+            Self::Range(beg, end) => write!(f, "{beg}-{end}"),
+        }
+    }
+}
+
+fn parse_port(port: &str) -> Result<u16, Error> {
+    if let Ok(port) = port.parse::<u16>() {
+        return Ok(port);
+    }
+
+    if let Ok(port) = parse_named_port(port) {
+        return Ok(port);
+    }
+
+    bail!("invalid port specification: {port}")
+}
+
+impl std::str::FromStr for PortEntry {
+    type Err = Error;
+
+    fn from_str(s: &str) -> Result<Self, Self::Err> {
+        Ok(match s.trim().split_once(':') {
+            None => PortEntry::from(parse_port(s)?),
+            Some((first, second)) => {
+                PortEntry::try_from((parse_port(first)?, parse_port(second)?))?
+            }
+        })
+    }
+}
+
+impl From<u16> for PortEntry {
+    fn from(port: u16) -> Self {
+        PortEntry::Port(port)
+    }
+}
+
+impl TryFrom<(u16, u16)> for PortEntry {
+    type Error = Error;
+
+    fn try_from(ports: (u16, u16)) -> Result<Self, Error> {
+        if ports.0 > ports.1 {
+            bail!("start port is greater than end port!");
+        }
+
+        Ok(PortEntry::Range(ports.0, ports.1))
+    }
+}
+
+#[derive(Clone, Debug, DeserializeFromStr)]
+#[cfg_attr(test, derive(Eq, PartialEq))]
+pub struct PortList(pub(crate) Vec<PortEntry>);
+
+impl FromIterator<PortEntry> for PortList {
+    fn from_iter<T: IntoIterator<Item = PortEntry>>(iter: T) -> Self {
+        Self(iter.into_iter().collect())
+    }
+}
+
+impl<T: Into<PortEntry>> From<T> for PortList {
+    fn from(value: T) -> Self {
+        Self(vec![value.into()])
+    }
+}
+
+impl Deref for PortList {
+    type Target = Vec<PortEntry>;
+
+    fn deref(&self) -> &Self::Target {
+        &self.0
+    }
+}
+
+impl std::str::FromStr for PortList {
+    type Err = Error;
+
+    fn from_str(s: &str) -> Result<Self, Error> {
+        if s.is_empty() {
+            bail!("empty port specification");
+        }
+
+        let mut entries = Vec::new();
+
+        for entry in s.trim().split(',') {
+            entries.push(entry.parse()?);
+        }
+
+        if entries.is_empty() {
+            bail!("invalid empty port list");
+        }
+
+        Ok(Self(entries))
+    }
+}
+
+impl fmt::Display for PortList {
+    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
+        use fmt::Write;
+        if self.0.len() > 1 {
+            f.write_char('{')?;
+        }
+
+        let mut comma = '\0';
+        for entry in &self.0 {
+            if std::mem::replace(&mut comma, ',') != '\0' {
+                f.write_char(comma)?;
+            }
+            fmt::Display::fmt(entry, f)?;
+        }
+
+        if self.0.len() > 1 {
+            f.write_char('}')?;
+        }
+
+        Ok(())
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn test_parse_port_entry() {
+        let mut port_entry: PortEntry = "12345".parse().expect("valid port entry");
+        assert_eq!(port_entry, PortEntry::from(12345));
+
+        port_entry = "0:65535".parse().expect("valid port entry");
+        assert_eq!(port_entry, PortEntry::try_from((0, 65535)).unwrap());
+
+        "65536".parse::<PortEntry>().unwrap_err();
+        "100:100000".parse::<PortEntry>().unwrap_err();
+        "qweasd".parse::<PortEntry>().unwrap_err();
+        "".parse::<PortEntry>().unwrap_err();
+    }
+
+    #[test]
+    fn test_parse_port_list() {
+        let mut port_list: PortList = "12345".parse().expect("valid port list");
+        assert_eq!(port_list, PortList::from(12345));
+
+        port_list = "12345,0:65535,1337,ssh:80,https"
+            .parse()
+            .expect("valid port list");
+
+        assert_eq!(
+            port_list,
+            PortList(vec![
+                PortEntry::from(12345),
+                PortEntry::try_from((0, 65535)).unwrap(),
+                PortEntry::from(1337),
+                PortEntry::try_from((22, 80)).unwrap(),
+                PortEntry::from(443),
+            ])
+        );
+
+        "0::1337".parse::<PortList>().unwrap_err();
+        "0:1337,".parse::<PortList>().unwrap_err();
+        "70000".parse::<PortList>().unwrap_err();
+        "qweasd".parse::<PortList>().unwrap_err();
+        "".parse::<PortList>().unwrap_err();
+    }
+}
-- 
2.39.2




^ permalink raw reply	[flat|nested] 67+ messages in thread

* [pve-devel] [PATCH proxmox-firewall 04/37] config: firewall: add types for log level and rate limit
  2024-04-02 17:15 [pve-devel] [RFC container/firewall/manager/proxmox-firewall/qemu-server 00/37] proxmox firewall nftables implementation Stefan Hanreich
                   ` (2 preceding siblings ...)
  2024-04-02 17:15 ` [pve-devel] [PATCH proxmox-firewall 03/37] config: firewall: add types for ports Stefan Hanreich
@ 2024-04-02 17:15 ` Stefan Hanreich
  2024-04-02 17:15 ` [pve-devel] [PATCH proxmox-firewall 05/37] config: firewall: add types for aliases Stefan Hanreich
                   ` (37 subsequent siblings)
  41 siblings, 0 replies; 67+ messages in thread
From: Stefan Hanreich @ 2024-04-02 17:15 UTC (permalink / raw)
  To: pve-devel; +Cc: Stefan Hanreich, Wolfgang Bumiller

Adds types for log and (log-)rate-limiting firewall config options as
well as FromStr implementations for parsing them from the config.

Co-authored-by: Wolfgang Bumiller <w.bumiller@proxmox.com>
Signed-off-by: Stefan Hanreich <s.hanreich@proxmox.com>
---
 proxmox-ve-config/Cargo.toml                |   1 +
 proxmox-ve-config/src/firewall/mod.rs       |   2 +
 proxmox-ve-config/src/firewall/parse.rs     |  21 ++
 proxmox-ve-config/src/firewall/types/log.rs | 222 ++++++++++++++++++++
 proxmox-ve-config/src/firewall/types/mod.rs |   1 +
 5 files changed, 247 insertions(+)
 create mode 100644 proxmox-ve-config/src/firewall/parse.rs
 create mode 100644 proxmox-ve-config/src/firewall/types/log.rs

diff --git a/proxmox-ve-config/Cargo.toml b/proxmox-ve-config/Cargo.toml
index 80b336a..7bb391e 100644
--- a/proxmox-ve-config/Cargo.toml
+++ b/proxmox-ve-config/Cargo.toml
@@ -16,4 +16,5 @@ anyhow = "1"
 
 serde = { version = "1", features = [ "derive" ] }
 serde_json = "1"
+serde_plain = "1"
 serde_with = "2.3.3"
diff --git a/proxmox-ve-config/src/firewall/mod.rs b/proxmox-ve-config/src/firewall/mod.rs
index a9f65bf..2e0f31e 100644
--- a/proxmox-ve-config/src/firewall/mod.rs
+++ b/proxmox-ve-config/src/firewall/mod.rs
@@ -1,2 +1,4 @@
 pub mod ports;
 pub mod types;
+
+pub(crate) mod parse;
diff --git a/proxmox-ve-config/src/firewall/parse.rs b/proxmox-ve-config/src/firewall/parse.rs
new file mode 100644
index 0000000..a75daee
--- /dev/null
+++ b/proxmox-ve-config/src/firewall/parse.rs
@@ -0,0 +1,21 @@
+use anyhow::{bail, format_err, Error};
+
+pub fn parse_bool(value: &str) -> Result<bool, Error> {
+    Ok(
+        if value == "0"
+            || value.eq_ignore_ascii_case("false")
+            || value.eq_ignore_ascii_case("off")
+            || value.eq_ignore_ascii_case("no")
+        {
+            false
+        } else if value == "1"
+            || value.eq_ignore_ascii_case("true")
+            || value.eq_ignore_ascii_case("on")
+            || value.eq_ignore_ascii_case("yes")
+        {
+            true
+        } else {
+            bail!("not a boolean: {value:?}");
+        },
+    )
+}
diff --git a/proxmox-ve-config/src/firewall/types/log.rs b/proxmox-ve-config/src/firewall/types/log.rs
new file mode 100644
index 0000000..72344e4
--- /dev/null
+++ b/proxmox-ve-config/src/firewall/types/log.rs
@@ -0,0 +1,222 @@
+use std::fmt;
+use std::str::FromStr;
+
+use crate::firewall::parse::parse_bool;
+use anyhow::{bail, Error};
+use serde::{Deserialize, Serialize};
+
+#[derive(Copy, Clone, Debug, Deserialize, Serialize, Default)]
+#[cfg_attr(test, derive(Eq, PartialEq))]
+#[serde(rename_all = "lowercase")]
+pub enum LogRateLimitTimescale {
+    #[default]
+    Second,
+    Minute,
+    Hour,
+    Day,
+}
+
+impl FromStr for LogRateLimitTimescale {
+    type Err = Error;
+
+    fn from_str(str: &str) -> Result<Self, Error> {
+        match str {
+            "second" => Ok(LogRateLimitTimescale::Second),
+            "minute" => Ok(LogRateLimitTimescale::Minute),
+            "hour" => Ok(LogRateLimitTimescale::Hour),
+            "day" => Ok(LogRateLimitTimescale::Day),
+            _ => bail!("Invalid time scale provided"),
+        }
+    }
+}
+
+#[derive(Debug, Deserialize, Clone)]
+#[cfg_attr(test, derive(Eq, PartialEq))]
+pub struct LogRateLimit {
+    enabled: bool,
+    rate: i64, // in packets
+    per: LogRateLimitTimescale,
+    burst: i64, // in packets
+}
+
+impl LogRateLimit {
+    pub fn new(enabled: bool, rate: i64, per: LogRateLimitTimescale, burst: i64) -> Self {
+        Self {
+            enabled,
+            rate,
+            per,
+            burst,
+        }
+    }
+
+    pub fn enabled(&self) -> bool {
+        self.enabled
+    }
+
+    pub fn rate(&self) -> i64 {
+        self.rate
+    }
+
+    pub fn burst(&self) -> i64 {
+        self.burst
+    }
+
+    pub fn per(&self) -> LogRateLimitTimescale {
+        self.per
+    }
+}
+
+impl Default for LogRateLimit {
+    fn default() -> Self {
+        Self {
+            enabled: true,
+            rate: 1,
+            burst: 5,
+            per: LogRateLimitTimescale::Second,
+        }
+    }
+}
+
+impl FromStr for LogRateLimit {
+    type Err = Error;
+
+    fn from_str(str: &str) -> Result<Self, Error> {
+        let mut limit = Self::default();
+
+        for element in str.split(',') {
+            match element.split_once('=') {
+                None => {
+                    limit.enabled = parse_bool(element)?;
+                }
+                Some((key, value)) if !key.is_empty() && !value.is_empty() => match key {
+                    "enable" => limit.enabled = parse_bool(value)?,
+                    "burst" => limit.burst = i64::from_str(value)?,
+                    "rate" => match value.split_once('/') {
+                        None => {
+                            limit.rate = i64::from_str(value)?;
+                        }
+                        Some((rate, unit)) => {
+                            if unit.is_empty() {
+                                bail!("empty unit specification")
+                            }
+
+                            limit.rate = i64::from_str(rate)?;
+                            limit.per = LogRateLimitTimescale::from_str(unit)?;
+                        }
+                    },
+                    _ => bail!("Invalid value for Key found in log_ratelimit!"),
+                },
+                _ => bail!("invalid value in log_ratelimit"),
+            }
+        }
+
+        Ok(limit)
+    }
+}
+
+#[derive(Clone, Copy, Debug, Eq, PartialEq, Default)]
+pub enum LogLevel {
+    #[default]
+    Nolog,
+    Emergency,
+    Alert,
+    Critical,
+    Error,
+    Warning,
+    Notice,
+    Info,
+    Debug,
+}
+
+impl std::str::FromStr for LogLevel {
+    type Err = Error;
+
+    fn from_str(s: &str) -> Result<Self, Error> {
+        Ok(match s {
+            "nolog" => LogLevel::Nolog,
+            "emerg" => LogLevel::Emergency,
+            "alert" => LogLevel::Alert,
+            "crit" => LogLevel::Critical,
+            "err" => LogLevel::Error,
+            "warn" => LogLevel::Warning,
+            "warning" => LogLevel::Warning,
+            "notice" => LogLevel::Notice,
+            "info" => LogLevel::Info,
+            "debug" => LogLevel::Debug,
+            _ => bail!("invalid log level {s:?}"),
+        })
+    }
+}
+
+impl fmt::Display for LogLevel {
+    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
+        f.write_str(match self {
+            LogLevel::Nolog => "nolog",
+            LogLevel::Emergency => "emerg",
+            LogLevel::Alert => "alert",
+            LogLevel::Critical => "crit",
+            LogLevel::Error => "err",
+            LogLevel::Warning => "warn",
+            LogLevel::Notice => "notice",
+            LogLevel::Info => "info",
+            LogLevel::Debug => "debug",
+        })
+    }
+}
+
+serde_plain::derive_deserialize_from_fromstr!(LogLevel, "valid log level");
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn test_parse_rate_limit() {
+        let mut parsed_rate_limit = "1,burst=123,rate=44"
+            .parse::<LogRateLimit>()
+            .expect("valid rate limit");
+
+        assert_eq!(
+            parsed_rate_limit,
+            LogRateLimit {
+                enabled: true,
+                burst: 123,
+                rate: 44,
+                per: LogRateLimitTimescale::Second,
+            }
+        );
+
+        parsed_rate_limit = "1".parse::<LogRateLimit>().expect("valid rate limit");
+
+        assert_eq!(parsed_rate_limit, LogRateLimit::default());
+
+        parsed_rate_limit = "enable=0,rate=123/hour"
+            .parse::<LogRateLimit>()
+            .expect("valid rate limit");
+
+        assert_eq!(
+            parsed_rate_limit,
+            LogRateLimit {
+                enabled: false,
+                burst: 5,
+                rate: 123,
+                per: LogRateLimitTimescale::Hour,
+            }
+        );
+
+        "2".parse::<LogRateLimit>()
+            .expect_err("invalid value for enable");
+
+        "enabled=0,rate=123"
+            .parse::<LogRateLimit>()
+            .expect_err("invalid key in log ratelimit");
+
+        "enable=0,rate=123,"
+            .parse::<LogRateLimit>()
+            .expect_err("trailing comma in log rate limit specification");
+
+        "enable=0,rate=123/proxmox,"
+            .parse::<LogRateLimit>()
+            .expect_err("invalid unit for rate");
+    }
+}
diff --git a/proxmox-ve-config/src/firewall/types/mod.rs b/proxmox-ve-config/src/firewall/types/mod.rs
index b740e5d..8bf31b8 100644
--- a/proxmox-ve-config/src/firewall/types/mod.rs
+++ b/proxmox-ve-config/src/firewall/types/mod.rs
@@ -1,4 +1,5 @@
 pub mod address;
+pub mod log;
 pub mod port;
 
 pub use address::Cidr;
-- 
2.39.2




^ permalink raw reply	[flat|nested] 67+ messages in thread

* [pve-devel] [PATCH proxmox-firewall 05/37] config: firewall: add types for aliases
  2024-04-02 17:15 [pve-devel] [RFC container/firewall/manager/proxmox-firewall/qemu-server 00/37] proxmox firewall nftables implementation Stefan Hanreich
                   ` (3 preceding siblings ...)
  2024-04-02 17:15 ` [pve-devel] [PATCH proxmox-firewall 04/37] config: firewall: add types for log level and rate limit Stefan Hanreich
@ 2024-04-02 17:15 ` Stefan Hanreich
  2024-04-02 17:15 ` [pve-devel] [PATCH proxmox-firewall 06/37] config: host: add helpers for host network configuration Stefan Hanreich
                   ` (36 subsequent siblings)
  41 siblings, 0 replies; 67+ messages in thread
From: Stefan Hanreich @ 2024-04-02 17:15 UTC (permalink / raw)
  To: pve-devel; +Cc: Stefan Hanreich, Wolfgang Bumiller

Co-authored-by: Wolfgang Bumiller <w.bumiller@proxmox.com>
Signed-off-by: Stefan Hanreich <s.hanreich@proxmox.com>
---
 proxmox-ve-config/src/firewall/parse.rs       |  44 +++++
 proxmox-ve-config/src/firewall/types/alias.rs | 160 ++++++++++++++++++
 proxmox-ve-config/src/firewall/types/mod.rs   |   2 +
 3 files changed, 206 insertions(+)
 create mode 100644 proxmox-ve-config/src/firewall/types/alias.rs

diff --git a/proxmox-ve-config/src/firewall/parse.rs b/proxmox-ve-config/src/firewall/parse.rs
index a75daee..8e30006 100644
--- a/proxmox-ve-config/src/firewall/parse.rs
+++ b/proxmox-ve-config/src/firewall/parse.rs
@@ -1,5 +1,49 @@
 use anyhow::{bail, format_err, Error};
 
+/// Parses out a "name" which can be alphanumeric and include dashes.
+///
+/// Returns `None` if the name part would be empty.
+///
+/// Returns a tuple with the name and the remainder (not trimmed).
+pub fn match_name(line: &str) -> Option<(&str, &str)> {
+    let end = line
+        .as_bytes()
+        .iter()
+        .position(|&b| !(b.is_ascii_alphanumeric() || b == b'-'));
+
+    let (name, rest) = match end {
+        Some(end) => line.split_at(end),
+        None => (line, ""),
+    };
+
+    if name.is_empty() {
+        None
+    } else {
+        Some((name, rest))
+    }
+}
+
+/// Parses up to the next whitespace character or end of the string.
+///
+/// Returns `None` if the non-whitespace part would be empty.
+///
+/// Returns a tuple containing the parsed section and the *trimmed* remainder.
+pub fn match_non_whitespace(line: &str) -> Option<(&str, &str)> {
+    let (text, rest) = line
+        .as_bytes()
+        .iter()
+        .position(|&b| b.is_ascii_whitespace())
+        .map(|pos| {
+            let (a, b) = line.split_at(pos);
+            (a, b.trim_start())
+        })
+        .unwrap_or((line, ""));
+    if text.is_empty() {
+        None
+    } else {
+        Some((text, rest))
+    }
+}
 pub fn parse_bool(value: &str) -> Result<bool, Error> {
     Ok(
         if value == "0"
diff --git a/proxmox-ve-config/src/firewall/types/alias.rs b/proxmox-ve-config/src/firewall/types/alias.rs
new file mode 100644
index 0000000..43c6486
--- /dev/null
+++ b/proxmox-ve-config/src/firewall/types/alias.rs
@@ -0,0 +1,160 @@
+use std::fmt::Display;
+use std::str::FromStr;
+
+use anyhow::{bail, format_err, Error};
+use serde_with::DeserializeFromStr;
+
+use crate::firewall::parse::{match_name, match_non_whitespace};
+use crate::firewall::types::address::Cidr;
+
+#[derive(Debug, Clone)]
+#[cfg_attr(test, derive(Eq, PartialEq))]
+pub enum AliasScope {
+    Datacenter,
+    Guest,
+}
+
+impl FromStr for AliasScope {
+    type Err = Error;
+
+    fn from_str(s: &str) -> Result<Self, Self::Err> {
+        Ok(match s {
+            "dc" => AliasScope::Datacenter,
+            "guest" => AliasScope::Guest,
+            _ => bail!("invalid scope for alias: {s}"),
+        })
+    }
+}
+
+impl Display for AliasScope {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        f.write_str(match self {
+            AliasScope::Datacenter => "dc",
+            AliasScope::Guest => "guest",
+        })
+    }
+}
+
+#[derive(Debug, Clone, DeserializeFromStr)]
+#[cfg_attr(test, derive(Eq, PartialEq))]
+pub struct AliasName {
+    scope: AliasScope,
+    name: String,
+}
+
+impl Display for AliasName {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        f.write_fmt(format_args!("{}/{}", self.scope, self.name))
+    }
+}
+
+impl FromStr for AliasName {
+    type Err = Error;
+
+    fn from_str(s: &str) -> Result<Self, Self::Err> {
+        match s.split_once('/') {
+            Some((prefix, name)) if !name.is_empty() => Ok(Self {
+                scope: prefix.parse()?,
+                name: name.to_string(),
+            }),
+            _ => {
+                bail!("Invalid Alias name!")
+            }
+        }
+    }
+}
+
+impl AliasName {
+    pub fn new(scope: AliasScope, name: impl Into<String>) -> Self {
+        Self {
+            scope,
+            name: name.into(),
+        }
+    }
+
+    pub fn name(&self) -> &str {
+        &self.name
+    }
+
+    pub fn scope(&self) -> &AliasScope {
+        &self.scope
+    }
+}
+
+#[derive(Debug)]
+#[cfg_attr(test, derive(Eq, PartialEq))]
+pub struct Alias {
+    name: String,
+    address: Cidr,
+    comment: Option<String>,
+}
+
+impl Alias {
+    pub fn new(
+        name: impl Into<String>,
+        address: impl Into<Cidr>,
+        comment: impl Into<Option<String>>,
+    ) -> Self {
+        Self {
+            name: name.into(),
+            address: address.into(),
+            comment: comment.into(),
+        }
+    }
+
+    pub fn name(&self) -> &str {
+        &self.name
+    }
+
+    pub fn address(&self) -> &Cidr {
+        &self.address
+    }
+
+    pub fn comment(&self) -> Option<&str> {
+        self.comment.as_deref()
+    }
+}
+
+impl FromStr for Alias {
+    type Err = Error;
+
+    fn from_str(s: &str) -> Result<Self, Self::Err> {
+        let (name, line) =
+            match_name(s.trim_start()).ok_or_else(|| format_err!("expected an alias name"))?;
+
+        let (address, line) = match_non_whitespace(line.trim_start())
+            .ok_or_else(|| format_err!("expected a value for alias {name:?}"))?;
+
+        let address: Cidr = address.parse()?;
+
+        let line = line.trim_start();
+
+        let comment = match line.strip_prefix('#') {
+            Some(comment) => Some(comment.trim().to_string()),
+            None if !line.is_empty() => bail!("trailing characters in alias: {line:?}"),
+            None => None,
+        };
+
+        Ok(Alias {
+            name: name.to_string(),
+            address,
+            comment,
+        })
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn test_parse_alias_name() {
+        for name in ["dc/proxmox_123", "guest/proxmox-123"] {
+            name.parse::<AliasName>().expect("valid alias name");
+        }
+
+        for name in ["proxmox/proxmox_123", "guests/proxmox-123", "dc/", "/name"] {
+            name.parse::<AliasName>().expect_err("invalid alias name");
+        }
+    }
+}
diff --git a/proxmox-ve-config/src/firewall/types/mod.rs b/proxmox-ve-config/src/firewall/types/mod.rs
index 8bf31b8..69b69f4 100644
--- a/proxmox-ve-config/src/firewall/types/mod.rs
+++ b/proxmox-ve-config/src/firewall/types/mod.rs
@@ -1,5 +1,7 @@
 pub mod address;
+pub mod alias;
 pub mod log;
 pub mod port;
 
 pub use address::Cidr;
+pub use alias::Alias;
-- 
2.39.2




^ permalink raw reply	[flat|nested] 67+ messages in thread

* [pve-devel] [PATCH proxmox-firewall 06/37] config: host: add helpers for host network configuration
  2024-04-02 17:15 [pve-devel] [RFC container/firewall/manager/proxmox-firewall/qemu-server 00/37] proxmox firewall nftables implementation Stefan Hanreich
                   ` (4 preceding siblings ...)
  2024-04-02 17:15 ` [pve-devel] [PATCH proxmox-firewall 05/37] config: firewall: add types for aliases Stefan Hanreich
@ 2024-04-02 17:15 ` Stefan Hanreich
  2024-04-03 10:46   ` Max Carrara
  2024-04-09 14:20   ` Lukas Wagner
  2024-04-02 17:15 ` [pve-devel] [PATCH proxmox-firewall 07/37] config: guest: add helpers for parsing guest network config Stefan Hanreich
                   ` (35 subsequent siblings)
  41 siblings, 2 replies; 67+ messages in thread
From: Stefan Hanreich @ 2024-04-02 17:15 UTC (permalink / raw)
  To: pve-devel; +Cc: Stefan Hanreich, Wolfgang Bumiller

Currently the helpers for obtaining the host network configuration
panic on error, which could be avoided by the use of
OnceLock::get_or_init, but this method is currently only available in
nightly versions.

Generally, if there is a problem with obtaining a hostname for the
current node then something else is probably already quite broken, so
I would deem it acceptable for now, same goes for obtaining the
current network configuration.

Co-authored-by: Wolfgang Bumiller <w.bumiller@proxmox.com>
Signed-off-by: Stefan Hanreich <s.hanreich@proxmox.com>
---
 proxmox-ve-config/Cargo.toml        |  1 +
 proxmox-ve-config/src/host/mod.rs   |  1 +
 proxmox-ve-config/src/host/utils.rs | 97 +++++++++++++++++++++++++++++
 proxmox-ve-config/src/lib.rs        |  1 +
 4 files changed, 100 insertions(+)
 create mode 100644 proxmox-ve-config/src/host/mod.rs
 create mode 100644 proxmox-ve-config/src/host/utils.rs

diff --git a/proxmox-ve-config/Cargo.toml b/proxmox-ve-config/Cargo.toml
index 7bb391e..480eb58 100644
--- a/proxmox-ve-config/Cargo.toml
+++ b/proxmox-ve-config/Cargo.toml
@@ -13,6 +13,7 @@ license = "AGPL-3"
 [dependencies]
 log = "0.4"
 anyhow = "1"
+nix = "0.26"
 
 serde = { version = "1", features = [ "derive" ] }
 serde_json = "1"
diff --git a/proxmox-ve-config/src/host/mod.rs b/proxmox-ve-config/src/host/mod.rs
new file mode 100644
index 0000000..b5614dd
--- /dev/null
+++ b/proxmox-ve-config/src/host/mod.rs
@@ -0,0 +1 @@
+pub mod utils;
diff --git a/proxmox-ve-config/src/host/utils.rs b/proxmox-ve-config/src/host/utils.rs
new file mode 100644
index 0000000..1636f95
--- /dev/null
+++ b/proxmox-ve-config/src/host/utils.rs
@@ -0,0 +1,97 @@
+use std::net::{IpAddr, ToSocketAddrs};
+use std::sync::OnceLock;
+
+use crate::firewall::types::Cidr;
+
+use nix::sys::socket::{AddressFamily, SockaddrLike};
+
+pub fn hostname() -> &'static str {
+    static HOSTNAME: OnceLock<String> = OnceLock::new();
+
+    // We should rather use get_or_try_init to avoid needing to panic
+    // but it is currently experimental
+    HOSTNAME.get_or_init(|| {
+        use nix::libc::{c_char, gethostname, sysconf, _SC_HOST_NAME_MAX};
+        use std::ffi::CStr;
+
+        let max_len = unsafe { sysconf(_SC_HOST_NAME_MAX) } as usize + 1;
+        let mut buffer = vec![0; max_len];
+
+        let ret = unsafe { gethostname(buffer.as_mut_ptr() as *mut c_char, buffer.len()) };
+
+        if ret != 0 {
+            // failing to get the hostname means something is *really* off
+            panic!("gethostname failed with returncode {ret}");
+        }
+
+        let c_str = CStr::from_bytes_until_nul(&buffer).expect("buffer contains a NUL byte");
+
+        String::from_utf8_lossy(c_str.to_bytes()).to_string()
+    })
+}
+
+pub fn host_ips() -> &'static [IpAddr] {
+    static IP_ADDRESSES: OnceLock<Vec<IpAddr>> = OnceLock::new();
+
+    // We should rather use get_or_try_init to avoid needing to panic
+    // but it is currently experimental
+    IP_ADDRESSES.get_or_init(|| {
+        let hostname = hostname();
+
+        format!("{hostname}:0")
+            .to_socket_addrs()
+            .expect("local hostname is resolvable")
+            .map(|addr| addr.ip())
+            .collect()
+    })
+}
+
+pub fn network_interface_cidrs() -> &'static [Cidr] {
+    static INTERFACES: OnceLock<Vec<Cidr>> = OnceLock::new();
+
+    // We should rather use get_or_try_init to avoid needing to panic
+    // but it is currently experimental
+    INTERFACES.get_or_init(|| {
+        use nix::ifaddrs::getifaddrs;
+
+        let mut cidrs = Vec::new();
+
+        let interfaces = getifaddrs().expect("should be able to query network interfaces");
+
+        for interface in interfaces {
+            if let (Some(address), Some(netmask)) = (interface.address, interface.netmask) {
+                match (address.family(), netmask.family()) {
+                    (Some(AddressFamily::Inet), Some(AddressFamily::Inet)) => {
+                        let address = address.as_sockaddr_in().expect("is an IPv4 address").ip();
+
+                        let netmask = netmask
+                            .as_sockaddr_in()
+                            .expect("is an IPv4 address")
+                            .ip()
+                            .count_ones()
+                            .try_into()
+                            .expect("count_ones of u32 is < u8_max");
+
+                        cidrs.push(Cidr::new_v4(address, netmask).expect("netmask is valid"));
+                    }
+                    (Some(AddressFamily::Inet6), Some(AddressFamily::Inet6)) => {
+                        let address = address.as_sockaddr_in6().expect("is an IPv6 address").ip();
+
+                        let netmask_address =
+                            netmask.as_sockaddr_in6().expect("is an IPv6 address").ip();
+
+                        let netmask = u128::from_be_bytes(netmask_address.octets())
+                            .count_ones()
+                            .try_into()
+                            .expect("count_ones of u128 is < u8_max");
+
+                        cidrs.push(Cidr::new_v6(address, netmask).expect("netmask is valid"));
+                    }
+                    _ => continue,
+                }
+            }
+        }
+
+        cidrs
+    })
+}
diff --git a/proxmox-ve-config/src/lib.rs b/proxmox-ve-config/src/lib.rs
index a0734b8..2bf9352 100644
--- a/proxmox-ve-config/src/lib.rs
+++ b/proxmox-ve-config/src/lib.rs
@@ -1 +1,2 @@
 pub mod firewall;
+pub mod host;
-- 
2.39.2




^ permalink raw reply	[flat|nested] 67+ messages in thread

* [pve-devel] [PATCH proxmox-firewall 07/37] config: guest: add helpers for parsing guest network config
  2024-04-02 17:15 [pve-devel] [RFC container/firewall/manager/proxmox-firewall/qemu-server 00/37] proxmox firewall nftables implementation Stefan Hanreich
                   ` (5 preceding siblings ...)
  2024-04-02 17:15 ` [pve-devel] [PATCH proxmox-firewall 06/37] config: host: add helpers for host network configuration Stefan Hanreich
@ 2024-04-02 17:15 ` Stefan Hanreich
  2024-04-02 17:16 ` [pve-devel] [PATCH proxmox-firewall 08/37] config: firewall: add types for ipsets Stefan Hanreich
                   ` (34 subsequent siblings)
  41 siblings, 0 replies; 67+ messages in thread
From: Stefan Hanreich @ 2024-04-02 17:15 UTC (permalink / raw)
  To: pve-devel; +Cc: Stefan Hanreich, Wolfgang Bumiller

Currently this is parsing the config files via the filesystem. In the
future we could also get this information from pmxcfs directly via
IPC which should be more performant, particularly for a large number
of VMs.

Co-authored-by: Wolfgang Bumiller <w.bumiller@proxmox.com>
Signed-off-by: Stefan Hanreich <s.hanreich@proxmox.com>
---
 proxmox-ve-config/Cargo.toml            |   2 +
 proxmox-ve-config/src/firewall/parse.rs |  15 +
 proxmox-ve-config/src/guest/mod.rs      | 100 ++++++
 proxmox-ve-config/src/guest/types.rs    |  32 ++
 proxmox-ve-config/src/guest/vm.rs       | 431 ++++++++++++++++++++++++
 proxmox-ve-config/src/lib.rs            |   1 +
 6 files changed, 581 insertions(+)
 create mode 100644 proxmox-ve-config/src/guest/mod.rs
 create mode 100644 proxmox-ve-config/src/guest/types.rs
 create mode 100644 proxmox-ve-config/src/guest/vm.rs

diff --git a/proxmox-ve-config/Cargo.toml b/proxmox-ve-config/Cargo.toml
index 480eb58..b009d53 100644
--- a/proxmox-ve-config/Cargo.toml
+++ b/proxmox-ve-config/Cargo.toml
@@ -19,3 +19,5 @@ serde = { version = "1", features = [ "derive" ] }
 serde_json = "1"
 serde_plain = "1"
 serde_with = "2.3.3"
+
+proxmox-schema = "3.1.0"
diff --git a/proxmox-ve-config/src/firewall/parse.rs b/proxmox-ve-config/src/firewall/parse.rs
index 8e30006..669623b 100644
--- a/proxmox-ve-config/src/firewall/parse.rs
+++ b/proxmox-ve-config/src/firewall/parse.rs
@@ -44,6 +44,21 @@ pub fn match_non_whitespace(line: &str) -> Option<(&str, &str)> {
         Some((text, rest))
     }
 }
+
+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<bool, Error> {
     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..b10318e
--- /dev/null
+++ b/proxmox-ve-config/src/guest/mod.rs
@@ -0,0 +1,100 @@
+use core::ops::Deref;
+use std::collections::HashMap;
+
+use anyhow::{format_err, Error};
+use serde::Deserialize;
+
+use crate::host::utils::hostname;
+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 is_local(&self) -> bool {
+        hostname() == 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<Vmid, GuestEntry>,
+}
+
+impl Deref for GuestMap {
+    type Target = HashMap<Vmid, GuestEntry>;
+
+    fn deref(&self) -> &Self::Target {
+        &self.guests
+    }
+}
+
+impl GuestMap {
+    pub fn load() -> Result<Self, Error> {
+        let data = std::fs::read(VMLIST_CONFIG_PATH)
+            .map_err(|err| format_err!("failed to read {VMLIST_CONFIG_PATH} {err}"))?;
+
+        serde_json::from_slice(&data)
+            .map_err(|err| format_err!("failed to parse {VMLIST_CONFIG_PATH} {err}"))
+    }
+
+    pub fn firewall_config_path(&self, vmid: &Vmid) -> String {
+        format!("/etc/pve/firewall/{}.fw", vmid)
+    }
+
+    pub fn config_path_local(&self, vmid: &Vmid) -> Option<String> {
+        if let Some(vm) = self.get(vmid) {
+            return Some(format!(
+                "/etc/pve/local/{}/{}.conf",
+                vm.ty.config_folder(),
+                vmid
+            ));
+        }
+
+        None
+    }
+}
diff --git a/proxmox-ve-config/src/guest/types.rs b/proxmox-ve-config/src/guest/types.rs
new file mode 100644
index 0000000..3e3e5ba
--- /dev/null
+++ b/proxmox-ve-config/src/guest/types.rs
@@ -0,0 +1,32 @@
+use std::fmt;
+use std::str::FromStr;
+
+use anyhow::{format_err, Error};
+
+#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)]
+pub struct Vmid(u32);
+
+impl Vmid {
+    pub fn new(id: u32) -> Self {
+        Vmid(id)
+    }
+}
+
+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<Self, Self::Err> {
+        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..18970f0
--- /dev/null
+++ b/proxmox-ve-config/src/guest/vm.rs
@@ -0,0 +1,431 @@
+use anyhow::{bail, Error};
+use core::fmt::Display;
+use std::collections::HashMap;
+use std::io;
+use std::str::FromStr;
+
+use proxmox_schema::property_string::PropertyIterator;
+
+use crate::firewall::parse::{match_digits, parse_bool};
+
+#[derive(Debug)]
+#[cfg_attr(test, derive(Eq, PartialEq))]
+pub struct MacAddress([u8; 6]);
+
+impl FromStr for MacAddress {
+    type Err = Error;
+
+    fn from_str(s: &str) -> Result<Self, Self::Err> {
+        let split = s.split(':');
+
+        let parsed = split
+            .into_iter()
+            .map(|elem| u8::from_str_radix(elem, 16))
+            .collect::<Result<Vec<u8>, _>>()
+            .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<Self, Self::Err> {
+        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,
+}
+
+impl NetworkDevice {
+    pub fn model(&self) -> NetworkDeviceModel {
+        self.model
+    }
+
+    pub fn mac_address(&self) -> &MacAddress {
+        &self.mac_address
+    }
+
+    pub fn has_firewall(&self) -> bool {
+        self.firewall
+    }
+}
+
+impl FromStr for NetworkDevice {
+    type Err = Error;
+
+    fn from_str(s: &str) -> Result<Self, Self::Err> {
+        let (mut ty, mut hwaddr, mut firewall) = (None, None, true);
+
+        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)?;
+                    }
+                    _ => {
+                        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,
+            });
+        }
+
+        bail!("No valid network device detected in string {s}");
+    }
+}
+
+#[derive(Debug, Default)]
+#[cfg_attr(test, derive(Eq, PartialEq))]
+pub struct NetworkConfig {
+    network_devices: HashMap<i64, NetworkDevice>,
+}
+
+impl NetworkConfig {
+    pub fn new() -> Self {
+        Self::default()
+    }
+
+    pub fn index_from_net_key(key: &str) -> Result<i64, Error> {
+        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<i64, NetworkDevice> {
+        &self.network_devices
+    }
+
+    pub fn parse<R: io::BufRead>(input: R) -> Result<Self, Error> {
+        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") {
+                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::<MacAddress>().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::<MacAddress>()
+                .expect_err("invalid mac 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,
+            }
+        );
+
+        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,
+            }
+        );
+
+        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,
+            }
+        );
+
+        "model=virtio"
+            .parse::<NetworkDevice>()
+            .expect_err("invalid network configuration");
+
+        "bridge=public,firewall=0"
+            .parse::<NetworkDevice>()
+            .expect_err("invalid network configuration");
+
+        "".parse::<NetworkDevice>()
+            .expect_err("invalid network configuration");
+
+        "name=eth0,bridge=public,firewall=0,hwaddr=AA:AA:AG:E2:3E:24,ip=dhcp,type=veth"
+            .parse::<NetworkDevice>()
+            .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,
+            }
+        );
+
+        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=dhcp,type=veth  
+net5: name=eth0,bridge=data,firewall=1,hwaddr=BC:24:11:47:83:13,ip=dhcp,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,
+            }
+        );
+
+        assert_eq!(
+            network_config.network_devices()[&2],
+            NetworkDevice {
+                model: NetworkDeviceModel::Veth,
+                mac_address: MacAddress([0xBC, 0x24, 0x11, 0x47, 0x83, 0x12]),
+                firewall: false,
+            }
+        );
+
+        assert_eq!(
+            network_config.network_devices()[&5],
+            NetworkDevice {
+                model: NetworkDeviceModel::Veth,
+                mac_address: MacAddress([0xBC, 0x24, 0x11, 0x47, 0x83, 0x13]),
+                firewall: true,
+            }
+        );
+
+        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;
-- 
2.39.2




^ permalink raw reply	[flat|nested] 67+ messages in thread

* [pve-devel] [PATCH proxmox-firewall 08/37] config: firewall: add types for ipsets
  2024-04-02 17:15 [pve-devel] [RFC container/firewall/manager/proxmox-firewall/qemu-server 00/37] proxmox firewall nftables implementation Stefan Hanreich
                   ` (6 preceding siblings ...)
  2024-04-02 17:15 ` [pve-devel] [PATCH proxmox-firewall 07/37] config: guest: add helpers for parsing guest network config Stefan Hanreich
@ 2024-04-02 17:16 ` Stefan Hanreich
  2024-04-02 17:16 ` [pve-devel] [PATCH proxmox-firewall 09/37] config: firewall: add types for rules Stefan Hanreich
                   ` (33 subsequent siblings)
  41 siblings, 0 replies; 67+ messages in thread
From: Stefan Hanreich @ 2024-04-02 17:16 UTC (permalink / raw)
  To: pve-devel; +Cc: Stefan Hanreich, Wolfgang Bumiller

Co-authored-by: Wolfgang Bumiller <w.bumiller@proxmox.com>
Signed-off-by: Stefan Hanreich <s.hanreich@proxmox.com>
---
 proxmox-ve-config/src/firewall/types/ipset.rs | 345 ++++++++++++++++++
 proxmox-ve-config/src/firewall/types/mod.rs   |   2 +
 2 files changed, 347 insertions(+)
 create mode 100644 proxmox-ve-config/src/firewall/types/ipset.rs

diff --git a/proxmox-ve-config/src/firewall/types/ipset.rs b/proxmox-ve-config/src/firewall/types/ipset.rs
new file mode 100644
index 0000000..32db51b
--- /dev/null
+++ b/proxmox-ve-config/src/firewall/types/ipset.rs
@@ -0,0 +1,345 @@
+use core::fmt::Display;
+use std::ops::{Deref, DerefMut};
+use std::str::FromStr;
+
+use anyhow::{bail, format_err, Error};
+use serde_with::DeserializeFromStr;
+
+use crate::firewall::parse::match_non_whitespace;
+use crate::firewall::types::address::Cidr;
+use crate::firewall::types::alias::AliasName;
+use crate::guest::vm::NetworkConfig;
+
+#[derive(Debug, Clone, Copy, Eq, PartialEq)]
+pub enum IpsetScope {
+    Datacenter,
+    Guest,
+}
+
+impl FromStr for IpsetScope {
+    type Err = Error;
+
+    fn from_str(s: &str) -> Result<Self, Self::Err> {
+        Ok(match s {
+            "+dc" => IpsetScope::Datacenter,
+            "+guest" => IpsetScope::Guest,
+            _ => bail!("invalid scope for ipset: {s}"),
+        })
+    }
+}
+
+impl Display for IpsetScope {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        let prefix = match self {
+            Self::Datacenter => "dc",
+            Self::Guest => "guest",
+        };
+
+        f.write_str(prefix)
+    }
+}
+
+#[derive(Debug, Clone, DeserializeFromStr)]
+#[cfg_attr(test, derive(Eq, PartialEq))]
+pub struct IpsetName {
+    pub scope: IpsetScope,
+    pub name: String,
+}
+
+impl IpsetName {
+    pub fn new(scope: IpsetScope, name: impl Into<String>) -> Self {
+        Self {
+            scope,
+            name: name.into(),
+        }
+    }
+
+    pub fn name(&self) -> &str {
+        &self.name
+    }
+
+    pub fn scope(&self) -> IpsetScope {
+        self.scope
+    }
+}
+
+impl FromStr for IpsetName {
+    type Err = Error;
+
+    fn from_str(s: &str) -> Result<Self, Self::Err> {
+        match s.split_once('/') {
+            Some((prefix, name)) if !name.is_empty() => Ok(Self {
+                scope: prefix.parse()?,
+                name: name.to_string(),
+            }),
+            _ => {
+                bail!("Invalid IPSet name: {s}")
+            }
+        }
+    }
+}
+
+impl Display for IpsetName {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        write!(f, "{}/{}", self.scope, self.name)
+    }
+}
+
+#[derive(Debug)]
+#[cfg_attr(test, derive(Eq, PartialEq))]
+pub enum IpsetAddress {
+    Alias(AliasName),
+    Cidr(Cidr),
+}
+
+impl FromStr for IpsetAddress {
+    type Err = Error;
+
+    fn from_str(s: &str) -> Result<Self, Error> {
+        if let Ok(cidr) = s.parse() {
+            return Ok(IpsetAddress::Cidr(cidr));
+        }
+
+        if let Ok(name) = s.parse() {
+            return Ok(IpsetAddress::Alias(name));
+        }
+
+        bail!("Invalid address in IPSet: {s}")
+    }
+}
+
+impl<T: Into<Cidr>> From<T> for IpsetAddress {
+    fn from(cidr: T) -> Self {
+        IpsetAddress::Cidr(cidr.into())
+    }
+}
+
+#[derive(Debug)]
+#[cfg_attr(test, derive(Eq, PartialEq))]
+pub struct IpsetEntry {
+    pub nomatch: bool,
+    pub address: IpsetAddress,
+    pub comment: Option<String>,
+}
+
+impl<T: Into<IpsetAddress>> From<T> for IpsetEntry {
+    fn from(value: T) -> Self {
+        Self {
+            nomatch: false,
+            address: value.into(),
+            comment: None,
+        }
+    }
+}
+
+impl FromStr for IpsetEntry {
+    type Err = Error;
+
+    fn from_str(line: &str) -> Result<Self, Error> {
+        let line = line.trim_start();
+
+        let (nomatch, line) = match line.strip_prefix('!') {
+            Some(line) => (true, line),
+            None => (false, line),
+        };
+
+        let (address, line) =
+            match_non_whitespace(line.trim_start()).ok_or_else(|| format_err!("missing value"))?;
+
+        let address: IpsetAddress = address.parse()?;
+        let line = line.trim_start();
+
+        let comment = match line.strip_prefix('#') {
+            Some(comment) => Some(comment.trim().to_string()),
+            None if !line.is_empty() => bail!("trailing characters in ipset entry: {line:?}"),
+            None => None,
+        };
+
+        Ok(Self {
+            nomatch,
+            address,
+            comment,
+        })
+    }
+}
+
+#[derive(Debug)]
+#[cfg_attr(test, derive(Eq, PartialEq))]
+pub struct Ipfilter<'a> {
+    index: i64,
+    ipset: &'a Ipset,
+}
+
+impl Ipfilter<'_> {
+    pub fn index(&self) -> i64 {
+        self.index
+    }
+
+    pub fn ipset(&self) -> &Ipset {
+        self.ipset
+    }
+}
+
+#[derive(Debug)]
+#[cfg_attr(test, derive(Eq, PartialEq))]
+pub struct Ipset {
+    pub name: IpsetName,
+    set: Vec<IpsetEntry>,
+    pub comment: Option<String>,
+}
+
+impl Ipset {
+    pub const fn new(name: IpsetName) -> Self {
+        Self {
+            name,
+            set: Vec::new(),
+            comment: None,
+        }
+    }
+
+    pub fn name(&self) -> &IpsetName {
+        &self.name
+    }
+
+    pub fn from_parts(scope: IpsetScope, name: impl Into<String>) -> Self {
+        Self::new(IpsetName::new(scope, name))
+    }
+
+    pub(crate) fn parse_entry(&mut self, line: &str) -> Result<(), Error> {
+        self.set.push(line.parse()?);
+        Ok(())
+    }
+
+    pub fn ipfilter(&self) -> Option<Ipfilter> {
+        if self.name.scope() != IpsetScope::Guest {
+            return None;
+        }
+
+        let name = self.name.name();
+
+        if let Some(key) = name.strip_prefix("ipfilter-") {
+            let id = NetworkConfig::index_from_net_key(key);
+
+            if let Ok(id) = id {
+                return Some(Ipfilter {
+                    index: id,
+                    ipset: self,
+                });
+            }
+        }
+
+        None
+    }
+}
+
+impl Deref for Ipset {
+    type Target = Vec<IpsetEntry>;
+
+    #[inline]
+    fn deref(&self) -> &Self::Target {
+        &self.set
+    }
+}
+
+impl DerefMut for Ipset {
+    #[inline]
+    fn deref_mut(&mut self) -> &mut Vec<IpsetEntry> {
+        &mut self.set
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn test_parse_ipset_name() {
+        for test_case in [
+            ("+dc/proxmox-123", IpsetScope::Datacenter, "proxmox-123"),
+            ("+guest/proxmox_123", IpsetScope::Guest, "proxmox_123"),
+        ] {
+            let ipset_name = test_case.0.parse::<IpsetName>().expect("valid ipset name");
+
+            assert_eq!(
+                ipset_name,
+                IpsetName {
+                    scope: test_case.1,
+                    name: test_case.2.to_string(),
+                }
+            )
+        }
+
+        for name in ["+dc/", "+guests/proxmox_123", "guest/proxmox_123"] {
+            name.parse::<IpsetName>().expect_err("invalid ipset name");
+        }
+    }
+
+    #[test]
+    fn test_parse_ipset_address() {
+        let mut ipset_address = "10.0.0.1"
+            .parse::<IpsetAddress>()
+            .expect("valid ipset address");
+        assert!(matches!(ipset_address, IpsetAddress::Cidr(Cidr::Ipv4(..))));
+
+        ipset_address = "fe80::1/64"
+            .parse::<IpsetAddress>()
+            .expect("valid ipset address");
+        assert!(matches!(ipset_address, IpsetAddress::Cidr(Cidr::Ipv6(..))));
+
+        ipset_address = "dc/proxmox-123"
+            .parse::<IpsetAddress>()
+            .expect("valid ipset address");
+        assert!(matches!(ipset_address, IpsetAddress::Alias(..)));
+
+        ipset_address = "guest/proxmox_123"
+            .parse::<IpsetAddress>()
+            .expect("valid ipset address");
+        assert!(matches!(ipset_address, IpsetAddress::Alias(..)));
+    }
+
+    #[test]
+    fn test_ipfilter() {
+        let mut ipset = Ipset::from_parts(IpsetScope::Guest, "ipfilter-net0");
+        ipset.ipfilter().expect("is an ipfilter");
+
+        ipset = Ipset::from_parts(IpsetScope::Guest, "ipfilter-qwe");
+        assert!(ipset.ipfilter().is_none());
+
+        ipset = Ipset::from_parts(IpsetScope::Guest, "proxmox");
+        assert!(ipset.ipfilter().is_none());
+
+        ipset = Ipset::from_parts(IpsetScope::Datacenter, "ipfilter-net0");
+        assert!(ipset.ipfilter().is_none());
+    }
+
+    #[test]
+    fn test_parse_ipset_entry() {
+        let mut entry = "!10.0.0.1 # qweqweasd"
+            .parse::<IpsetEntry>()
+            .expect("valid ipset entry");
+
+        assert_eq!(
+            entry,
+            IpsetEntry {
+                nomatch: true,
+                comment: Some("qweqweasd".to_string()),
+                address: IpsetAddress::Cidr(Cidr::new_v4([10, 0, 0, 1], 32).unwrap())
+            }
+        );
+
+        entry = "fe80::1/48"
+            .parse::<IpsetEntry>()
+            .expect("valid ipset entry");
+
+        assert_eq!(
+            entry,
+            IpsetEntry {
+                nomatch: false,
+                comment: None,
+                address: IpsetAddress::Cidr(
+                    Cidr::new_v6([0xFE80, 0, 0, 0, 0, 0, 0, 1], 48).unwrap()
+                )
+            }
+        )
+    }
+}
diff --git a/proxmox-ve-config/src/firewall/types/mod.rs b/proxmox-ve-config/src/firewall/types/mod.rs
index 69b69f4..5833787 100644
--- a/proxmox-ve-config/src/firewall/types/mod.rs
+++ b/proxmox-ve-config/src/firewall/types/mod.rs
@@ -1,7 +1,9 @@
 pub mod address;
 pub mod alias;
+pub mod ipset;
 pub mod log;
 pub mod port;
 
 pub use address::Cidr;
 pub use alias::Alias;
+pub use ipset::Ipset;
-- 
2.39.2




^ permalink raw reply	[flat|nested] 67+ messages in thread

* [pve-devel] [PATCH proxmox-firewall 09/37] config: firewall: add types for rules
  2024-04-02 17:15 [pve-devel] [RFC container/firewall/manager/proxmox-firewall/qemu-server 00/37] proxmox firewall nftables implementation Stefan Hanreich
                   ` (7 preceding siblings ...)
  2024-04-02 17:16 ` [pve-devel] [PATCH proxmox-firewall 08/37] config: firewall: add types for ipsets Stefan Hanreich
@ 2024-04-02 17:16 ` Stefan Hanreich
  2024-04-03 10:46   ` Max Carrara
  2024-04-02 17:16 ` [pve-devel] [PATCH proxmox-firewall 10/37] config: firewall: add types for security groups Stefan Hanreich
                   ` (32 subsequent siblings)
  41 siblings, 1 reply; 67+ messages in thread
From: Stefan Hanreich @ 2024-04-02 17:16 UTC (permalink / raw)
  To: pve-devel; +Cc: Stefan Hanreich, Wolfgang Bumiller

Additionally we implement FromStr for all rule types and parts, which
can be used for parsing firewall config rules. Initial rule parsing
works by parsing the different options into a HashMap and only then
de-serializing a struct from the parsed options.

This intermediate step makes rule parsing a lot easier, since we can
reuse the deserialization logic from serde. Also, we can split the
parsing/deserialization logic from the validation logic.

Co-authored-by: Wolfgang Bumiller <w.bumiller@proxmox.com>
Signed-off-by: Stefan Hanreich <s.hanreich@proxmox.com>
---
 proxmox-ve-config/src/firewall/parse.rs       | 185 ++++
 proxmox-ve-config/src/firewall/types/mod.rs   |   3 +
 proxmox-ve-config/src/firewall/types/rule.rs  | 412 ++++++++
 .../src/firewall/types/rule_match.rs          | 953 ++++++++++++++++++
 4 files changed, 1553 insertions(+)
 create mode 100644 proxmox-ve-config/src/firewall/types/rule.rs
 create mode 100644 proxmox-ve-config/src/firewall/types/rule_match.rs

diff --git a/proxmox-ve-config/src/firewall/parse.rs b/proxmox-ve-config/src/firewall/parse.rs
index 669623b..227e045 100644
--- a/proxmox-ve-config/src/firewall/parse.rs
+++ b/proxmox-ve-config/src/firewall/parse.rs
@@ -1,3 +1,5 @@
+use std::fmt;
+
 use anyhow::{bail, format_err, Error};
 
 /// Parses out a "name" which can be alphanumeric and include dashes.
@@ -78,3 +80,186 @@ pub fn parse_bool(value: &str) -> Result<bool, Error> {
         },
     )
 }
+
+/// `&str` deserializer which also accepts an `Option`.
+///
+/// Serde's `StringDeserializer` does not.
+#[derive(Clone, Copy, Debug)]
+pub struct SomeStrDeserializer<'a, E>(serde::de::value::StrDeserializer<'a, E>);
+
+impl<'de, 'a, E> serde::de::Deserializer<'de> for SomeStrDeserializer<'a, E>
+where
+    E: serde::de::Error,
+{
+    type Error = E;
+
+    fn deserialize_any<V>(self, visitor: V) -> Result<V::Value, Self::Error>
+    where
+        V: serde::de::Visitor<'de>,
+    {
+        self.0.deserialize_any(visitor)
+    }
+
+    fn deserialize_option<V>(self, visitor: V) -> Result<V::Value, Self::Error>
+    where
+        V: serde::de::Visitor<'de>,
+    {
+        visitor.visit_some(self.0)
+    }
+
+    fn deserialize_str<V>(self, visitor: V) -> Result<V::Value, Self::Error>
+    where
+        V: serde::de::Visitor<'de>,
+    {
+        self.0.deserialize_str(visitor)
+    }
+
+    fn deserialize_string<V>(self, visitor: V) -> Result<V::Value, Self::Error>
+    where
+        V: serde::de::Visitor<'de>,
+    {
+        self.0.deserialize_string(visitor)
+    }
+
+    fn deserialize_enum<V>(
+        self,
+        _name: &str,
+        _variants: &'static [&'static str],
+        visitor: V,
+    ) -> Result<V::Value, Self::Error>
+    where
+        V: serde::de::Visitor<'de>,
+    {
+        visitor.visit_enum(self.0)
+    }
+
+    serde::forward_to_deserialize_any! {
+        bool i8 i16 i32 i64 i128 u8 u16 u32 u64 u128 f32 f64 char
+        bytes byte_buf unit unit_struct newtype_struct seq tuple
+        tuple_struct map struct identifier ignored_any
+    }
+}
+
+/// `&str` wrapper which implements `IntoDeserializer` via `SomeStrDeserializer`.
+#[derive(Clone, Debug)]
+pub struct SomeStr<'a>(pub &'a str);
+
+impl<'a> From<&'a str> for SomeStr<'a> {
+    fn from(s: &'a str) -> Self {
+        Self(s)
+    }
+}
+
+impl<'de, 'a, E> serde::de::IntoDeserializer<'de, E> for SomeStr<'a>
+where
+    E: serde::de::Error,
+{
+    type Deserializer = SomeStrDeserializer<'a, E>;
+
+    fn into_deserializer(self) -> Self::Deserializer {
+        SomeStrDeserializer(self.0.into_deserializer())
+    }
+}
+
+/// `String` deserializer which also accepts an `Option`.
+///
+/// Serde's `StringDeserializer` does not.
+#[derive(Clone, Debug)]
+pub struct SomeStringDeserializer<E>(serde::de::value::StringDeserializer<E>);
+
+impl<'de, E> serde::de::Deserializer<'de> for SomeStringDeserializer<E>
+where
+    E: serde::de::Error,
+{
+    type Error = E;
+
+    fn deserialize_any<V>(self, visitor: V) -> Result<V::Value, Self::Error>
+    where
+        V: serde::de::Visitor<'de>,
+    {
+        self.0.deserialize_any(visitor)
+    }
+
+    fn deserialize_option<V>(self, visitor: V) -> Result<V::Value, Self::Error>
+    where
+        V: serde::de::Visitor<'de>,
+    {
+        visitor.visit_some(self.0)
+    }
+
+    fn deserialize_str<V>(self, visitor: V) -> Result<V::Value, Self::Error>
+    where
+        V: serde::de::Visitor<'de>,
+    {
+        self.0.deserialize_str(visitor)
+    }
+
+    fn deserialize_string<V>(self, visitor: V) -> Result<V::Value, Self::Error>
+    where
+        V: serde::de::Visitor<'de>,
+    {
+        self.0.deserialize_string(visitor)
+    }
+
+    fn deserialize_enum<V>(
+        self,
+        _name: &str,
+        _variants: &'static [&'static str],
+        visitor: V,
+    ) -> Result<V::Value, Self::Error>
+    where
+        V: serde::de::Visitor<'de>,
+    {
+        visitor.visit_enum(self.0)
+    }
+
+    serde::forward_to_deserialize_any! {
+        bool i8 i16 i32 i64 i128 u8 u16 u32 u64 u128 f32 f64 char
+        bytes byte_buf unit unit_struct newtype_struct seq tuple
+        tuple_struct map struct identifier ignored_any
+    }
+}
+
+/// `&str` wrapper which implements `IntoDeserializer` via `SomeStringDeserializer`.
+#[derive(Clone, Debug)]
+pub struct SomeString(pub String);
+
+impl From<&str> for SomeString {
+    fn from(s: &str) -> Self {
+        Self::from(s.to_string())
+    }
+}
+
+impl From<String> for SomeString {
+    fn from(s: String) -> Self {
+        Self(s)
+    }
+}
+
+impl<'de, E> serde::de::IntoDeserializer<'de, E> for SomeString
+where
+    E: serde::de::Error,
+{
+    type Deserializer = SomeStringDeserializer<E>;
+
+    fn into_deserializer(self) -> Self::Deserializer {
+        SomeStringDeserializer(self.0.into_deserializer())
+    }
+}
+
+#[derive(Debug)]
+pub struct SerdeStringError(String);
+
+impl std::error::Error for SerdeStringError {}
+
+impl fmt::Display for SerdeStringError {
+    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
+        f.write_str(&self.0)
+    }
+}
+
+impl serde::de::Error for SerdeStringError {
+    fn custom<T: fmt::Display>(msg: T) -> Self {
+        Self(msg.to_string())
+    }
+}
diff --git a/proxmox-ve-config/src/firewall/types/mod.rs b/proxmox-ve-config/src/firewall/types/mod.rs
index 5833787..b4a6b12 100644
--- a/proxmox-ve-config/src/firewall/types/mod.rs
+++ b/proxmox-ve-config/src/firewall/types/mod.rs
@@ -3,7 +3,10 @@ pub mod alias;
 pub mod ipset;
 pub mod log;
 pub mod port;
+pub mod rule;
+pub mod rule_match;
 
 pub use address::Cidr;
 pub use alias::Alias;
 pub use ipset::Ipset;
+pub use rule::Rule;
diff --git a/proxmox-ve-config/src/firewall/types/rule.rs b/proxmox-ve-config/src/firewall/types/rule.rs
new file mode 100644
index 0000000..20deb3a
--- /dev/null
+++ b/proxmox-ve-config/src/firewall/types/rule.rs
@@ -0,0 +1,412 @@
+use core::fmt::Display;
+use std::fmt;
+use std::str::FromStr;
+
+use anyhow::{bail, ensure, format_err, Error};
+
+use crate::firewall::parse::match_name;
+use crate::firewall::types::rule_match::RuleMatch;
+use crate::firewall::types::rule_match::RuleOptions;
+
+#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
+pub enum Direction {
+    #[default]
+    In,
+    Out,
+}
+
+impl std::str::FromStr for Direction {
+    type Err = Error;
+
+    fn from_str(s: &str) -> Result<Self, Error> {
+        for (name, dir) in [("IN", Direction::In), ("OUT", Direction::Out)] {
+            if s.eq_ignore_ascii_case(name) {
+                return Ok(dir);
+            }
+        }
+
+        bail!("invalid direction: {s:?}, expect 'IN' or 'OUT'");
+    }
+}
+
+serde_plain::derive_deserialize_from_fromstr!(Direction, "valid packet direction");
+
+impl fmt::Display for Direction {
+    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
+        match self {
+            Direction::In => f.write_str("in"),
+            Direction::Out => f.write_str("out"),
+        }
+    }
+}
+
+#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
+pub enum Verdict {
+    Accept,
+    Reject,
+    #[default]
+    Drop,
+}
+
+impl std::str::FromStr for Verdict {
+    type Err = Error;
+
+    fn from_str(s: &str) -> Result<Self, Error> {
+        for (name, verdict) in [
+            ("ACCEPT", Verdict::Accept),
+            ("REJECT", Verdict::Reject),
+            ("DROP", Verdict::Drop),
+        ] {
+            if s.eq_ignore_ascii_case(name) {
+                return Ok(verdict);
+            }
+        }
+        bail!("invalid verdict {s:?}, expected one of 'ACCEPT', 'REJECT' or 'DROP'");
+    }
+}
+
+impl Display for Verdict {
+    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+        let string = match self {
+            Verdict::Accept => "ACCEPT",
+            Verdict::Drop => "DROP",
+            Verdict::Reject => "REJECT",
+        };
+
+        write!(f, "{string}")
+    }
+}
+
+serde_plain::derive_deserialize_from_fromstr!(Verdict, "valid verdict");
+
+#[derive(Clone, Debug)]
+#[cfg_attr(test, derive(Eq, PartialEq))]
+pub struct Rule {
+    pub(crate) disabled: bool,
+    pub(crate) kind: Kind,
+    pub(crate) comment: Option<String>,
+}
+
+impl std::ops::Deref for Rule {
+    type Target = Kind;
+
+    fn deref(&self) -> &Self::Target {
+        &self.kind
+    }
+}
+
+impl std::ops::DerefMut for Rule {
+    fn deref_mut(&mut self) -> &mut Self::Target {
+        &mut self.kind
+    }
+}
+
+impl FromStr for Rule {
+    type Err = Error;
+
+    fn from_str(input: &str) -> Result<Self, Self::Err> {
+        if input.contains(['\n', '\r']) {
+            bail!("rule must not contain any newlines!");
+        }
+
+        let (line, comment) = match input.rsplit_once('#') {
+            Some((line, comment)) if !comment.is_empty() => (line.trim(), Some(comment.trim())),
+            _ => (input.trim(), None),
+        };
+
+        let (disabled, line) = match line.strip_prefix('|') {
+            Some(line) => (true, line.trim_start()),
+            None => (false, line),
+        };
+
+        // todo: case insensitive?
+        let kind = if line.starts_with("GROUP") {
+            Kind::from(line.parse::<RuleGroup>()?)
+        } else {
+            Kind::from(line.parse::<RuleMatch>()?)
+        };
+
+        Ok(Self {
+            disabled,
+            comment: comment.map(str::to_string),
+            kind,
+        })
+    }
+}
+
+impl Rule {
+    pub fn iface(&self) -> Option<&str> {
+        match &self.kind {
+            Kind::Group(group) => group.iface(),
+            Kind::Match(rule) => rule.iface(),
+        }
+    }
+
+    pub fn disabled(&self) -> bool {
+        self.disabled
+    }
+
+    pub fn kind(&self) -> &Kind {
+        &self.kind
+    }
+
+    pub fn comment(&self) -> Option<&str> {
+        self.comment.as_deref()
+    }
+}
+
+#[derive(Clone, Debug)]
+#[cfg_attr(test, derive(Eq, PartialEq))]
+pub enum Kind {
+    Group(RuleGroup),
+    Match(RuleMatch),
+}
+
+impl Kind {
+    pub fn is_group(&self) -> bool {
+        matches!(self, Kind::Group(_))
+    }
+
+    pub fn is_match(&self) -> bool {
+        matches!(self, Kind::Match(_))
+    }
+}
+
+impl From<RuleGroup> for Kind {
+    fn from(value: RuleGroup) -> Self {
+        Kind::Group(value)
+    }
+}
+
+impl From<RuleMatch> for Kind {
+    fn from(value: RuleMatch) -> Self {
+        Kind::Match(value)
+    }
+}
+
+#[derive(Clone, Debug)]
+#[cfg_attr(test, derive(Eq, PartialEq))]
+pub struct RuleGroup {
+    pub(crate) group: String,
+    pub(crate) iface: Option<String>,
+}
+
+impl RuleGroup {
+    pub(crate) fn from_options(group: String, options: RuleOptions) -> Result<Self, Error> {
+        ensure!(
+            options.proto.is_none()
+                && options.dport.is_none()
+                && options.sport.is_none()
+                && options.dest.is_none()
+                && options.source.is_none()
+                && options.log.is_none()
+                && options.icmp_type.is_none(),
+            "only interface parameter is permitted for group rules"
+        );
+
+        Ok(Self {
+            group,
+            iface: options.iface,
+        })
+    }
+
+    pub fn group(&self) -> &str {
+        &self.group
+    }
+
+    pub fn iface(&self) -> Option<&str> {
+        self.iface.as_deref()
+    }
+}
+
+impl FromStr for RuleGroup {
+    type Err = Error;
+
+    fn from_str(input: &str) -> Result<Self, Self::Err> {
+        let (keyword, rest) = match_name(input)
+            .ok_or_else(|| format_err!("expected a leading keyword in rule group"))?;
+
+        if !keyword.eq_ignore_ascii_case("group") {
+            bail!("Expected keyword GROUP")
+        }
+
+        let (name, rest) =
+            match_name(rest.trim()).ok_or_else(|| format_err!("expected a name for rule group"))?;
+
+        let options = rest.trim_start().parse()?;
+
+        Self::from_options(name.to_string(), options)
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use crate::firewall::types::{
+        address::{IpEntry, IpList},
+        alias::{AliasName, AliasScope},
+        ipset::{IpsetName, IpsetScope},
+        log::LogLevel,
+        rule_match::{Icmp, IcmpCode, IpAddrMatch, IpMatch, Ports, Protocol, Udp},
+        Cidr,
+    };
+
+    use super::*;
+
+    #[test]
+    fn test_parse_rule() {
+        let mut rule: Rule = "|GROUP tgr -i eth0 # acomm".parse().expect("valid rule");
+
+        assert_eq!(
+            rule,
+            Rule {
+                disabled: true,
+                comment: Some("acomm".to_string()),
+                kind: Kind::Group(RuleGroup {
+                    group: "tgr".to_string(),
+                    iface: Some("eth0".to_string()),
+                }),
+            },
+        );
+
+        rule = "IN ACCEPT -p udp -dport 33 -sport 22 -log warning"
+            .parse()
+            .expect("valid rule");
+
+        assert_eq!(
+            rule,
+            Rule {
+                disabled: false,
+                comment: None,
+                kind: Kind::Match(RuleMatch {
+                    dir: Direction::In,
+                    verdict: Verdict::Accept,
+                    proto: Some(Udp::new(Ports::from_u16(22, 33)).into()),
+                    log: Some(LogLevel::Warning),
+                    ..Default::default()
+                }),
+            }
+        );
+
+        rule = "IN ACCEPT --proto udp -i eth0".parse().expect("valid rule");
+
+        assert_eq!(
+            rule,
+            Rule {
+                disabled: false,
+                comment: None,
+                kind: Kind::Match(RuleMatch {
+                    dir: Direction::In,
+                    verdict: Verdict::Accept,
+                    proto: Some(Udp::new(Ports::new(None, None)).into()),
+                    iface: Some("eth0".to_string()),
+                    ..Default::default()
+                }),
+            }
+        );
+
+        rule = " OUT DROP \
+          -source 10.0.0.0/24 -dest 20.0.0.0-20.255.255.255,192.168.0.0/16 \
+          -p icmp -log nolog -icmp-type port-unreachable "
+            .parse()
+            .expect("valid rule");
+
+        assert_eq!(
+            rule,
+            Rule {
+                disabled: false,
+                comment: None,
+                kind: Kind::Match(RuleMatch {
+                    dir: Direction::Out,
+                    verdict: Verdict::Drop,
+                    ip: IpMatch::new(
+                        IpAddrMatch::Ip(IpList::from(Cidr::new_v4([10, 0, 0, 0], 24).unwrap())),
+                        IpAddrMatch::Ip(
+                            IpList::new(vec![
+                                IpEntry::Range([20, 0, 0, 0].into(), [20, 255, 255, 255].into()),
+                                IpEntry::Cidr(Cidr::new_v4([192, 168, 0, 0], 16).unwrap()),
+                            ])
+                            .unwrap()
+                        ),
+                    )
+                    .ok(),
+                    proto: Some(Protocol::Icmp(Icmp::new_code(IcmpCode::Named(
+                        "port-unreachable"
+                    )))),
+                    log: Some(LogLevel::Nolog),
+                    ..Default::default()
+                }),
+            }
+        );
+
+        rule = "IN BGP(ACCEPT) --log crit --iface eth0"
+            .parse()
+            .expect("valid rule");
+
+        assert_eq!(
+            rule,
+            Rule {
+                disabled: false,
+                comment: None,
+                kind: Kind::Match(RuleMatch {
+                    dir: Direction::In,
+                    verdict: Verdict::Accept,
+                    log: Some(LogLevel::Critical),
+                    fw_macro: Some("BGP".to_string()),
+                    iface: Some("eth0".to_string()),
+                    ..Default::default()
+                }),
+            }
+        );
+
+        rule = "IN ACCEPT --source dc/test --dest +dc/test"
+            .parse()
+            .expect("valid rule");
+
+        assert_eq!(
+            rule,
+            Rule {
+                disabled: false,
+                comment: None,
+                kind: Kind::Match(RuleMatch {
+                    dir: Direction::In,
+                    verdict: Verdict::Accept,
+                    ip: Some(
+                        IpMatch::new(
+                            IpAddrMatch::Alias(AliasName::new(AliasScope::Datacenter, "test")),
+                            IpAddrMatch::Set(IpsetName::new(IpsetScope::Datacenter, "test"),),
+                        )
+                        .unwrap()
+                    ),
+                    ..Default::default()
+                }),
+            }
+        );
+
+        rule = "IN REJECT".parse().expect("valid rule");
+
+        assert_eq!(
+            rule,
+            Rule {
+                disabled: false,
+                comment: None,
+                kind: Kind::Match(RuleMatch {
+                    dir: Direction::In,
+                    verdict: Verdict::Reject,
+                    ..Default::default()
+                }),
+            }
+        );
+
+        "IN DROP ---log crit"
+            .parse::<Rule>()
+            .expect_err("too many dashes in option");
+
+        "IN DROP --log --iface eth0"
+            .parse::<Rule>()
+            .expect_err("no value for option");
+
+        "IN DROP --log crit --iface"
+            .parse::<Rule>()
+            .expect_err("no value for option");
+    }
+}
diff --git a/proxmox-ve-config/src/firewall/types/rule_match.rs b/proxmox-ve-config/src/firewall/types/rule_match.rs
new file mode 100644
index 0000000..ae5345c
--- /dev/null
+++ b/proxmox-ve-config/src/firewall/types/rule_match.rs
@@ -0,0 +1,953 @@
+use std::collections::HashMap;
+use std::fmt;
+use std::str::FromStr;
+
+use serde::Deserialize;
+
+use anyhow::{bail, format_err, Error};
+use serde::de::IntoDeserializer;
+
+use crate::firewall::parse::{match_name, match_non_whitespace, SomeStr};
+use crate::firewall::types::address::{Family, IpList};
+use crate::firewall::types::alias::AliasName;
+use crate::firewall::types::ipset::IpsetName;
+use crate::firewall::types::log::LogLevel;
+use crate::firewall::types::port::PortList;
+use crate::firewall::types::rule::{Direction, Verdict};
+
+#[derive(Clone, Debug, Default, Deserialize)]
+#[cfg_attr(test, derive(Eq, PartialEq))]
+#[serde(deny_unknown_fields, rename_all = "kebab-case")]
+pub(crate) struct RuleOptions {
+    #[serde(alias = "p")]
+    pub(crate) proto: Option<String>,
+
+    pub(crate) dport: Option<String>,
+    pub(crate) sport: Option<String>,
+
+    pub(crate) dest: Option<String>,
+    pub(crate) source: Option<String>,
+
+    #[serde(alias = "i")]
+    pub(crate) iface: Option<String>,
+
+    pub(crate) log: Option<LogLevel>,
+    pub(crate) icmp_type: Option<String>,
+}
+
+impl FromStr for RuleOptions {
+    type Err = Error;
+
+    fn from_str(mut line: &str) -> Result<Self, Self::Err> {
+        let mut options = HashMap::new();
+
+        loop {
+            line = line.trim_start();
+
+            if line.is_empty() {
+                break;
+            }
+
+            line = line
+                .strip_prefix('-')
+                .ok_or_else(|| format_err!("expected an option starting with '-'"))?;
+
+            // second dash is optional
+            line = line.strip_prefix('-').unwrap_or(line);
+
+            let param;
+            (param, line) = match_name(line)
+                .ok_or_else(|| format_err!("expected a parameter name after '-'"))?;
+
+            let value;
+            (value, line) = match_non_whitespace(line.trim_start())
+                .ok_or_else(|| format_err!("expected a value for {param:?}"))?;
+
+            if options.insert(param, SomeStr(value)).is_some() {
+                bail!("duplicate option in rule: {param}")
+            }
+        }
+
+        Ok(RuleOptions::deserialize(IntoDeserializer::<
+            '_,
+            crate::firewall::parse::SerdeStringError,
+        >::into_deserializer(
+            options
+        ))?)
+    }
+}
+
+#[derive(Clone, Debug, Default)]
+#[cfg_attr(test, derive(Eq, PartialEq))]
+pub struct RuleMatch {
+    pub(crate) dir: Direction,
+    pub(crate) verdict: Verdict,
+    pub(crate) fw_macro: Option<String>,
+
+    pub(crate) iface: Option<String>,
+    pub(crate) log: Option<LogLevel>,
+    pub(crate) ip: Option<IpMatch>,
+    pub(crate) proto: Option<Protocol>,
+}
+
+impl RuleMatch {
+    pub(crate) fn from_options(
+        dir: Direction,
+        verdict: Verdict,
+        fw_macro: impl Into<Option<String>>,
+        options: RuleOptions,
+    ) -> Result<Self, Error> {
+        if options.dport.is_some() && options.icmp_type.is_some() {
+            bail!("dport and icmp-type are mutually exclusive");
+        }
+
+        let ip = IpMatch::from_options(&options)?;
+        let proto = Protocol::from_options(&options)?;
+
+        // todo: check protocol & IP Version compatibility
+
+        Ok(Self {
+            dir,
+            verdict,
+            fw_macro: fw_macro.into(),
+            iface: options.iface,
+            log: options.log,
+            ip,
+            proto,
+        })
+    }
+
+    pub fn direction(&self) -> Direction {
+        self.dir
+    }
+
+    pub fn iface(&self) -> Option<&str> {
+        self.iface.as_deref()
+    }
+
+    pub fn verdict(&self) -> Verdict {
+        self.verdict
+    }
+
+    pub fn fw_macro(&self) -> Option<&str> {
+        self.fw_macro.as_deref()
+    }
+
+    pub fn log(&self) -> Option<LogLevel> {
+        self.log
+    }
+
+    pub fn ip(&self) -> Option<&IpMatch> {
+        self.ip.as_ref()
+    }
+
+    pub fn proto(&self) -> Option<&Protocol> {
+        self.proto.as_ref()
+    }
+}
+
+/// Returns `(Macro name, Verdict, RestOfTheLine)`.
+fn parse_action(line: &str) -> Result<(Option<&str>, Verdict, &str), Error> {
+    let (verdict, line) =
+        match_name(line).ok_or_else(|| format_err!("expected a verdict or macro name"))?;
+
+    Ok(if let Some(line) = line.strip_prefix('(') {
+        // <macro>(<verdict>)
+
+        let macro_name = verdict;
+        let (verdict, line) = match_name(line).ok_or_else(|| format_err!("expected a verdict"))?;
+        let line = line
+            .strip_prefix(')')
+            .ok_or_else(|| format_err!("expected closing ')' after verdict"))?;
+
+        let verdict: Verdict = verdict.parse()?;
+
+        (Some(macro_name), verdict, line.trim_start())
+    } else {
+        (None, verdict.parse()?, line.trim_start())
+    })
+}
+
+impl FromStr for RuleMatch {
+    type Err = Error;
+
+    fn from_str(line: &str) -> Result<Self, Self::Err> {
+        let (dir, rest) = match_name(line).ok_or_else(|| format_err!("expected a direction"))?;
+
+        let direction: Direction = dir.parse()?;
+
+        let (fw_macro, verdict, rest) = parse_action(rest.trim_start())?;
+
+        let options: RuleOptions = rest.trim_start().parse()?;
+
+        Self::from_options(direction, verdict, fw_macro.map(str::to_string), options)
+    }
+}
+
+#[derive(Clone, Debug)]
+#[cfg_attr(test, derive(Eq, PartialEq))]
+pub struct IpMatch {
+    pub(crate) src: Option<IpAddrMatch>,
+    pub(crate) dst: Option<IpAddrMatch>,
+}
+
+impl IpMatch {
+    pub fn new(
+        src: impl Into<Option<IpAddrMatch>>,
+        dst: impl Into<Option<IpAddrMatch>>,
+    ) -> Result<Self, Error> {
+        let source = src.into();
+        let dest = dst.into();
+
+        if source.is_none() && dest.is_none() {
+            bail!("either src or dst must be set")
+        }
+
+        if let (Some(src), Some(dst)) = (&source, &dest) {
+            if src.family() != dst.family() {
+                bail!("src and dst family must be equal")
+            }
+        }
+
+        let ip_match = Self {
+            src: source,
+            dst: dest,
+        };
+
+        Ok(ip_match)
+    }
+
+    fn from_options(options: &RuleOptions) -> Result<Option<Self>, Error> {
+        let src = options
+            .source
+            .as_ref()
+            .map(|elem| elem.parse::<IpAddrMatch>())
+            .transpose()?;
+
+        let dst = options
+            .dest
+            .as_ref()
+            .map(|elem| elem.parse::<IpAddrMatch>())
+            .transpose()?;
+
+        Ok(IpMatch::new(src, dst).ok())
+    }
+
+    pub fn src(&self) -> Option<&IpAddrMatch> {
+        self.src.as_ref()
+    }
+
+    pub fn dst(&self) -> Option<&IpAddrMatch> {
+        self.dst.as_ref()
+    }
+}
+
+#[derive(Clone, Debug, Deserialize)]
+#[cfg_attr(test, derive(Eq, PartialEq))]
+pub enum IpAddrMatch {
+    Ip(IpList),
+    Set(IpsetName),
+    Alias(AliasName),
+}
+
+impl IpAddrMatch {
+    pub fn family(&self) -> Option<Family> {
+        if let IpAddrMatch::Ip(list) = self {
+            return Some(list.family());
+        }
+
+        None
+    }
+}
+
+impl FromStr for IpAddrMatch {
+    type Err = Error;
+
+    fn from_str(value: &str) -> Result<Self, Error> {
+        if value.is_empty() {
+            bail!("empty IP specification");
+        }
+
+        if let Ok(ip_list) = value.parse() {
+            return Ok(IpAddrMatch::Ip(ip_list));
+        }
+
+        if let Ok(ipset) = value.parse() {
+            return Ok(IpAddrMatch::Set(ipset));
+        }
+
+        if let Ok(name) = value.parse() {
+            return Ok(IpAddrMatch::Alias(name));
+        }
+
+        bail!("invalid IP specification: {value}")
+    }
+}
+
+#[derive(Clone, Debug)]
+#[cfg_attr(test, derive(Eq, PartialEq))]
+pub enum Protocol {
+    Dccp(Ports),
+    Sctp(Sctp),
+    Tcp(Tcp),
+    Udp(Udp),
+    UdpLite(Ports),
+    Icmp(Icmp),
+    Icmpv6(Icmpv6),
+    Named(String),
+    Numeric(u8),
+}
+
+impl Protocol {
+    pub(crate) fn from_options(options: &RuleOptions) -> Result<Option<Self>, Error> {
+        let proto = match options.proto.as_deref() {
+            Some(p) => p,
+            None => return Ok(None),
+        };
+
+        Ok(Some(match proto {
+            "dccp" | "33" => Protocol::Dccp(Ports::from_options(options)?),
+            "sctp" | "132" => Protocol::Sctp(Sctp::from_options(options)?),
+            "tcp" | "6" => Protocol::Tcp(Tcp::from_options(options)?),
+            "udp" | "17" => Protocol::Udp(Udp::from_options(options)?),
+            "udplite" | "136" => Protocol::UdpLite(Ports::from_options(options)?),
+            "icmp" | "1" => Protocol::Icmp(Icmp::from_options(options)?),
+            "ipv6-icmp" | "icmpv6" | "58" => Protocol::Icmpv6(Icmpv6::from_options(options)?),
+            other => match other.parse::<u8>() {
+                Ok(num) => Protocol::Numeric(num),
+                Err(_) => Protocol::Named(other.to_string()),
+            },
+        }))
+    }
+
+    pub fn family(&self) -> Option<Family> {
+        match self {
+            Self::Icmp(_) => Some(Family::V4),
+            Self::Icmpv6(_) => Some(Family::V6),
+            _ => None,
+        }
+    }
+}
+
+#[derive(Clone, Debug, Default)]
+#[cfg_attr(test, derive(Eq, PartialEq))]
+pub struct Udp {
+    ports: Ports,
+}
+
+impl Udp {
+    fn from_options(options: &RuleOptions) -> Result<Self, Error> {
+        Ok(Self {
+            ports: Ports::from_options(options)?,
+        })
+    }
+
+    pub fn new(ports: Ports) -> Self {
+        Self { ports }
+    }
+
+    pub fn ports(&self) -> &Ports {
+        &self.ports
+    }
+}
+
+impl From<Udp> for Protocol {
+    fn from(value: Udp) -> Self {
+        Protocol::Udp(value)
+    }
+}
+
+#[derive(Clone, Debug, Default)]
+#[cfg_attr(test, derive(Eq, PartialEq))]
+pub struct Ports {
+    sport: Option<PortList>,
+    dport: Option<PortList>,
+}
+
+impl Ports {
+    pub fn new(sport: impl Into<Option<PortList>>, dport: impl Into<Option<PortList>>) -> Self {
+        Self {
+            sport: sport.into(),
+            dport: dport.into(),
+        }
+    }
+
+    fn from_options(options: &RuleOptions) -> Result<Self, Error> {
+        Ok(Self {
+            sport: options.sport.as_deref().map(|s| s.parse()).transpose()?,
+            dport: options.dport.as_deref().map(|s| s.parse()).transpose()?,
+        })
+    }
+
+    pub fn from_u16(sport: impl Into<Option<u16>>, dport: impl Into<Option<u16>>) -> Self {
+        Self::new(
+            sport.into().map(PortList::from),
+            dport.into().map(PortList::from),
+        )
+    }
+
+    pub fn sport(&self) -> Option<&PortList> {
+        self.sport.as_ref()
+    }
+
+    pub fn dport(&self) -> Option<&PortList> {
+        self.dport.as_ref()
+    }
+}
+
+#[derive(Clone, Debug, Default)]
+#[cfg_attr(test, derive(Eq, PartialEq))]
+pub struct Tcp {
+    ports: Ports,
+}
+
+impl Tcp {
+    pub fn new(ports: Ports) -> Self {
+        Self { ports }
+    }
+
+    fn from_options(options: &RuleOptions) -> Result<Self, Error> {
+        Ok(Self {
+            ports: Ports::from_options(options)?,
+        })
+    }
+
+    pub fn ports(&self) -> &Ports {
+        &self.ports
+    }
+}
+
+impl From<Tcp> for Protocol {
+    fn from(value: Tcp) -> Self {
+        Protocol::Tcp(value)
+    }
+}
+
+#[derive(Clone, Debug, Default)]
+#[cfg_attr(test, derive(Eq, PartialEq))]
+pub struct Sctp {
+    ports: Ports,
+}
+
+impl Sctp {
+    fn from_options(options: &RuleOptions) -> Result<Self, Error> {
+        Ok(Self {
+            ports: Ports::from_options(options)?,
+        })
+    }
+
+    pub fn ports(&self) -> &Ports {
+        &self.ports
+    }
+}
+
+#[derive(Clone, Debug, Default)]
+#[cfg_attr(test, derive(Eq, PartialEq))]
+pub struct Icmp {
+    ty: Option<IcmpType>,
+    code: Option<IcmpCode>,
+}
+
+impl Icmp {
+    pub fn new_ty(ty: IcmpType) -> Self {
+        Self {
+            ty: Some(ty),
+            ..Default::default()
+        }
+    }
+
+    pub fn new_code(code: IcmpCode) -> Self {
+        Self {
+            code: Some(code),
+            ..Default::default()
+        }
+    }
+
+    fn from_options(options: &RuleOptions) -> Result<Self, Error> {
+        if let Some(ty) = &options.icmp_type {
+            return ty.parse();
+        }
+
+        Ok(Self::default())
+    }
+
+    pub fn ty(&self) -> Option<&IcmpType> {
+        self.ty.as_ref()
+    }
+
+    pub fn code(&self) -> Option<&IcmpCode> {
+        self.code.as_ref()
+    }
+}
+
+impl FromStr for Icmp {
+    type Err = Error;
+
+    fn from_str(s: &str) -> Result<Self, Self::Err> {
+        let mut this = Self::default();
+
+        if let Ok(ty) = s.parse() {
+            this.ty = Some(ty);
+            return Ok(this);
+        }
+
+        if let Ok(code) = s.parse() {
+            this.code = Some(code);
+            return Ok(this);
+        }
+
+        bail!("supplied string is neither a valid icmp type nor code");
+    }
+}
+
+#[derive(Clone, Debug)]
+#[cfg_attr(test, derive(Eq, PartialEq))]
+pub enum IcmpType {
+    Numeric(u8),
+    Named(&'static str),
+}
+
+// MUST BE SORTED!
+const ICMP_TYPES: &[(&str, u8)] = &[
+    ("address-mask-reply", 18),
+    ("address-mask-request", 17),
+    ("destination-unreachable", 3),
+    ("echo-reply", 0),
+    ("echo-request", 8),
+    ("info-reply", 16),
+    ("info-request", 15),
+    ("parameter-problem", 12),
+    ("redirect", 5),
+    ("router-advertisement", 9),
+    ("router-solicitation", 10),
+    ("source-quench", 4),
+    ("time-exceeded", 11),
+    ("timestamp-reply", 14),
+    ("timestamp-request", 13),
+];
+
+impl std::str::FromStr for IcmpType {
+    type Err = Error;
+
+    fn from_str(s: &str) -> Result<Self, Error> {
+        if let Ok(ty) = s.trim().parse::<u8>() {
+            return Ok(Self::Numeric(ty));
+        }
+
+        if let Ok(index) = ICMP_TYPES.binary_search_by(|v| v.0.cmp(s)) {
+            return Ok(Self::Named(ICMP_TYPES[index].0));
+        }
+
+        bail!("{s:?} is not a valid icmp type");
+    }
+}
+
+impl fmt::Display for IcmpType {
+    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
+        match self {
+            IcmpType::Numeric(ty) => write!(f, "{ty}"),
+            IcmpType::Named(ty) => write!(f, "{ty}"),
+        }
+    }
+}
+
+#[derive(Clone, Debug)]
+#[cfg_attr(test, derive(Eq, PartialEq))]
+pub enum IcmpCode {
+    Numeric(u8),
+    Named(&'static str),
+}
+
+// MUST BE SORTED!
+const ICMP_CODES: &[(&str, u8)] = &[
+    ("admin-prohibited", 13),
+    ("host-prohibited", 10),
+    ("host-unreachable", 1),
+    ("net-prohibited", 9),
+    ("net-unreachable", 0),
+    ("port-unreachable", 3),
+    ("prot-unreachable", 2),
+];
+
+impl std::str::FromStr for IcmpCode {
+    type Err = Error;
+
+    fn from_str(s: &str) -> Result<Self, Error> {
+        if let Ok(code) = s.trim().parse::<u8>() {
+            return Ok(Self::Numeric(code));
+        }
+
+        if let Ok(index) = ICMP_CODES.binary_search_by(|v| v.0.cmp(s)) {
+            return Ok(Self::Named(ICMP_CODES[index].0));
+        }
+
+        bail!("{s:?} is not a valid icmp code");
+    }
+}
+
+impl fmt::Display for IcmpCode {
+    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
+        match self {
+            IcmpCode::Numeric(code) => write!(f, "{code}"),
+            IcmpCode::Named(code) => write!(f, "{code}"),
+        }
+    }
+}
+
+#[derive(Clone, Debug, Default)]
+#[cfg_attr(test, derive(Eq, PartialEq))]
+pub struct Icmpv6 {
+    pub ty: Option<Icmpv6Type>,
+    pub code: Option<Icmpv6Code>,
+}
+
+impl Icmpv6 {
+    pub fn new_ty(ty: Icmpv6Type) -> Self {
+        Self {
+            ty: Some(ty),
+            ..Default::default()
+        }
+    }
+
+    pub fn new_code(code: Icmpv6Code) -> Self {
+        Self {
+            code: Some(code),
+            ..Default::default()
+        }
+    }
+
+    fn from_options(options: &RuleOptions) -> Result<Self, Error> {
+        if let Some(ty) = &options.icmp_type {
+            return ty.parse();
+        }
+
+        Ok(Self::default())
+    }
+
+    pub fn ty(&self) -> Option<&Icmpv6Type> {
+        self.ty.as_ref()
+    }
+
+    pub fn code(&self) -> Option<&Icmpv6Code> {
+        self.code.as_ref()
+    }
+}
+
+impl FromStr for Icmpv6 {
+    type Err = Error;
+
+    fn from_str(s: &str) -> Result<Self, Self::Err> {
+        let mut this = Self::default();
+
+        if let Ok(ty) = s.parse() {
+            this.ty = Some(ty);
+            return Ok(this);
+        }
+
+        if let Ok(code) = s.parse() {
+            this.code = Some(code);
+            return Ok(this);
+        }
+
+        bail!("supplied string is neither a valid icmpv6 type nor code");
+    }
+}
+
+#[derive(Clone, Debug)]
+#[cfg_attr(test, derive(Eq, PartialEq))]
+pub enum Icmpv6Type {
+    Numeric(u8),
+    Named(&'static str),
+}
+
+// MUST BE SORTED!
+const ICMPV6_TYPES: &[(&str, u8)] = &[
+    ("destination-unreachable", 1),
+    ("echo-reply", 129),
+    ("echo-request", 128),
+    ("ind-neighbor-advert", 142),
+    ("ind-neighbor-solicit", 141),
+    ("mld-listener-done", 132),
+    ("mld-listener-query", 130),
+    ("mld-listener-reduction", 132),
+    ("mld-listener-report", 131),
+    ("mld2-listener-report", 143),
+    ("nd-neighbor-advert", 136),
+    ("nd-neighbor-solicit", 135),
+    ("nd-redirect", 137),
+    ("nd-router-advert", 134),
+    ("nd-router-solicit", 133),
+    ("packet-too-big", 2),
+    ("parameter-problem", 4),
+    ("router-renumbering", 138),
+    ("time-exceeded", 3),
+];
+
+impl std::str::FromStr for Icmpv6Type {
+    type Err = Error;
+
+    fn from_str(s: &str) -> Result<Self, Error> {
+        if let Ok(ty) = s.trim().parse::<u8>() {
+            return Ok(Self::Numeric(ty));
+        }
+
+        if let Ok(index) = ICMPV6_TYPES.binary_search_by(|v| v.0.cmp(s)) {
+            return Ok(Self::Named(ICMPV6_TYPES[index].0));
+        }
+
+        bail!("{s:?} is not a valid icmpv6 type");
+    }
+}
+
+impl fmt::Display for Icmpv6Type {
+    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
+        match self {
+            Icmpv6Type::Numeric(ty) => write!(f, "{ty}"),
+            Icmpv6Type::Named(ty) => write!(f, "{ty}"),
+        }
+    }
+}
+
+#[derive(Clone, Debug)]
+#[cfg_attr(test, derive(Eq, PartialEq))]
+pub enum Icmpv6Code {
+    Numeric(u8),
+    Named(&'static str),
+}
+
+// MUST BE SORTED!
+const ICMPV6_CODES: &[(&str, u8)] = &[
+    ("addr-unreachable", 3),
+    ("admin-prohibited", 1),
+    ("no-route", 0),
+    ("policy-fail", 5),
+    ("port-unreachable", 4),
+    ("reject-route", 6),
+];
+
+impl std::str::FromStr for Icmpv6Code {
+    type Err = Error;
+
+    fn from_str(s: &str) -> Result<Self, Error> {
+        if let Ok(code) = s.trim().parse::<u8>() {
+            return Ok(Self::Numeric(code));
+        }
+
+        if let Ok(index) = ICMPV6_CODES.binary_search_by(|v| v.0.cmp(s)) {
+            return Ok(Self::Named(ICMPV6_CODES[index].0));
+        }
+
+        bail!("{s:?} is not a valid icmpv6 code");
+    }
+}
+
+impl fmt::Display for Icmpv6Code {
+    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
+        match self {
+            Icmpv6Code::Numeric(code) => write!(f, "{code}"),
+            Icmpv6Code::Named(code) => write!(f, "{code}"),
+        }
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use crate::firewall::types::Cidr;
+
+    use super::*;
+
+    #[test]
+    fn test_parse_action() {
+        assert_eq!(parse_action("REJECT").unwrap(), (None, Verdict::Reject, ""));
+
+        assert_eq!(
+            parse_action("SSH(ACCEPT) qweasd").unwrap(),
+            (Some("SSH"), Verdict::Accept, "qweasd")
+        );
+    }
+
+    #[test]
+    fn test_parse_ip_addr_match() {
+        for input in [
+            "10.0.0.0/8",
+            "10.0.0.0/8,192.168.0.0-192.168.255.255,172.16.0.1",
+            "dc/test",
+            "+guest/proxmox",
+        ] {
+            input.parse::<IpAddrMatch>().expect("valid ip match");
+        }
+
+        for input in [
+            "10.0.0.0/",
+            "10.0.0.0/8,192.168.256.0-192.168.255.255,172.16.0.1",
+            "dcc/test",
+            "+guest/",
+            "",
+        ] {
+            input.parse::<IpAddrMatch>().expect_err("invalid ip match");
+        }
+    }
+
+    #[test]
+    fn test_parse_options() {
+        let mut options: RuleOptions =
+            "-p udp --sport 123 --dport 234 -source 127.0.0.1 --dest 127.0.0.1 -i ens1 --log crit"
+                .parse()
+                .expect("valid option string");
+
+        assert_eq!(
+            options,
+            RuleOptions {
+                proto: Some("udp".to_string()),
+                sport: Some("123".to_string()),
+                dport: Some("234".to_string()),
+                source: Some("127.0.0.1".to_string()),
+                dest: Some("127.0.0.1".to_string()),
+                iface: Some("ens1".to_string()),
+                log: Some(LogLevel::Critical),
+                icmp_type: None,
+            }
+        );
+
+        options = "".parse().expect("valid option string");
+
+        assert_eq!(options, RuleOptions::default(),);
+    }
+
+    #[test]
+    fn test_construct_ip_match() {
+        IpMatch::new(
+            IpAddrMatch::Ip(IpList::from(Cidr::new_v4([10, 0, 0, 0], 8).unwrap())),
+            IpAddrMatch::Ip(IpList::from(Cidr::new_v4([10, 0, 0, 0], 8).unwrap())),
+        )
+        .expect("valid ip match");
+
+        IpMatch::new(
+            IpAddrMatch::Ip(IpList::from(Cidr::new_v4([10, 0, 0, 0], 8).unwrap())),
+            IpAddrMatch::Ip(IpList::from(Cidr::new_v6([0x0000; 8], 8).unwrap())),
+        )
+        .expect_err("cannot mix ip families");
+
+        IpMatch::new(None, None).expect_err("at least one ip must be set");
+    }
+
+    #[test]
+    fn test_from_options() {
+        let mut options = RuleOptions {
+            proto: Some("tcp".to_string()),
+            sport: Some("123".to_string()),
+            dport: Some("234".to_string()),
+            source: Some("192.168.0.1".to_string()),
+            dest: Some("10.0.0.1".to_string()),
+            iface: Some("eth123".to_string()),
+            log: Some(LogLevel::Error),
+            ..Default::default()
+        };
+
+        assert_eq!(
+            Protocol::from_options(&options).unwrap().unwrap(),
+            Protocol::Tcp(Tcp::new(Ports::from_u16(123, 234))),
+        );
+
+        assert_eq!(
+            IpMatch::from_options(&options).unwrap().unwrap(),
+            IpMatch::new(
+                IpAddrMatch::Ip(IpList::from(Cidr::new_v4([192, 168, 0, 1], 32).unwrap()),),
+                IpAddrMatch::Ip(IpList::from(Cidr::new_v4([10, 0, 0, 1], 32).unwrap()),)
+            )
+            .unwrap(),
+        );
+
+        options = RuleOptions::default();
+
+        assert_eq!(Protocol::from_options(&options).unwrap(), None,);
+
+        assert_eq!(IpMatch::from_options(&options).unwrap(), None,);
+
+        options = RuleOptions {
+            proto: Some("tcp".to_string()),
+            sport: Some("qwe".to_string()),
+            source: Some("qwe".to_string()),
+            ..Default::default()
+        };
+
+        Protocol::from_options(&options).expect_err("invalid source port");
+
+        IpMatch::from_options(&options).expect_err("invalid source address");
+
+        options = RuleOptions {
+            icmp_type: Some("port-unreachable".to_string()),
+            dport: Some("123".to_string()),
+            ..Default::default()
+        };
+
+        RuleMatch::from_options(Direction::In, Verdict::Drop, None, options)
+            .expect_err("cannot mix dport and icmp-type");
+    }
+
+    #[test]
+    fn test_parse_icmp() {
+        let mut icmp: Icmp = "info-request".parse().expect("valid icmp type");
+
+        assert_eq!(
+            icmp,
+            Icmp {
+                ty: Some(IcmpType::Named("info-request")),
+                code: None
+            }
+        );
+
+        icmp = "12".parse().expect("valid icmp type");
+
+        assert_eq!(
+            icmp,
+            Icmp {
+                ty: Some(IcmpType::Numeric(12)),
+                code: None
+            }
+        );
+
+        icmp = "port-unreachable".parse().expect("valid icmp code");
+
+        assert_eq!(
+            icmp,
+            Icmp {
+                ty: None,
+                code: Some(IcmpCode::Named("port-unreachable"))
+            }
+        );
+    }
+
+    #[test]
+    fn test_parse_icmp6() {
+        let mut icmp: Icmpv6 = "echo-reply".parse().expect("valid icmpv6 type");
+
+        assert_eq!(
+            icmp,
+            Icmpv6 {
+                ty: Some(Icmpv6Type::Named("echo-reply")),
+                code: None
+            }
+        );
+
+        icmp = "12".parse().expect("valid icmpv6 type");
+
+        assert_eq!(
+            icmp,
+            Icmpv6 {
+                ty: Some(Icmpv6Type::Numeric(12)),
+                code: None
+            }
+        );
+
+        icmp = "admin-prohibited".parse().expect("valid icmpv6 code");
+
+        assert_eq!(
+            icmp,
+            Icmpv6 {
+                ty: None,
+                code: Some(Icmpv6Code::Named("admin-prohibited"))
+            }
+        );
+    }
+}
-- 
2.39.2




^ permalink raw reply	[flat|nested] 67+ messages in thread

* [pve-devel] [PATCH proxmox-firewall 10/37] config: firewall: add types for security groups
  2024-04-02 17:15 [pve-devel] [RFC container/firewall/manager/proxmox-firewall/qemu-server 00/37] proxmox firewall nftables implementation Stefan Hanreich
                   ` (8 preceding siblings ...)
  2024-04-02 17:16 ` [pve-devel] [PATCH proxmox-firewall 09/37] config: firewall: add types for rules Stefan Hanreich
@ 2024-04-02 17:16 ` Stefan Hanreich
  2024-04-02 17:16 ` [pve-devel] [PATCH proxmox-firewall 11/37] config: firewall: add generic parser for firewall configs Stefan Hanreich
                   ` (31 subsequent siblings)
  41 siblings, 0 replies; 67+ messages in thread
From: Stefan Hanreich @ 2024-04-02 17:16 UTC (permalink / raw)
  To: pve-devel; +Cc: Stefan Hanreich, Wolfgang Bumiller

Co-authored-by: Wolfgang Bumiller <w.bumiller@proxmox.com>
Signed-off-by: Stefan Hanreich <s.hanreich@proxmox.com>
---
 proxmox-ve-config/src/firewall/types/group.rs | 36 +++++++++++++++++++
 proxmox-ve-config/src/firewall/types/mod.rs   |  2 ++
 2 files changed, 38 insertions(+)
 create mode 100644 proxmox-ve-config/src/firewall/types/group.rs

diff --git a/proxmox-ve-config/src/firewall/types/group.rs b/proxmox-ve-config/src/firewall/types/group.rs
new file mode 100644
index 0000000..7455268
--- /dev/null
+++ b/proxmox-ve-config/src/firewall/types/group.rs
@@ -0,0 +1,36 @@
+use anyhow::Error;
+
+use crate::firewall::types::Rule;
+
+#[derive(Debug)]
+#[cfg_attr(test, derive(Eq, PartialEq))]
+pub struct Group {
+    rules: Vec<Rule>,
+    comment: Option<String>,
+}
+
+impl Group {
+    pub const fn new() -> Self {
+        Self {
+            rules: Vec::new(),
+            comment: None,
+        }
+    }
+
+    pub fn rules(&self) -> &Vec<Rule> {
+        &self.rules
+    }
+
+    pub fn comment(&self) -> Option<&str> {
+        self.comment.as_deref()
+    }
+
+    pub fn set_comment(&mut self, comment: Option<String>) {
+        self.comment = comment;
+    }
+
+    pub(crate) fn parse_entry(&mut self, line: &str) -> Result<(), Error> {
+        self.rules.push(line.parse()?);
+        Ok(())
+    }
+}
diff --git a/proxmox-ve-config/src/firewall/types/mod.rs b/proxmox-ve-config/src/firewall/types/mod.rs
index b4a6b12..8fd551e 100644
--- a/proxmox-ve-config/src/firewall/types/mod.rs
+++ b/proxmox-ve-config/src/firewall/types/mod.rs
@@ -1,5 +1,6 @@
 pub mod address;
 pub mod alias;
+pub mod group;
 pub mod ipset;
 pub mod log;
 pub mod port;
@@ -8,5 +9,6 @@ pub mod rule_match;
 
 pub use address::Cidr;
 pub use alias::Alias;
+pub use group::Group;
 pub use ipset::Ipset;
 pub use rule::Rule;
-- 
2.39.2




^ permalink raw reply	[flat|nested] 67+ messages in thread

* [pve-devel] [PATCH proxmox-firewall 11/37] config: firewall: add generic parser for firewall configs
  2024-04-02 17:15 [pve-devel] [RFC container/firewall/manager/proxmox-firewall/qemu-server 00/37] proxmox firewall nftables implementation Stefan Hanreich
                   ` (9 preceding siblings ...)
  2024-04-02 17:16 ` [pve-devel] [PATCH proxmox-firewall 10/37] config: firewall: add types for security groups Stefan Hanreich
@ 2024-04-02 17:16 ` Stefan Hanreich
  2024-04-03 10:47   ` Max Carrara
  2024-04-02 17:16 ` [pve-devel] [PATCH proxmox-firewall 12/37] config: firewall: add cluster-specific config + option types Stefan Hanreich
                   ` (30 subsequent siblings)
  41 siblings, 1 reply; 67+ messages in thread
From: Stefan Hanreich @ 2024-04-02 17:16 UTC (permalink / raw)
  To: pve-devel; +Cc: Stefan Hanreich, Wolfgang Bumiller

Since the basic format of cluster, host and guest firewall
configurations is the same, we create a generic parser that can handle
the common config format. The main difference is in the available
options, which can be passed via a generic parameter.

Co-authored-by: Wolfgang Bumiller <w.bumiller@proxmox.com>
Signed-off-by: Stefan Hanreich <s.hanreich@proxmox.com>
---
 proxmox-ve-config/src/firewall/common.rs | 182 +++++++++++++++++++++
 proxmox-ve-config/src/firewall/mod.rs    |   1 +
 proxmox-ve-config/src/firewall/parse.rs  | 200 +++++++++++++++++++++++
 3 files changed, 383 insertions(+)
 create mode 100644 proxmox-ve-config/src/firewall/common.rs

diff --git a/proxmox-ve-config/src/firewall/common.rs b/proxmox-ve-config/src/firewall/common.rs
new file mode 100644
index 0000000..887339b
--- /dev/null
+++ b/proxmox-ve-config/src/firewall/common.rs
@@ -0,0 +1,182 @@
+use std::collections::HashMap;
+use std::io;
+
+use anyhow::{bail, format_err, Error};
+use serde::de::IntoDeserializer;
+
+use crate::firewall::parse::{parse_named_section_tail, split_key_value, SomeString};
+use crate::firewall::types::ipset::{IpsetName, IpsetScope};
+use crate::firewall::types::{Alias, Group, Ipset, Rule};
+
+#[derive(Debug, Default)]
+pub struct Config<O>
+where
+    O: Default + std::fmt::Debug + serde::de::DeserializeOwned,
+{
+    pub(crate) options: O,
+    pub(crate) rules: Vec<Rule>,
+    pub(crate) aliases: HashMap<String, Alias>,
+    pub(crate) ipsets: HashMap<String, Ipset>,
+    pub(crate) groups: HashMap<String, Group>,
+}
+
+enum Sec {
+    None,
+    Options,
+    Aliases,
+    Rules,
+    Ipset(String, Ipset),
+    Group(String, Group),
+}
+
+#[derive(Default)]
+pub struct ParserConfig {
+    /// Network interfaces must be of the form `netX`.
+    pub guest_iface_names: bool,
+    pub ipset_scope: Option<IpsetScope>,
+}
+
+impl<O> Config<O>
+where
+    O: Default + std::fmt::Debug + serde::de::DeserializeOwned,
+{
+    pub fn new() -> Self {
+        Self::default()
+    }
+
+    pub fn parse<R: io::BufRead>(input: R, parser_cfg: &ParserConfig) -> Result<Self, Error> {
+        let mut section = Sec::None;
+
+        let mut this = Self::new();
+        let mut options = HashMap::new();
+
+        for line in input.lines() {
+            let line = line?;
+            let line = line.trim();
+
+            if line.is_empty() || line.starts_with('#') {
+                continue;
+            }
+
+            if line.eq_ignore_ascii_case("[OPTIONS]") {
+                this.set_section(&mut section, Sec::Options)?;
+            } else if line.eq_ignore_ascii_case("[ALIASES]") {
+                this.set_section(&mut section, Sec::Aliases)?;
+            } else if line.eq_ignore_ascii_case("[RULES]") {
+                this.set_section(&mut section, Sec::Rules)?;
+            } else if let Some(line) = line.strip_prefix("[IPSET") {
+                let (name, comment) = parse_named_section_tail("ipset", line)?;
+
+                let scope = parser_cfg.ipset_scope.ok_or_else(|| {
+                    format_err!("IPSET in config, but no scope set in parser config")
+                })?;
+
+                let ipset_name = IpsetName::new(scope, name.to_string());
+                let mut ipset = Ipset::new(ipset_name);
+                ipset.comment = comment.map(str::to_owned);
+
+                this.set_section(&mut section, Sec::Ipset(name.to_string(), ipset))?;
+            } else if let Some(line) = line.strip_prefix("[group") {
+                let (name, comment) = parse_named_section_tail("group", line)?;
+                let mut group = Group::new();
+
+                group.set_comment(comment.map(str::to_owned));
+
+                this.set_section(&mut section, Sec::Group(name.to_owned(), group))?;
+            } else if line.starts_with('[') {
+                bail!("invalid section {line:?}");
+            } else {
+                match &mut section {
+                    Sec::None => bail!("config line with no section: {line:?}"),
+                    Sec::Options => Self::parse_option(line, &mut options)?,
+                    Sec::Aliases => this.parse_alias(line)?,
+                    Sec::Rules => this.parse_rule(line, parser_cfg)?,
+                    Sec::Ipset(_name, ipset) => ipset.parse_entry(line)?,
+                    Sec::Group(_name, group) => group.parse_entry(line)?,
+                }
+            }
+        }
+        this.set_section(&mut section, Sec::None)?;
+
+        this.options = O::deserialize(IntoDeserializer::<
+            '_,
+            crate::firewall::parse::SerdeStringError,
+        >::into_deserializer(options))?;
+
+        Ok(this)
+    }
+
+    fn parse_option(line: &str, options: &mut HashMap<String, SomeString>) -> Result<(), Error> {
+        let (key, value) = split_key_value(line)
+            .ok_or_else(|| format_err!("expected colon separated key and value, found {line:?}"))?;
+
+        if options.insert(key.to_string(), value.into()).is_some() {
+            bail!("duplicate option {key:?}");
+        }
+
+        Ok(())
+    }
+
+    fn parse_alias(&mut self, line: &str) -> Result<(), Error> {
+        let alias: Alias = line.parse()?;
+
+        if self
+            .aliases
+            .insert(alias.name().to_string(), alias)
+            .is_some()
+        {
+            bail!("duplicate alias: {line}");
+        }
+
+        Ok(())
+    }
+
+    fn parse_rule(&mut self, line: &str, parser_cfg: &ParserConfig) -> Result<(), Error> {
+        let rule: Rule = line.parse()?;
+
+        if parser_cfg.guest_iface_names {
+            if let Some(iface) = rule.iface() {
+                let _ = iface
+                    .strip_prefix("net")
+                    .ok_or_else(|| {
+                        format_err!("interface name must be of the form \"net<number>\"")
+                    })?
+                    .parse::<u16>()
+                    .map_err(|_| {
+                        format_err!("interface name must be of the form \"net<number>\"")
+                    })?;
+            }
+        }
+
+        self.rules.push(rule);
+        Ok(())
+    }
+
+    fn set_section(&mut self, sec: &mut Sec, to: Sec) -> Result<(), Error> {
+        let prev = std::mem::replace(sec, to);
+
+        match prev {
+            Sec::Ipset(name, ipset) => {
+                if self.ipsets.insert(name.clone(), ipset).is_some() {
+                    bail!("duplicate ipset: {name:?}");
+                }
+            }
+            Sec::Group(name, group) => {
+                if self.groups.insert(name.clone(), group).is_some() {
+                    bail!("duplicate group: {name:?}");
+                }
+            }
+            _ => (),
+        }
+
+        Ok(())
+    }
+
+    pub fn ipsets(&self) -> &HashMap<String, Ipset> {
+        &self.ipsets
+    }
+
+    pub fn alias(&self, name: &str) -> Option<&Alias> {
+        self.aliases.get(name)
+    }
+}
diff --git a/proxmox-ve-config/src/firewall/mod.rs b/proxmox-ve-config/src/firewall/mod.rs
index 2e0f31e..591ee52 100644
--- a/proxmox-ve-config/src/firewall/mod.rs
+++ b/proxmox-ve-config/src/firewall/mod.rs
@@ -1,3 +1,4 @@
+pub mod common;
 pub mod ports;
 pub mod types;
 
diff --git a/proxmox-ve-config/src/firewall/parse.rs b/proxmox-ve-config/src/firewall/parse.rs
index 227e045..9cc2b8a 100644
--- a/proxmox-ve-config/src/firewall/parse.rs
+++ b/proxmox-ve-config/src/firewall/parse.rs
@@ -61,6 +61,16 @@ pub fn match_digits(line: &str) -> Option<(&str, &str)> {
 
     None
 }
+
+/// Separate a `key: value` line, trimming whitespace.
+///
+/// Returns `None` if the `key` would be empty.
+pub fn split_key_value(line: &str) -> Option<(&str, &str)> {
+    line.split_once(':')
+        .map(|(key, value)| (key.trim(), value.trim()))
+}
+
+/// Parse a boolean.
 pub fn parse_bool(value: &str) -> Result<bool, Error> {
     Ok(
         if value == "0"
@@ -81,6 +91,196 @@ pub fn parse_bool(value: &str) -> Result<bool, Error> {
     )
 }
 
+/// Parse the *remainder* of a section line, that is `<whitespace>NAME] #optional comment`.
+/// The `kind` parameter is used for error messages and should be the section type.
+///
+/// Return the name and the optional comment.
+pub fn parse_named_section_tail<'a>(
+    kind: &'static str,
+    line: &'a str,
+) -> Result<(&'a str, Option<&'a str>), Error> {
+    if line.is_empty() || !line.as_bytes()[0].is_ascii_whitespace() {
+        bail!("incomplete {kind} section");
+    }
+
+    let line = line.trim_start();
+    let (name, line) = match_name(line)
+        .ok_or_else(|| format_err!("expected a name for the {kind} at {line:?}"))?;
+
+    let line = line
+        .strip_prefix(']')
+        .ok_or_else(|| format_err!("expected closing ']' in {kind} section header"))?
+        .trim_start();
+
+    Ok(match line.strip_prefix('#') {
+        Some(comment) => (name, Some(comment.trim())),
+        None if !line.is_empty() => bail!("trailing characters after {kind} section: {line:?}"),
+        None => (name, None),
+    })
+}
+
+// parses a number from a string OR number
+pub mod serde_option_number {
+    use std::fmt;
+
+    use serde::de::{Deserializer, Error, Visitor};
+
+    pub fn deserialize<'de, D: Deserializer<'de>>(
+        deserializer: D,
+    ) -> Result<Option<i64>, D::Error> {
+        struct V;
+
+        impl<'de> Visitor<'de> for V {
+            type Value = Option<i64>;
+
+            fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result {
+                f.write_str("a numerical value")
+            }
+
+            fn visit_str<E: Error>(self, v: &str) -> Result<Self::Value, E> {
+                v.parse().map_err(E::custom).map(Some)
+            }
+
+            fn visit_none<E: Error>(self) -> Result<Self::Value, E> {
+                Ok(None)
+            }
+
+            fn visit_some<D>(self, deserializer: D) -> Result<Self::Value, D::Error>
+            where
+                D: Deserializer<'de>,
+            {
+                deserializer.deserialize_any(self)
+            }
+        }
+
+        deserializer.deserialize_any(V)
+    }
+}
+
+// parses a bool from a string OR bool
+pub mod serde_option_bool {
+    use std::fmt;
+
+    use serde::de::{Deserializer, Error, Visitor};
+
+    pub fn deserialize<'de, D: Deserializer<'de>>(
+        deserializer: D,
+    ) -> Result<Option<bool>, D::Error> {
+        struct V;
+
+        impl<'de> Visitor<'de> for V {
+            type Value = Option<bool>;
+
+            fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result {
+                f.write_str("a boolean-like value")
+            }
+
+            fn visit_bool<E: Error>(self, v: bool) -> Result<Self::Value, E> {
+                Ok(Some(v))
+            }
+
+            fn visit_str<E: Error>(self, v: &str) -> Result<Self::Value, E> {
+                super::parse_bool(v).map_err(E::custom).map(Some)
+            }
+
+            fn visit_none<E: Error>(self) -> Result<Self::Value, E> {
+                Ok(None)
+            }
+
+            fn visit_some<D>(self, deserializer: D) -> Result<Self::Value, D::Error>
+            where
+                D: Deserializer<'de>,
+            {
+                deserializer.deserialize_any(self)
+            }
+        }
+
+        deserializer.deserialize_any(V)
+    }
+}
+
+// parses a comma_separated list of strings
+pub mod serde_option_conntrack_helpers {
+    use std::fmt;
+
+    use serde::de::{Deserializer, Error, Visitor};
+
+    pub fn deserialize<'de, D: Deserializer<'de>>(
+        deserializer: D,
+    ) -> Result<Option<Vec<String>>, D::Error> {
+        struct V;
+
+        impl<'de> Visitor<'de> for V {
+            type Value = Option<Vec<String>>;
+
+            fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result {
+                f.write_str("A list of conntrack helpers")
+            }
+
+            fn visit_str<E: Error>(self, v: &str) -> Result<Self::Value, E> {
+                if v.is_empty() {
+                    return Ok(None);
+                }
+
+                Ok(Some(v.split(',').map(String::from).collect()))
+            }
+
+            fn visit_none<E: Error>(self) -> Result<Self::Value, E> {
+                Ok(None)
+            }
+
+            fn visit_some<D>(self, deserializer: D) -> Result<Self::Value, D::Error>
+            where
+                D: Deserializer<'de>,
+            {
+                deserializer.deserialize_any(self)
+            }
+        }
+
+        deserializer.deserialize_any(V)
+    }
+}
+
+// parses a log_ratelimit string: '[enable=]<1|0> [,burst=<integer>] [,rate=<rate>]'
+pub mod serde_option_log_ratelimit {
+    use std::fmt;
+
+    use serde::de::{Deserializer, Error, Visitor};
+
+    use crate::firewall::types::log::LogRateLimit;
+
+    pub fn deserialize<'de, D: Deserializer<'de>>(
+        deserializer: D,
+    ) -> Result<Option<LogRateLimit>, D::Error> {
+        struct V;
+
+        impl<'de> Visitor<'de> for V {
+            type Value = Option<LogRateLimit>;
+
+            fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result {
+                f.write_str("a boolean-like value")
+            }
+
+            fn visit_str<E: Error>(self, v: &str) -> Result<Self::Value, E> {
+                v.parse().map_err(E::custom).map(Some)
+            }
+
+            fn visit_none<E: Error>(self) -> Result<Self::Value, E> {
+                Ok(None)
+            }
+
+            fn visit_some<D>(self, deserializer: D) -> Result<Self::Value, D::Error>
+            where
+                D: Deserializer<'de>,
+            {
+                deserializer.deserialize_any(self)
+            }
+        }
+
+        deserializer.deserialize_any(V)
+    }
+}
+
 /// `&str` deserializer which also accepts an `Option`.
 ///
 /// Serde's `StringDeserializer` does not.
-- 
2.39.2




^ permalink raw reply	[flat|nested] 67+ messages in thread

* [pve-devel] [PATCH proxmox-firewall 12/37] config: firewall: add cluster-specific config + option types
  2024-04-02 17:15 [pve-devel] [RFC container/firewall/manager/proxmox-firewall/qemu-server 00/37] proxmox firewall nftables implementation Stefan Hanreich
                   ` (10 preceding siblings ...)
  2024-04-02 17:16 ` [pve-devel] [PATCH proxmox-firewall 11/37] config: firewall: add generic parser for firewall configs Stefan Hanreich
@ 2024-04-02 17:16 ` Stefan Hanreich
  2024-04-02 17:16 ` [pve-devel] [PATCH proxmox-firewall 13/37] config: firewall: add host specific " Stefan Hanreich
                   ` (29 subsequent siblings)
  41 siblings, 0 replies; 67+ messages in thread
From: Stefan Hanreich @ 2024-04-02 17:16 UTC (permalink / raw)
  To: pve-devel; +Cc: Stefan Hanreich, Wolfgang Bumiller

Co-authored-by: Wolfgang Bumiller <w.bumiller@proxmox.com>
Signed-off-by: Stefan Hanreich <s.hanreich@proxmox.com>
---
 proxmox-ve-config/src/firewall/cluster.rs | 342 ++++++++++++++++++++++
 proxmox-ve-config/src/firewall/mod.rs     |   1 +
 2 files changed, 343 insertions(+)
 create mode 100644 proxmox-ve-config/src/firewall/cluster.rs

diff --git a/proxmox-ve-config/src/firewall/cluster.rs b/proxmox-ve-config/src/firewall/cluster.rs
new file mode 100644
index 0000000..903dadc
--- /dev/null
+++ b/proxmox-ve-config/src/firewall/cluster.rs
@@ -0,0 +1,342 @@
+use std::collections::HashMap;
+use std::io;
+
+use anyhow::Error;
+use serde::Deserialize;
+
+use crate::firewall::common::ParserConfig;
+use crate::firewall::types::ipset::{Ipset, IpsetScope};
+use crate::firewall::types::log::LogRateLimit;
+use crate::firewall::types::rule::{Direction, Verdict};
+use crate::firewall::types::{Alias, Group, Rule};
+
+use crate::firewall::parse::{serde_option_bool, serde_option_log_ratelimit};
+
+#[derive(Debug, Default)]
+pub struct Config {
+    pub(crate) config: super::common::Config<Options>,
+}
+
+impl Config {
+    pub fn parse<R: io::BufRead>(input: R) -> Result<Self, Error> {
+        let parser_config = ParserConfig {
+            guest_iface_names: false,
+            ipset_scope: Some(IpsetScope::Datacenter),
+        };
+
+        Ok(Self {
+            config: super::common::Config::parse(input, &parser_config)?,
+        })
+    }
+
+    pub fn rules(&self) -> &Vec<Rule> {
+        &self.config.rules
+    }
+
+    pub fn groups(&self) -> &HashMap<String, Group> {
+        &self.config.groups
+    }
+
+    pub fn ipsets(&self) -> &HashMap<String, Ipset> {
+        &self.config.ipsets
+    }
+
+    pub fn alias(&self, name: &str) -> Option<&Alias> {
+        self.config.alias(name)
+    }
+
+    pub fn is_enabled(&self) -> bool {
+        self.config.options.enable.unwrap_or(false)
+    }
+
+    pub fn ebtables(&self) -> bool {
+        self.config.options.ebtables.unwrap_or(false)
+    }
+
+    pub fn default_policy(&self, dir: Direction) -> Verdict {
+        match dir {
+            Direction::In => self.config.options.policy_in.unwrap_or(Verdict::Drop),
+            Direction::Out => self.config.options.policy_out.unwrap_or(Verdict::Accept),
+        }
+    }
+
+    pub fn log_ratelimit(&self) -> Option<LogRateLimit> {
+        let rate_limit = self
+            .config
+            .options
+            .log_ratelimit
+            .clone()
+            .unwrap_or_default();
+
+        match rate_limit.enabled() {
+            true => Some(rate_limit),
+            false => None,
+        }
+    }
+}
+
+#[derive(Debug, Default, Deserialize)]
+#[cfg_attr(test, derive(Eq, PartialEq))]
+pub struct Options {
+    #[serde(default, with = "serde_option_bool")]
+    enable: Option<bool>,
+
+    #[serde(default, with = "serde_option_bool")]
+    ebtables: Option<bool>,
+
+    #[serde(default, with = "serde_option_log_ratelimit")]
+    log_ratelimit: Option<LogRateLimit>,
+
+    policy_in: Option<Verdict>,
+    policy_out: Option<Verdict>,
+}
+
+#[cfg(test)]
+mod tests {
+    use crate::firewall::types::{
+        address::IpList,
+        alias::{AliasName, AliasScope},
+        ipset::{IpsetAddress, IpsetEntry},
+        log::{LogLevel, LogRateLimitTimescale},
+        rule::{Kind, RuleGroup},
+        rule_match::{
+            Icmpv6, Icmpv6Code, IpAddrMatch, IpMatch, Ports, Protocol, RuleMatch, Tcp, Udp,
+        },
+        Cidr,
+    };
+
+    use super::*;
+
+    #[test]
+    fn test_parse_config() {
+        const CONFIG: &str = r#"
+[OPTIONS]
+enable: 1
+log_ratelimit: 1,rate=10/second,burst=20
+ebtables: 0
+policy_in: REJECT
+policy_out: REJECT
+
+[ALIASES]
+
+another 8.8.8.18
+analias 7.7.0.0/16 # much
+wide cccc::/64
+
+[IPSET a-set]
+
+!5.5.5.5
+1.2.3.4/30
+dc/analias # a comment
+dc/wide
+dddd::/96
+
+[RULES]
+
+GROUP tgr -i eth0 # acomm
+IN ACCEPT -p udp -dport 33 -sport 22 -log warning
+
+[group tgr] # comment for tgr
+
+|OUT ACCEPT -source fe80::1/48 -dest dddd:3:3::9/64 -p icmpv6 -log nolog -icmp-type port-unreachable
+OUT ACCEPT -p tcp -sport 33 -log nolog
+IN BGP(REJECT) -log crit -source 1.2.3.4
+"#;
+
+        let mut config = CONFIG.as_bytes();
+        let config = Config::parse(&mut config).unwrap();
+
+        assert_eq!(
+            config.config.options,
+            Options {
+                ebtables: Some(false),
+                enable: Some(true),
+                log_ratelimit: Some(LogRateLimit::new(
+                    true,
+                    10,
+                    LogRateLimitTimescale::Second,
+                    20
+                )),
+                policy_in: Some(Verdict::Reject),
+                policy_out: Some(Verdict::Reject),
+            }
+        );
+
+        assert_eq!(config.config.aliases.len(), 3);
+
+        assert_eq!(
+            config.config.aliases["another"],
+            Alias::new("another", Cidr::new_v4([8, 8, 8, 18], 32).unwrap(), None),
+        );
+
+        assert_eq!(
+            config.config.aliases["analias"],
+            Alias::new(
+                "analias",
+                Cidr::new_v4([7, 7, 0, 0], 16).unwrap(),
+                "much".to_string()
+            ),
+        );
+
+        assert_eq!(
+            config.config.aliases["wide"],
+            Alias::new(
+                "wide",
+                Cidr::new_v6(
+                    [0xCCCC, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x000],
+                    64
+                )
+                .unwrap(),
+                None
+            ),
+        );
+
+        assert_eq!(config.config.ipsets.len(), 1);
+
+        let mut ipset_elements = vec![
+            IpsetEntry {
+                nomatch: true,
+                address: Cidr::new_v4([5, 5, 5, 5], 32).unwrap().into(),
+                comment: None,
+            },
+            IpsetEntry {
+                nomatch: false,
+                address: Cidr::new_v4([1, 2, 3, 4], 30).unwrap().into(),
+                comment: None,
+            },
+            IpsetEntry {
+                nomatch: false,
+                address: IpsetAddress::Alias(AliasName::new(AliasScope::Datacenter, "analias")),
+                comment: Some("a comment".to_string()),
+            },
+            IpsetEntry {
+                nomatch: false,
+                address: IpsetAddress::Alias(AliasName::new(AliasScope::Datacenter, "wide")),
+                comment: None,
+            },
+            IpsetEntry {
+                nomatch: false,
+                address: Cidr::new_v6([0xdd, 0xdd, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], 96)
+                    .unwrap()
+                    .into(),
+                comment: None,
+            },
+        ];
+
+        let mut ipset = Ipset::from_parts(IpsetScope::Datacenter, "a-set");
+        ipset.append(&mut ipset_elements);
+
+        assert_eq!(config.config.ipsets["a-set"], ipset,);
+
+        assert_eq!(config.config.rules.len(), 2);
+
+        assert_eq!(
+            config.config.rules[0],
+            Rule {
+                disabled: false,
+                comment: Some("acomm".to_string()),
+                kind: Kind::Group(RuleGroup {
+                    group: "tgr".to_string(),
+                    iface: Some("eth0".to_string()),
+                }),
+            },
+        );
+
+        assert_eq!(
+            config.config.rules[1],
+            Rule {
+                disabled: false,
+                comment: None,
+                kind: Kind::Match(RuleMatch {
+                    dir: Direction::In,
+                    verdict: Verdict::Accept,
+                    proto: Some(Protocol::Udp(Udp::new(Ports::from_u16(22, 33)))),
+                    log: Some(LogLevel::Warning),
+                    ..Default::default()
+                }),
+            },
+        );
+
+        assert_eq!(config.config.groups.len(), 1);
+
+        let entry = &config.config.groups["tgr"];
+        assert_eq!(entry.comment(), Some("comment for tgr"));
+        assert_eq!(entry.rules().len(), 3);
+
+        assert_eq!(
+            entry.rules()[0],
+            Rule {
+                disabled: true,
+                comment: None,
+                kind: Kind::Match(RuleMatch {
+                    dir: Direction::Out,
+                    verdict: Verdict::Accept,
+                    ip: Some(IpMatch {
+                        src: Some(IpAddrMatch::Ip(IpList::from(
+                            Cidr::new_v6(
+                                [0xfe, 0x80, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1],
+                                48
+                            )
+                            .unwrap()
+                        ))),
+                        dst: Some(IpAddrMatch::Ip(IpList::from(
+                            Cidr::new_v6(
+                                [0xdd, 0xdd, 0, 3, 0, 3, 0, 0, 0, 0, 0, 0, 0, 0, 0, 9],
+                                64
+                            )
+                            .unwrap()
+                        ))),
+                    }),
+                    proto: Some(Protocol::Icmpv6(Icmpv6::new_code(Icmpv6Code::Named(
+                        "port-unreachable"
+                    )))),
+                    log: Some(LogLevel::Nolog),
+                    ..Default::default()
+                }),
+            },
+        );
+        assert_eq!(
+            entry.rules()[1],
+            Rule {
+                disabled: false,
+                comment: None,
+                kind: Kind::Match(RuleMatch {
+                    dir: Direction::Out,
+                    verdict: Verdict::Accept,
+                    proto: Some(Protocol::Tcp(Tcp::new(Ports::from_u16(33, None)))),
+                    log: Some(LogLevel::Nolog),
+                    ..Default::default()
+                }),
+            },
+        );
+
+        assert_eq!(
+            entry.rules()[2],
+            Rule {
+                disabled: false,
+                comment: None,
+                kind: Kind::Match(RuleMatch {
+                    dir: Direction::In,
+                    verdict: Verdict::Reject,
+                    log: Some(LogLevel::Critical),
+                    fw_macro: Some("BGP".to_string()),
+                    ip: Some(IpMatch {
+                        src: Some(IpAddrMatch::Ip(IpList::from(
+                            Cidr::new_v4([1, 2, 3, 4], 32).unwrap()
+                        ))),
+                        dst: None,
+                    }),
+                    ..Default::default()
+                }),
+            },
+        );
+
+        let empty_config = Config::parse("".as_bytes()).expect("empty config is invalid");
+
+        assert_eq!(empty_config.config.options, Options::default());
+        assert!(empty_config.config.rules.is_empty());
+        assert!(empty_config.config.aliases.is_empty());
+        assert!(empty_config.config.ipsets.is_empty());
+        assert!(empty_config.config.groups.is_empty());
+    }
+}
diff --git a/proxmox-ve-config/src/firewall/mod.rs b/proxmox-ve-config/src/firewall/mod.rs
index 591ee52..82689c3 100644
--- a/proxmox-ve-config/src/firewall/mod.rs
+++ b/proxmox-ve-config/src/firewall/mod.rs
@@ -1,3 +1,4 @@
+pub mod cluster;
 pub mod common;
 pub mod ports;
 pub mod types;
-- 
2.39.2




^ permalink raw reply	[flat|nested] 67+ messages in thread

* [pve-devel] [PATCH proxmox-firewall 13/37] config: firewall: add host specific config + option types
  2024-04-02 17:15 [pve-devel] [RFC container/firewall/manager/proxmox-firewall/qemu-server 00/37] proxmox firewall nftables implementation Stefan Hanreich
                   ` (11 preceding siblings ...)
  2024-04-02 17:16 ` [pve-devel] [PATCH proxmox-firewall 12/37] config: firewall: add cluster-specific config + option types Stefan Hanreich
@ 2024-04-02 17:16 ` Stefan Hanreich
  2024-04-03 10:47   ` Max Carrara
  2024-04-02 17:16 ` [pve-devel] [PATCH proxmox-firewall 14/37] config: firewall: add guest-specific " Stefan Hanreich
                   ` (28 subsequent siblings)
  41 siblings, 1 reply; 67+ messages in thread
From: Stefan Hanreich @ 2024-04-02 17:16 UTC (permalink / raw)
  To: pve-devel; +Cc: Stefan Hanreich, Wolfgang Bumiller

Co-authored-by: Wolfgang Bumiller <w.bumiller@proxmox.com>
Signed-off-by: Stefan Hanreich <s.hanreich@proxmox.com>
---
 proxmox-ve-config/src/firewall/host.rs | 309 +++++++++++++++++++++++++
 proxmox-ve-config/src/firewall/mod.rs  |   1 +
 2 files changed, 310 insertions(+)
 create mode 100644 proxmox-ve-config/src/firewall/host.rs

diff --git a/proxmox-ve-config/src/firewall/host.rs b/proxmox-ve-config/src/firewall/host.rs
new file mode 100644
index 0000000..3e47bfa
--- /dev/null
+++ b/proxmox-ve-config/src/firewall/host.rs
@@ -0,0 +1,309 @@
+use std::io;
+use std::net::IpAddr;
+
+use anyhow::{bail, Error};
+use serde::Deserialize;
+
+use crate::host::utils::{host_ips, hostname, network_interface_cidrs};
+
+use crate::firewall::parse;
+use crate::firewall::types::log::LogLevel;
+use crate::firewall::types::rule::Direction;
+use crate::firewall::types::{Alias, Cidr, Rule};
+
+#[derive(Debug, Default, Deserialize)]
+#[cfg_attr(test, derive(Eq, PartialEq))]
+pub struct Options {
+    #[serde(default, with = "parse::serde_option_bool")]
+    enable: Option<bool>,
+
+    #[serde(default, with = "parse::serde_option_bool")]
+    nftables: Option<bool>,
+
+    log_level_in: Option<LogLevel>,
+    log_level_out: Option<LogLevel>,
+
+    #[serde(default, with = "parse::serde_option_bool")]
+    log_nf_conntrack: Option<bool>,
+    #[serde(default, with = "parse::serde_option_bool")]
+    ndp: Option<bool>,
+
+    #[serde(default, with = "parse::serde_option_bool")]
+    nf_conntrack_allow_invalid: Option<bool>,
+
+    #[serde(default, with = "parse::serde_option_conntrack_helpers")]
+    nf_conntrack_helpers: Option<Vec<String>>,
+
+    #[serde(default, with = "parse::serde_option_number")]
+    nf_conntrack_max: Option<i64>,
+    #[serde(default, with = "parse::serde_option_number")]
+    nf_conntrack_tcp_timeout_established: Option<i64>,
+    #[serde(default, with = "parse::serde_option_number")]
+    nf_conntrack_tcp_timeout_syn_recv: Option<i64>,
+
+    #[serde(default, with = "parse::serde_option_bool")]
+    nosmurfs: Option<bool>,
+
+    #[serde(default, with = "parse::serde_option_bool")]
+    protection_synflood: Option<bool>,
+    #[serde(default, with = "parse::serde_option_number")]
+    protection_synflood_burst: Option<i64>,
+    #[serde(default, with = "parse::serde_option_number")]
+    protection_synflood_rate: Option<i64>,
+
+    smurf_log_level: Option<LogLevel>,
+    tcp_flags_log_level: Option<LogLevel>,
+
+    #[serde(default, with = "parse::serde_option_bool")]
+    tcpflags: Option<bool>,
+}
+
+#[derive(Debug, Default)]
+pub struct Config {
+    pub(crate) config: super::common::Config<Options>,
+}
+
+impl Config {
+    pub fn new() -> Self {
+        Self {
+            config: Default::default(),
+        }
+    }
+
+    pub fn parse<R: io::BufRead>(input: R) -> Result<Self, Error> {
+        let config = super::common::Config::parse(input, &Default::default())?;
+
+        if !config.groups.is_empty() {
+            bail!("host firewall config cannot declare groups");
+        }
+
+        if !config.aliases.is_empty() {
+            bail!("host firewall config cannot declare aliases");
+        }
+
+        if !config.ipsets.is_empty() {
+            bail!("host firewall config cannot declare ipsets");
+        }
+
+        Ok(Self { config })
+    }
+
+    pub fn rules(&self) -> &[Rule] {
+        &self.config.rules
+    }
+
+    pub fn management_ips() -> Result<Vec<Cidr>, Error> {
+        let mut management_cidrs = Vec::new();
+
+        for host_ip in host_ips() {
+            for network_interface_cidr in network_interface_cidrs() {
+                match (host_ip, network_interface_cidr) {
+                    (IpAddr::V4(ip), Cidr::Ipv4(cidr)) => {
+                        if cidr.contains_address(ip) {
+                            management_cidrs.push(network_interface_cidr.clone());
+                        }
+                    }
+                    (IpAddr::V6(ip), Cidr::Ipv6(cidr)) => {
+                        if cidr.contains_address(ip) {
+                            management_cidrs.push(network_interface_cidr.clone());
+                        }
+                    }
+                    _ => continue,
+                };
+            }
+        }
+
+        Ok(management_cidrs)
+    }
+
+    pub fn hostname() -> &'static str {
+        hostname()
+    }
+
+    pub fn get_alias(&self, name: &str) -> Option<&Alias> {
+        self.config.alias(name)
+    }
+
+    pub fn is_enabled(&self) -> bool {
+        self.config.options.enable.unwrap_or(true)
+    }
+
+    pub fn nftables(&self) -> bool {
+        self.config.options.nftables.unwrap_or(false)
+    }
+
+    pub fn allow_ndp(&self) -> bool {
+        self.config.options.ndp.unwrap_or(true)
+    }
+
+    pub fn block_smurfs(&self) -> bool {
+        self.config.options.nosmurfs.unwrap_or(true)
+    }
+
+    pub fn block_smurfs_log_level(&self) -> LogLevel {
+        self.config.options.smurf_log_level.unwrap_or_default()
+    }
+
+    pub fn block_synflood(&self) -> bool {
+        self.config.options.protection_synflood.unwrap_or(false)
+    }
+
+    pub fn synflood_rate(&self) -> i64 {
+        self.config.options.protection_synflood_rate.unwrap_or(200)
+    }
+
+    pub fn synflood_burst(&self) -> i64 {
+        self.config
+            .options
+            .protection_synflood_burst
+            .unwrap_or(1000)
+    }
+
+    pub fn block_invalid_tcp(&self) -> bool {
+        self.config.options.tcpflags.unwrap_or(false)
+    }
+
+    pub fn block_invalid_tcp_log_level(&self) -> LogLevel {
+        self.config.options.tcp_flags_log_level.unwrap_or_default()
+    }
+
+    pub fn block_invalid_conntrack(&self) -> bool {
+        !self
+            .config
+            .options
+            .nf_conntrack_allow_invalid
+            .unwrap_or(false)
+    }
+
+    pub fn nf_conntrack_max(&self) -> Option<i64> {
+        self.config.options.nf_conntrack_max
+    }
+
+    pub fn nf_conntrack_tcp_timeout_established(&self) -> Option<i64> {
+        self.config.options.nf_conntrack_tcp_timeout_established
+    }
+
+    pub fn nf_conntrack_tcp_timeout_syn_recv(&self) -> Option<i64> {
+        self.config.options.nf_conntrack_tcp_timeout_syn_recv
+    }
+
+    pub fn log_nf_conntrack(&self) -> bool {
+        self.config.options.log_nf_conntrack.unwrap_or(false)
+    }
+
+    pub fn conntrack_helpers(&self) -> Option<&Vec<String>> {
+        self.config.options.nf_conntrack_helpers.as_ref()
+    }
+
+    pub fn log_level(&self, dir: Direction) -> LogLevel {
+        match dir {
+            Direction::In => self.config.options.log_level_in.unwrap_or_default(),
+            Direction::Out => self.config.options.log_level_out.unwrap_or_default(),
+        }
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use crate::firewall::types::{
+        log::LogLevel,
+        rule::{Kind, RuleGroup, Verdict},
+        rule_match::{Ports, Protocol, RuleMatch, Udp},
+    };
+
+    use super::*;
+
+    #[test]
+    fn test_parse_config() {
+        const CONFIG: &str = r#"
+[OPTIONS]
+enable: 1
+nftables: 1
+log_level_in: debug
+log_level_out: emerg
+log_nf_conntrack: 0
+ndp: 1
+nf_conntrack_allow_invalid: yes
+nf_conntrack_helpers: ftp
+nf_conntrack_max: 44000
+nf_conntrack_tcp_timeout_established: 500000
+nf_conntrack_tcp_timeout_syn_recv: 44
+nosmurfs: no
+protection_synflood: 1
+protection_synflood_burst: 2500
+protection_synflood_rate: 300
+smurf_log_level: notice
+tcp_flags_log_level: nolog
+tcpflags: yes
+
+[RULES]
+
+GROUP tgr -i eth0 # acomm
+IN ACCEPT -p udp -dport 33 -sport 22 -log warning
+
+"#;
+
+        let mut config = CONFIG.as_bytes();
+        let config = Config::parse(&mut config).unwrap();
+
+        assert_eq!(
+            config.config.options,
+            Options {
+                enable: Some(true),
+                nftables: Some(true),
+                log_level_in: Some(LogLevel::Debug),
+                log_level_out: Some(LogLevel::Emergency),
+                log_nf_conntrack: Some(false),
+                ndp: Some(true),
+                nf_conntrack_allow_invalid: Some(true),
+                nf_conntrack_helpers: Some(vec!["ftp".to_string()]),
+                nf_conntrack_max: Some(44000),
+                nf_conntrack_tcp_timeout_established: Some(500000),
+                nf_conntrack_tcp_timeout_syn_recv: Some(44),
+                nosmurfs: Some(false),
+                protection_synflood: Some(true),
+                protection_synflood_burst: Some(2500),
+                protection_synflood_rate: Some(300),
+                smurf_log_level: Some(LogLevel::Notice),
+                tcp_flags_log_level: Some(LogLevel::Nolog),
+                tcpflags: Some(true),
+            }
+        );
+
+        assert_eq!(config.config.rules.len(), 2);
+
+        assert_eq!(
+            config.config.rules[0],
+            Rule {
+                disabled: false,
+                comment: Some("acomm".to_string()),
+                kind: Kind::Group(RuleGroup {
+                    group: "tgr".to_string(),
+                    iface: Some("eth0".to_string()),
+                }),
+            },
+        );
+
+        assert_eq!(
+            config.config.rules[1],
+            Rule {
+                disabled: false,
+                comment: None,
+                kind: Kind::Match(RuleMatch {
+                    dir: Direction::In,
+                    verdict: Verdict::Accept,
+                    proto: Some(Protocol::Udp(Udp::new(Ports::from_u16(22, 33)))),
+                    log: Some(LogLevel::Warning),
+                    ..Default::default()
+                }),
+            },
+        );
+
+        Config::parse("[ALIASES]\ntest 127.0.0.1".as_bytes())
+            .expect_err("host config cannot contain aliases");
+
+        Config::parse("[GROUP test]".as_bytes()).expect_err("host config cannot contain groups");
+
+        Config::parse("[IPSET test]".as_bytes()).expect_err("host config cannot contain ipsets");
+    }
+}
diff --git a/proxmox-ve-config/src/firewall/mod.rs b/proxmox-ve-config/src/firewall/mod.rs
index 82689c3..85fe6c4 100644
--- a/proxmox-ve-config/src/firewall/mod.rs
+++ b/proxmox-ve-config/src/firewall/mod.rs
@@ -1,5 +1,6 @@
 pub mod cluster;
 pub mod common;
+pub mod host;
 pub mod ports;
 pub mod types;
 
-- 
2.39.2




^ permalink raw reply	[flat|nested] 67+ messages in thread

* [pve-devel] [PATCH proxmox-firewall 14/37] config: firewall: add guest-specific config + option types
  2024-04-02 17:15 [pve-devel] [RFC container/firewall/manager/proxmox-firewall/qemu-server 00/37] proxmox firewall nftables implementation Stefan Hanreich
                   ` (12 preceding siblings ...)
  2024-04-02 17:16 ` [pve-devel] [PATCH proxmox-firewall 13/37] config: firewall: add host specific " Stefan Hanreich
@ 2024-04-02 17:16 ` Stefan Hanreich
  2024-04-02 17:16 ` [pve-devel] [PATCH proxmox-firewall 15/37] config: firewall: add firewall macros Stefan Hanreich
                   ` (27 subsequent siblings)
  41 siblings, 0 replies; 67+ messages in thread
From: Stefan Hanreich @ 2024-04-02 17:16 UTC (permalink / raw)
  To: pve-devel; +Cc: Stefan Hanreich, Wolfgang Bumiller

Co-authored-by: Wolfgang Bumiller <w.bumiller@proxmox.com>
Signed-off-by: Stefan Hanreich <s.hanreich@proxmox.com>
---
 proxmox-ve-config/src/firewall/guest.rs | 194 ++++++++++++++++++++++++
 proxmox-ve-config/src/firewall/mod.rs   |   1 +
 2 files changed, 195 insertions(+)
 create mode 100644 proxmox-ve-config/src/firewall/guest.rs

diff --git a/proxmox-ve-config/src/firewall/guest.rs b/proxmox-ve-config/src/firewall/guest.rs
new file mode 100644
index 0000000..6ca446c
--- /dev/null
+++ b/proxmox-ve-config/src/firewall/guest.rs
@@ -0,0 +1,194 @@
+use std::collections::HashMap;
+use std::io;
+
+use crate::guest::types::Vmid;
+use crate::guest::vm::NetworkConfig;
+
+use crate::firewall::types::alias::{Alias, AliasName};
+use crate::firewall::types::ipset::IpsetScope;
+use crate::firewall::types::log::LogLevel;
+use crate::firewall::types::rule::{Direction, Rule, Verdict};
+use crate::firewall::types::Ipset;
+
+use anyhow::{bail, Error};
+use serde::Deserialize;
+
+use crate::firewall::parse::serde_option_bool;
+
+#[derive(Debug, Default, Deserialize)]
+#[cfg_attr(test, derive(Eq, PartialEq))]
+pub struct Options {
+    #[serde(default, with = "serde_option_bool")]
+    dhcp: Option<bool>,
+
+    #[serde(default, with = "serde_option_bool")]
+    enable: Option<bool>,
+
+    #[serde(default, with = "serde_option_bool")]
+    ipfilter: Option<bool>,
+
+    #[serde(default, with = "serde_option_bool")]
+    ndp: Option<bool>,
+
+    #[serde(default, with = "serde_option_bool")]
+    radv: Option<bool>,
+
+    log_level_in: Option<LogLevel>,
+    log_level_out: Option<LogLevel>,
+
+    #[serde(default, with = "serde_option_bool")]
+    macfilter: Option<bool>,
+
+    #[serde(rename = "policy_in")]
+    policy_in: Option<Verdict>,
+
+    #[serde(rename = "policy_out")]
+    policy_out: Option<Verdict>,
+}
+
+#[derive(Debug)]
+pub struct Config {
+    vmid: Vmid,
+
+    /// The interface prefix: "veth" for containers, "tap" for VMs.
+    iface_prefix: &'static str,
+
+    network_config: NetworkConfig,
+    config: super::common::Config<Options>,
+}
+
+impl Config {
+    pub fn parse<T: io::BufRead, U: io::BufRead>(
+        vmid: &Vmid,
+        iface_prefix: &'static str,
+        firewall_input: T,
+        network_input: U,
+    ) -> Result<Self, Error> {
+        let parser_cfg = super::common::ParserConfig {
+            guest_iface_names: true,
+            ipset_scope: Some(IpsetScope::Guest),
+        };
+
+        let config = super::common::Config::parse(firewall_input, &parser_cfg)?;
+        if !config.groups.is_empty() {
+            bail!("guest firewall config cannot declare groups");
+        }
+
+        let network_config = NetworkConfig::parse(network_input)?;
+
+        Ok(Self {
+            vmid: *vmid,
+            iface_prefix,
+            config,
+            network_config,
+        })
+    }
+
+    pub fn alias(&self, name: &AliasName) -> Option<&Alias> {
+        self.config.alias(name.name())
+    }
+
+    pub fn iface_name_by_key(&self, key: &str) -> Result<String, Error> {
+        let index = NetworkConfig::index_from_net_key(key)?;
+        Ok(format!("{}{}i{index}", self.iface_prefix, self.vmid))
+    }
+
+    pub fn iface_name_by_index(&self, index: i64) -> String {
+        format!("{}{}i{index}", self.iface_prefix, self.vmid)
+    }
+
+    pub fn is_enabled(&self) -> bool {
+        self.config.options.enable.unwrap_or(false)
+    }
+
+    pub fn rules(&self) -> &[Rule] {
+        &self.config.rules
+    }
+
+    pub fn log_level(&self, dir: Direction) -> LogLevel {
+        match dir {
+            Direction::In => self.config.options.log_level_in.unwrap_or_default(),
+            Direction::Out => self.config.options.log_level_out.unwrap_or_default(),
+        }
+    }
+
+    pub fn allow_ndp(&self) -> bool {
+        self.config.options.ndp.unwrap_or(true)
+    }
+
+    pub fn allow_dhcp(&self) -> bool {
+        self.config.options.dhcp.unwrap_or(true)
+    }
+
+    pub fn allow_ra(&self) -> bool {
+        self.config.options.radv.unwrap_or(false)
+    }
+
+    pub fn macfilter(&self) -> bool {
+        self.config.options.macfilter.unwrap_or(true)
+    }
+
+    pub fn ipfilter(&self) -> bool {
+        self.config.options.ipfilter.unwrap_or(false)
+    }
+
+    pub fn default_policy(&self, dir: Direction) -> Verdict {
+        match dir {
+            Direction::In => self.config.options.policy_in.unwrap_or(Verdict::Drop),
+            Direction::Out => self.config.options.policy_out.unwrap_or(Verdict::Accept),
+        }
+    }
+
+    pub fn network_config(&self) -> &NetworkConfig {
+        &self.network_config
+    }
+
+    pub fn ipsets(&self) -> &HashMap<String, Ipset> {
+        self.config.ipsets()
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn test_parse_config() {
+        // most of the stuff is already tested in cluster parsing, only testing
+        // guest specific options here
+        const CONFIG: &str = r#"
+[OPTIONS]
+enable: 1
+dhcp: 1
+ipfilter: 0
+log_level_in: emerg
+log_level_out: crit
+macfilter: 0
+ndp:1
+radv:1
+policy_in: REJECT
+policy_out: REJECT
+"#;
+
+        let config = CONFIG.as_bytes();
+        let network_config: Vec<u8> = Vec::new();
+        let config =
+            Config::parse(&Vmid::new(100), "tap", config, network_config.as_slice()).unwrap();
+
+        assert_eq!(
+            config.config.options,
+            Options {
+                dhcp: Some(true),
+                enable: Some(true),
+                ipfilter: Some(false),
+                ndp: Some(true),
+                radv: Some(true),
+                log_level_in: Some(LogLevel::Emergency),
+                log_level_out: Some(LogLevel::Critical),
+                macfilter: Some(false),
+                policy_in: Some(Verdict::Reject),
+                policy_out: Some(Verdict::Reject),
+            }
+        );
+    }
+}
diff --git a/proxmox-ve-config/src/firewall/mod.rs b/proxmox-ve-config/src/firewall/mod.rs
index 85fe6c4..afc3dcc 100644
--- a/proxmox-ve-config/src/firewall/mod.rs
+++ b/proxmox-ve-config/src/firewall/mod.rs
@@ -1,5 +1,6 @@
 pub mod cluster;
 pub mod common;
+pub mod guest;
 pub mod host;
 pub mod ports;
 pub mod types;
-- 
2.39.2




^ permalink raw reply	[flat|nested] 67+ messages in thread

* [pve-devel] [PATCH proxmox-firewall 15/37] config: firewall: add firewall macros
  2024-04-02 17:15 [pve-devel] [RFC container/firewall/manager/proxmox-firewall/qemu-server 00/37] proxmox firewall nftables implementation Stefan Hanreich
                   ` (13 preceding siblings ...)
  2024-04-02 17:16 ` [pve-devel] [PATCH proxmox-firewall 14/37] config: firewall: add guest-specific " Stefan Hanreich
@ 2024-04-02 17:16 ` Stefan Hanreich
  2024-04-02 17:16 ` [pve-devel] [PATCH proxmox-firewall 16/37] config: firewall: add conntrack helper types Stefan Hanreich
                   ` (26 subsequent siblings)
  41 siblings, 0 replies; 67+ messages in thread
From: Stefan Hanreich @ 2024-04-02 17:16 UTC (permalink / raw)
  To: pve-devel; +Cc: Stefan Hanreich, Wolfgang Bumiller

Co-authored-by: Wolfgang Bumiller <w.bumiller@proxmox.com>
Signed-off-by: Stefan Hanreich <s.hanreich@proxmox.com>
---
 proxmox-ve-config/resources/macros.json     | 896 ++++++++++++++++++++
 proxmox-ve-config/src/firewall/fw_macros.rs |  69 ++
 proxmox-ve-config/src/firewall/mod.rs       |   1 +
 3 files changed, 966 insertions(+)
 create mode 100644 proxmox-ve-config/resources/macros.json
 create mode 100644 proxmox-ve-config/src/firewall/fw_macros.rs

diff --git a/proxmox-ve-config/resources/macros.json b/proxmox-ve-config/resources/macros.json
new file mode 100644
index 0000000..666bcbf
--- /dev/null
+++ b/proxmox-ve-config/resources/macros.json
@@ -0,0 +1,896 @@
+{
+  "Amanda": {
+    "code": [
+      {
+        "dport": "10080",
+        "proto": "udp"
+      },
+      {
+        "dport": "10080",
+        "proto": "tcp"
+      }
+    ],
+    "desc": "Amanda Backup"
+  },
+  "Auth": {
+    "code": [
+      {
+        "dport": "113",
+        "proto": "tcp"
+      }
+    ],
+    "desc": "Auth (identd) traffic"
+  },
+  "BGP": {
+    "code": [
+      {
+        "dport": "179",
+        "proto": "tcp"
+      }
+    ],
+    "desc": "Border Gateway Protocol traffic"
+  },
+  "BitTorrent": {
+    "code": [
+      {
+        "dport": "6881:6889",
+        "proto": "tcp"
+      },
+      {
+        "dport": "6881",
+        "proto": "udp"
+      }
+    ],
+    "desc": "BitTorrent traffic for BitTorrent 3.1 and earlier"
+  },
+  "BitTorrent32": {
+    "code": [
+      {
+        "dport": "6881:6999",
+        "proto": "tcp"
+      },
+      {
+        "dport": "6881",
+        "proto": "udp"
+      }
+    ],
+    "desc": "BitTorrent traffic for BitTorrent 3.2 and later"
+  },
+  "CVS": {
+    "code": [
+      {
+        "dport": "2401",
+        "proto": "tcp"
+      }
+    ],
+    "desc": "Concurrent Versions System pserver traffic"
+  },
+  "Ceph": {
+    "code": [
+      {
+        "dport": "6789",
+        "proto": "tcp"
+      },
+      {
+        "dport": "3300",
+        "proto": "tcp"
+      },
+      {
+        "dport": "6800:7300",
+        "proto": "tcp"
+      }
+    ],
+    "desc": "Ceph Storage Cluster traffic (Ceph Monitors, OSD & MDS Daemons)"
+  },
+  "Citrix": {
+    "code": [
+      {
+        "dport": "1494",
+        "proto": "tcp"
+      },
+      {
+        "dport": "1604",
+        "proto": "udp"
+      },
+      {
+        "dport": "2598",
+        "proto": "tcp"
+      }
+    ],
+    "desc": "Citrix/ICA traffic (ICA, ICA Browser, CGP)"
+  },
+  "DAAP": {
+    "code": [
+      {
+        "dport": "3689",
+        "proto": "tcp"
+      },
+      {
+        "dport": "3689",
+        "proto": "udp"
+      }
+    ],
+    "desc": "Digital Audio Access Protocol traffic (iTunes, Rythmbox daemons)"
+  },
+  "DCC": {
+    "code": [
+      {
+        "dport": "6277",
+        "proto": "tcp"
+      }
+    ],
+    "desc": "Distributed Checksum Clearinghouse spam filtering mechanism"
+  },
+  "DHCPfwd": {
+    "code": [
+      {
+        "dport": "67:68",
+        "proto": "udp",
+        "sport": "67:68"
+      }
+    ],
+    "desc": "Forwarded DHCP traffic"
+  },
+  "DHCPv6": {
+    "code": [
+      {
+        "dport": "546:547",
+        "proto": "udp",
+        "sport": "546:547"
+      }
+    ],
+    "desc": "DHCPv6 traffic"
+  },
+  "DNS": {
+    "code": [
+      {
+        "dport": "53",
+        "proto": "udp"
+      },
+      {
+        "dport": "53",
+        "proto": "tcp"
+      }
+    ],
+    "desc": "Domain Name System traffic (upd and tcp)"
+  },
+  "Distcc": {
+    "code": [
+      {
+        "dport": "3632",
+        "proto": "tcp"
+      }
+    ],
+    "desc": "Distributed Compiler service"
+  },
+  "FTP": {
+    "code": [
+      {
+        "dport": "21",
+        "proto": "tcp"
+      }
+    ],
+    "desc": "File Transfer Protocol"
+  },
+  "Finger": {
+    "code": [
+      {
+        "dport": "79",
+        "proto": "tcp"
+      }
+    ],
+    "desc": "Finger protocol (RFC 742)"
+  },
+  "GNUnet": {
+    "code": [
+      {
+        "dport": "2086",
+        "proto": "tcp"
+      },
+      {
+        "dport": "2086",
+        "proto": "udp"
+      },
+      {
+        "dport": "1080",
+        "proto": "tcp"
+      },
+      {
+        "dport": "1080",
+        "proto": "udp"
+      }
+    ],
+    "desc": "GNUnet secure peer-to-peer networking traffic"
+  },
+  "GRE": {
+    "code": [
+      {
+        "proto": "47"
+      }
+    ],
+    "desc": "Generic Routing Encapsulation tunneling protocol"
+  },
+  "Git": {
+    "code": [
+      {
+        "dport": "9418",
+        "proto": "tcp"
+      }
+    ],
+    "desc": "Git distributed revision control traffic"
+  },
+  "HKP": {
+    "code": [
+      {
+        "dport": "11371",
+        "proto": "tcp"
+      }
+    ],
+    "desc": "OpenPGP HTTP key server protocol traffic"
+  },
+  "HTTP": {
+    "code": [
+      {
+        "dport": "80",
+        "proto": "tcp"
+      }
+    ],
+    "desc": "Hypertext Transfer Protocol (WWW)"
+  },
+  "HTTPS": {
+    "code": [
+      {
+        "dport": "443",
+        "proto": "tcp"
+      }
+    ],
+    "desc": "Hypertext Transfer Protocol (WWW) over SSL"
+  },
+  "ICPV2": {
+    "code": [
+      {
+        "dport": "3130",
+        "proto": "udp"
+      }
+    ],
+    "desc": "Internet Cache Protocol V2 (Squid) traffic"
+  },
+  "ICQ": {
+    "code": [
+      {
+        "dport": "5190",
+        "proto": "tcp"
+      }
+    ],
+    "desc": "AOL Instant Messenger traffic"
+  },
+  "IMAP": {
+    "code": [
+      {
+        "dport": "143",
+        "proto": "tcp"
+      }
+    ],
+    "desc": "Internet Message Access Protocol"
+  },
+  "IMAPS": {
+    "code": [
+      {
+        "dport": "993",
+        "proto": "tcp"
+      }
+    ],
+    "desc": "Internet Message Access Protocol over SSL"
+  },
+  "IPIP": {
+    "code": [
+      {
+        "proto": "94"
+      }
+    ],
+    "desc": "IPIP capsulation traffic"
+  },
+  "IPsec": {
+    "code": [
+      {
+        "dport": "500",
+        "proto": "udp",
+        "sport": "500"
+      },
+      {
+        "proto": "50"
+      }
+    ],
+    "desc": "IPsec traffic"
+  },
+  "IPsecah": {
+    "code": [
+      {
+        "dport": "500",
+        "proto": "udp",
+        "sport": "500"
+      },
+      {
+        "proto": "51"
+      }
+    ],
+    "desc": "IPsec authentication (AH) traffic"
+  },
+  "IPsecnat": {
+    "code": [
+      {
+        "dport": "500",
+        "proto": "udp"
+      },
+      {
+        "dport": "4500",
+        "proto": "udp"
+      },
+      {
+        "proto": "50"
+      }
+    ],
+    "desc": "IPsec traffic and Nat-Traversal"
+  },
+  "IRC": {
+    "code": [
+      {
+        "dport": "6667",
+        "proto": "tcp"
+      }
+    ],
+    "desc": "Internet Relay Chat traffic"
+  },
+  "Jetdirect": {
+    "code": [
+      {
+        "dport": "9100",
+        "proto": "tcp"
+      }
+    ],
+    "desc": "HP Jetdirect printing"
+  },
+  "L2TP": {
+    "code": [
+      {
+        "dport": "1701",
+        "proto": "udp"
+      }
+    ],
+    "desc": "Layer 2 Tunneling Protocol traffic"
+  },
+  "LDAP": {
+    "code": [
+      {
+        "dport": "389",
+        "proto": "tcp"
+      }
+    ],
+    "desc": "Lightweight Directory Access Protocol traffic"
+  },
+  "LDAPS": {
+    "code": [
+      {
+        "dport": "636",
+        "proto": "tcp"
+      }
+    ],
+    "desc": "Secure Lightweight Directory Access Protocol traffic"
+  },
+  "MDNS": {
+    "code": [
+      {
+        "dport": "5353",
+        "proto": "udp"
+      }
+    ],
+    "desc": "Multicast DNS"
+  },
+  "MSNP": {
+    "code": [
+      {
+        "dport": "1863",
+        "proto": "tcp"
+      }
+    ],
+    "desc": "Microsoft Notification Protocol"
+  },
+  "MSSQL": {
+    "code": [
+      {
+        "dport": "1433",
+        "proto": "tcp"
+      }
+    ],
+    "desc": "Microsoft SQL Server"
+  },
+  "Mail": {
+    "code": [
+      {
+        "dport": "25",
+        "proto": "tcp"
+      },
+      {
+        "dport": "465",
+        "proto": "tcp"
+      },
+      {
+        "dport": "587",
+        "proto": "tcp"
+      }
+    ],
+    "desc": "Mail traffic (SMTP, SMTPS, Submission)"
+  },
+  "Munin": {
+    "code": [
+      {
+        "dport": "4949",
+        "proto": "tcp"
+      }
+    ],
+    "desc": "Munin networked resource monitoring traffic"
+  },
+  "MySQL": {
+    "code": [
+      {
+        "dport": "3306",
+        "proto": "tcp"
+      }
+    ],
+    "desc": "MySQL server"
+  },
+  "NNTP": {
+    "code": [
+      {
+        "dport": "119",
+        "proto": "tcp"
+      }
+    ],
+    "desc": "NNTP traffic (Usenet)."
+  },
+  "NNTPS": {
+    "code": [
+      {
+        "dport": "563",
+        "proto": "tcp"
+      }
+    ],
+    "desc": "Encrypted NNTP traffic (Usenet)"
+  },
+  "NTP": {
+    "code": [
+      {
+        "dport": "123",
+        "proto": "udp"
+      }
+    ],
+    "desc": "Network Time Protocol (ntpd)"
+  },
+  "NeighborDiscovery": {
+    "code": [
+      {
+        "dport": "nd-router-solicit",
+        "proto": "icmpv6"
+      },
+      {
+        "dport": "nd-router-advert",
+        "proto": "icmpv6"
+      },
+      {
+        "dport": "nd-neighbor-solicit",
+        "proto": "icmpv6"
+      },
+      {
+        "dport": "nd-neighbor-advert",
+        "proto": "icmpv6"
+      }
+    ],
+    "desc": "IPv6 neighbor solicitation, neighbor and router advertisement"
+  },
+  "OSPF": {
+    "code": [
+      {
+        "proto": "89"
+      }
+    ],
+    "desc": "OSPF multicast traffic"
+  },
+  "OpenVPN": {
+    "code": [
+      {
+        "dport": "1194",
+        "proto": "udp"
+      }
+    ],
+    "desc": "OpenVPN traffic"
+  },
+  "PCA": {
+    "code": [
+      {
+        "dport": "5632",
+        "proto": "udp"
+      },
+      {
+        "dport": "5631",
+        "proto": "tcp"
+      }
+    ],
+    "desc": "Symantec PCAnywere (tm)"
+  },
+  "PMG": {
+    "code": [
+      {
+        "dport": "8006",
+        "proto": "tcp"
+      }
+    ],
+    "desc": "Proxmox Mail Gateway web interface"
+  },
+  "POP3": {
+    "code": [
+      {
+        "dport": "110",
+        "proto": "tcp"
+      }
+    ],
+    "desc": "POP3 traffic"
+  },
+  "POP3S": {
+    "code": [
+      {
+        "dport": "995",
+        "proto": "tcp"
+      }
+    ],
+    "desc": "Encrypted POP3 traffic"
+  },
+  "PPtP": {
+    "code": [
+      {
+        "proto": "47"
+      },
+      {
+        "dport": "1723",
+        "proto": "tcp"
+      }
+    ],
+    "desc": "Point-to-Point Tunneling Protocol"
+  },
+  "Ping": {
+    "code": [
+      {
+        "dport": "echo-request",
+        "proto": "icmp"
+      }
+    ],
+    "desc": "ICMP echo request"
+  },
+  "PostgreSQL": {
+    "code": [
+      {
+        "dport": "5432",
+        "proto": "tcp"
+      }
+    ],
+    "desc": "PostgreSQL server"
+  },
+  "Printer": {
+    "code": [
+      {
+        "dport": "515",
+        "proto": "tcp"
+      }
+    ],
+    "desc": "Line Printer protocol printing"
+  },
+  "RDP": {
+    "code": [
+      {
+        "dport": "3389",
+        "proto": "tcp"
+      }
+    ],
+    "desc": "Microsoft Remote Desktop Protocol traffic"
+  },
+  "RIP": {
+    "code": [
+      {
+        "dport": "520",
+        "proto": "udp"
+      }
+    ],
+    "desc": "Routing Information Protocol (bidirectional)"
+  },
+  "RNDC": {
+    "code": [
+      {
+        "dport": "953",
+        "proto": "tcp"
+      }
+    ],
+    "desc": "BIND remote management protocol"
+  },
+  "Razor": {
+    "code": [
+      {
+        "dport": "2703",
+        "proto": "tcp"
+      }
+    ],
+    "desc": "Razor Antispam System"
+  },
+  "Rdate": {
+    "code": [
+      {
+        "dport": "37",
+        "proto": "tcp"
+      }
+    ],
+    "desc": "Remote time retrieval (rdate)"
+  },
+  "Rsync": {
+    "code": [
+      {
+        "dport": "873",
+        "proto": "tcp"
+      }
+    ],
+    "desc": "Rsync server"
+  },
+  "SANE": {
+    "code": [
+      {
+        "dport": "6566",
+        "proto": "tcp"
+      }
+    ],
+    "desc": "SANE network scanning"
+  },
+  "SMB": {
+    "code": [
+      {
+        "dport": "135,445",
+        "proto": "udp"
+      },
+      {
+        "dport": "137:139",
+        "proto": "udp"
+      },
+      {
+        "dport": "1024:65535",
+        "proto": "udp",
+        "sport": "137"
+      },
+      {
+        "dport": "135,139,445",
+        "proto": "tcp"
+      }
+    ],
+    "desc": "Microsoft SMB traffic"
+  },
+  "SMBswat": {
+    "code": [
+      {
+        "dport": "901",
+        "proto": "tcp"
+      }
+    ],
+    "desc": "Samba Web Administration Tool"
+  },
+  "SMTP": {
+    "code": [
+      {
+        "dport": "25",
+        "proto": "tcp"
+      }
+    ],
+    "desc": "Simple Mail Transfer Protocol"
+  },
+  "SMTPS": {
+    "code": [
+      {
+        "dport": "465",
+        "proto": "tcp"
+      }
+    ],
+    "desc": "Encrypted Simple Mail Transfer Protocol"
+  },
+  "SNMP": {
+    "code": [
+      {
+        "dport": "161:162",
+        "proto": "udp"
+      },
+      {
+        "dport": "161",
+        "proto": "tcp"
+      }
+    ],
+    "desc": "Simple Network Management Protocol"
+  },
+  "SPAMD": {
+    "code": [
+      {
+        "dport": "783",
+        "proto": "tcp"
+      }
+    ],
+    "desc": "Spam Assassin SPAMD traffic"
+  },
+  "SSH": {
+    "code": [
+      {
+        "dport": "22",
+        "proto": "tcp"
+      }
+    ],
+    "desc": "Secure shell traffic"
+  },
+  "SVN": {
+    "code": [
+      {
+        "dport": "3690",
+        "proto": "tcp"
+      }
+    ],
+    "desc": "Subversion server (svnserve)"
+  },
+  "SixXS": {
+    "code": [
+      {
+        "dport": "3874",
+        "proto": "tcp"
+      },
+      {
+        "dport": "3740",
+        "proto": "udp"
+      },
+      {
+        "proto": "41"
+      },
+      {
+        "dport": "5072,8374",
+        "proto": "udp"
+      }
+    ],
+    "desc": "SixXS IPv6 Deployment and Tunnel Broker"
+  },
+  "Squid": {
+    "code": [
+      {
+        "dport": "3128",
+        "proto": "tcp"
+      }
+    ],
+    "desc": "Squid web proxy traffic"
+  },
+  "Submission": {
+    "code": [
+      {
+        "dport": "587",
+        "proto": "tcp"
+      }
+    ],
+    "desc": "Mail message submission traffic"
+  },
+  "Syslog": {
+    "code": [
+      {
+        "dport": "514",
+        "proto": "udp"
+      },
+      {
+        "dport": "514",
+        "proto": "tcp"
+      }
+    ],
+    "desc": "Syslog protocol (RFC 5424) traffic"
+  },
+  "TFTP": {
+    "code": [
+      {
+        "dport": "69",
+        "proto": "udp"
+      }
+    ],
+    "desc": "Trivial File Transfer Protocol traffic"
+  },
+  "Telnet": {
+    "code": [
+      {
+        "dport": "23",
+        "proto": "tcp"
+      }
+    ],
+    "desc": "Telnet traffic"
+  },
+  "Telnets": {
+    "code": [
+      {
+        "dport": "992",
+        "proto": "tcp"
+      }
+    ],
+    "desc": "Telnet over SSL"
+  },
+  "Time": {
+    "code": [
+      {
+        "dport": "37",
+        "proto": "tcp"
+      }
+    ],
+    "desc": "RFC 868 Time protocol"
+  },
+  "Trcrt": {
+    "code": [
+      {
+        "dport": "33434:33524",
+        "proto": "udp"
+      },
+      {
+        "dport": "echo-request",
+        "proto": "icmp"
+      }
+    ],
+    "desc": "Traceroute (for up to 30 hops) traffic"
+  },
+  "VNC": {
+    "code": [
+      {
+        "dport": "5900:5999",
+        "proto": "tcp"
+      }
+    ],
+    "desc": "VNC traffic for VNC display's 0 - 99"
+  },
+  "VNCL": {
+    "code": [
+      {
+        "dport": "5500",
+        "proto": "tcp"
+      }
+    ],
+    "desc": "VNC traffic from Vncservers to Vncviewers in listen mode"
+  },
+  "Web": {
+    "code": [
+      {
+        "dport": "80",
+        "proto": "tcp"
+      },
+      {
+        "dport": "443",
+        "proto": "tcp"
+      }
+    ],
+    "desc": "WWW traffic (HTTP and HTTPS)"
+  },
+  "Webcache": {
+    "code": [
+      {
+        "dport": "8080",
+        "proto": "tcp"
+      }
+    ],
+    "desc": "Web Cache/Proxy traffic (port 8080)"
+  },
+  "Webmin": {
+    "code": [
+      {
+        "dport": "10000",
+        "proto": "tcp"
+      }
+    ],
+    "desc": "Webmin traffic"
+  },
+  "Whois": {
+    "code": [
+      {
+        "dport": "43",
+        "proto": "tcp"
+      }
+    ],
+    "desc": "Whois (nicname, RFC 3912) traffic"
+  }
+}
diff --git a/proxmox-ve-config/src/firewall/fw_macros.rs b/proxmox-ve-config/src/firewall/fw_macros.rs
new file mode 100644
index 0000000..5fa8dab
--- /dev/null
+++ b/proxmox-ve-config/src/firewall/fw_macros.rs
@@ -0,0 +1,69 @@
+use std::collections::HashMap;
+
+use serde::Deserialize;
+use std::sync::OnceLock;
+
+use crate::firewall::types::rule_match::Protocol;
+
+use super::types::rule_match::RuleOptions;
+
+#[derive(Clone, Debug, Default, Deserialize)]
+struct FwMacroData {
+    #[serde(rename = "desc")]
+    pub description: &'static str,
+    pub code: Vec<RuleOptions>,
+}
+
+#[derive(Clone, Debug, Default)]
+pub struct FwMacro {
+    pub _description: &'static str,
+    pub code: Vec<Protocol>,
+}
+
+fn macros() -> &'static HashMap<String, FwMacro> {
+    const MACROS: &str = include_str!("../../resources/macros.json");
+    static HASHMAP: OnceLock<HashMap<String, FwMacro>> = OnceLock::new();
+
+    HASHMAP.get_or_init(|| {
+        let macro_data: HashMap<String, FwMacroData> = match serde_json::from_str(MACROS) {
+            Ok(m) => m,
+            Err(err) => {
+                log::error!("could not load data for macros: {err}");
+                HashMap::new()
+            }
+        };
+
+        let mut macros = HashMap::new();
+
+        'outer: for (name, data) in macro_data {
+            let mut code = Vec::new();
+
+            for c in data.code {
+                match Protocol::from_options(&c) {
+                    Ok(Some(p)) => code.push(p),
+                    Ok(None) => {
+                        continue 'outer;
+                    }
+                    Err(err) => {
+                        log::error!("could not parse data for macro {name}: {err}");
+                        continue 'outer;
+                    }
+                }
+            }
+
+            macros.insert(
+                name,
+                FwMacro {
+                    _description: data.description,
+                    code,
+                },
+            );
+        }
+
+        macros
+    })
+}
+
+pub fn get_macro(name: &str) -> Option<&'static FwMacro> {
+    macros().get(name)
+}
diff --git a/proxmox-ve-config/src/firewall/mod.rs b/proxmox-ve-config/src/firewall/mod.rs
index afc3dcc..0f438ca 100644
--- a/proxmox-ve-config/src/firewall/mod.rs
+++ b/proxmox-ve-config/src/firewall/mod.rs
@@ -1,5 +1,6 @@
 pub mod cluster;
 pub mod common;
+pub mod fw_macros;
 pub mod guest;
 pub mod host;
 pub mod ports;
-- 
2.39.2




^ permalink raw reply	[flat|nested] 67+ messages in thread

* [pve-devel] [PATCH proxmox-firewall 16/37] config: firewall: add conntrack helper types
  2024-04-02 17:15 [pve-devel] [RFC container/firewall/manager/proxmox-firewall/qemu-server 00/37] proxmox firewall nftables implementation Stefan Hanreich
                   ` (14 preceding siblings ...)
  2024-04-02 17:16 ` [pve-devel] [PATCH proxmox-firewall 15/37] config: firewall: add firewall macros Stefan Hanreich
@ 2024-04-02 17:16 ` Stefan Hanreich
  2024-04-02 17:16 ` [pve-devel] [PATCH proxmox-firewall 17/37] nftables: add crate for libnftables bindings Stefan Hanreich
                   ` (25 subsequent siblings)
  41 siblings, 0 replies; 67+ messages in thread
From: Stefan Hanreich @ 2024-04-02 17:16 UTC (permalink / raw)
  To: pve-devel; +Cc: Stefan Hanreich, Wolfgang Bumiller

Co-authored-by: Wolfgang Bumiller <w.bumiller@proxmox.com>
Signed-off-by: Stefan Hanreich <s.hanreich@proxmox.com>
---
 proxmox-ve-config/resources/ct_helper.json  |  52 +++++++++
 proxmox-ve-config/src/firewall/ct_helper.rs | 115 ++++++++++++++++++++
 proxmox-ve-config/src/firewall/mod.rs       |   1 +
 3 files changed, 168 insertions(+)
 create mode 100644 proxmox-ve-config/resources/ct_helper.json
 create mode 100644 proxmox-ve-config/src/firewall/ct_helper.rs

diff --git a/proxmox-ve-config/resources/ct_helper.json b/proxmox-ve-config/resources/ct_helper.json
new file mode 100644
index 0000000..5e70a3a
--- /dev/null
+++ b/proxmox-ve-config/resources/ct_helper.json
@@ -0,0 +1,52 @@
+[
+  {
+    "name": "amanda",
+    "v4": true,
+    "v6": true,
+    "udp": 10080
+  },
+  {
+    "name": "ftp",
+    "v4": true,
+    "v6": true,
+    "tcp": 21
+  } ,
+  {
+    "name": "irc",
+    "v4": true,
+    "tcp": 6667
+  },
+  {
+    "name": "netbios-ns",
+    "v4": true,
+    "udp": 137
+  },
+  {
+    "name": "pptp",
+    "v4": true,
+    "tcp": 1723
+  },
+  {
+    "name": "sane",
+    "v4": true,
+    "v6": true,
+    "tcp": 6566
+  },
+  {
+    "name": "sip",
+    "v4": true,
+    "v6": true,
+    "udp": 5060
+  },
+  {
+    "name": "snmp",
+    "v4": true,
+    "udp": 161
+  },
+  {
+    "name": "tftp",
+    "v4": true,
+    "v6": true,
+    "udp": 69
+  }
+]
diff --git a/proxmox-ve-config/src/firewall/ct_helper.rs b/proxmox-ve-config/src/firewall/ct_helper.rs
new file mode 100644
index 0000000..40e4fee
--- /dev/null
+++ b/proxmox-ve-config/src/firewall/ct_helper.rs
@@ -0,0 +1,115 @@
+use anyhow::{bail, Error};
+use serde::Deserialize;
+use std::collections::HashMap;
+use std::sync::OnceLock;
+
+use crate::firewall::types::address::Family;
+use crate::firewall::types::rule_match::{Ports, Protocol, Tcp, Udp};
+
+#[derive(Clone, Debug, Deserialize)]
+pub struct CtHelperMacroJson {
+    pub v4: Option<bool>,
+    pub v6: Option<bool>,
+    pub name: String,
+    pub tcp: Option<u16>,
+    pub udp: Option<u16>,
+}
+
+impl TryFrom<CtHelperMacroJson> for CtHelperMacro {
+    type Error = Error;
+
+    fn try_from(value: CtHelperMacroJson) -> Result<Self, Self::Error> {
+        if value.tcp.is_none() && value.udp.is_none() {
+            bail!("Neither TCP nor UDP port set in CT helper!");
+        }
+
+        let family = match (value.v4, value.v6) {
+            (Some(true), Some(true)) => None,
+            (Some(true), _) => Some(Family::V4),
+            (_, Some(true)) => Some(Family::V6),
+            _ => bail!("Neither v4 nor v6 set in CT Helper Macro!"),
+        };
+
+        let mut ct_helper = CtHelperMacro {
+            family,
+            name: value.name,
+            tcp: None,
+            udp: None,
+        };
+
+        if let Some(dport) = value.tcp {
+            let ports = Ports::from_u16(None, dport);
+            ct_helper.tcp = Some(Tcp::new(ports).into());
+        }
+
+        if let Some(dport) = value.udp {
+            let ports = Ports::from_u16(None, dport);
+            ct_helper.udp = Some(Udp::new(ports).into());
+        }
+
+        Ok(ct_helper)
+    }
+}
+
+#[derive(Clone, Debug, Deserialize)]
+#[serde(try_from = "CtHelperMacroJson")]
+pub struct CtHelperMacro {
+    family: Option<Family>,
+    name: String,
+    tcp: Option<Protocol>,
+    udp: Option<Protocol>,
+}
+
+impl CtHelperMacro {
+    fn helper_name(&self, protocol: &str) -> String {
+        format!("helper-{}-{protocol}", self.name)
+    }
+
+    pub fn tcp_helper_name(&self) -> String {
+        self.helper_name("tcp")
+    }
+
+    pub fn udp_helper_name(&self) -> String {
+        self.helper_name("udp")
+    }
+
+    pub fn family(&self) -> Option<Family> {
+        self.family
+    }
+
+    pub fn name(&self) -> &str {
+        self.name.as_ref()
+    }
+
+    pub fn tcp(&self) -> Option<&Protocol> {
+        self.tcp.as_ref()
+    }
+
+    pub fn udp(&self) -> Option<&Protocol> {
+        self.udp.as_ref()
+    }
+}
+
+fn hashmap() -> &'static HashMap<String, CtHelperMacro> {
+    const MACROS: &str = include_str!("../../resources/ct_helper.json");
+    static HASHMAP: OnceLock<HashMap<String, CtHelperMacro>> = OnceLock::new();
+
+    HASHMAP.get_or_init(|| {
+        let macro_data: Vec<CtHelperMacro> = match serde_json::from_str(MACROS) {
+            Ok(data) => data,
+            Err(err) => {
+                log::error!("could not load data for ct helpers: {err}");
+                Vec::new()
+            }
+        };
+
+        macro_data
+            .into_iter()
+            .map(|elem| (elem.name.clone(), elem))
+            .collect()
+    })
+}
+
+pub fn get_cthelper(name: &str) -> Option<&'static CtHelperMacro> {
+    hashmap().get(name)
+}
diff --git a/proxmox-ve-config/src/firewall/mod.rs b/proxmox-ve-config/src/firewall/mod.rs
index 0f438ca..2cf57e2 100644
--- a/proxmox-ve-config/src/firewall/mod.rs
+++ b/proxmox-ve-config/src/firewall/mod.rs
@@ -1,5 +1,6 @@
 pub mod cluster;
 pub mod common;
+pub mod ct_helper;
 pub mod fw_macros;
 pub mod guest;
 pub mod host;
-- 
2.39.2




^ permalink raw reply	[flat|nested] 67+ messages in thread

* [pve-devel] [PATCH proxmox-firewall 17/37] nftables: add crate for libnftables bindings
  2024-04-02 17:15 [pve-devel] [RFC container/firewall/manager/proxmox-firewall/qemu-server 00/37] proxmox firewall nftables implementation Stefan Hanreich
                   ` (15 preceding siblings ...)
  2024-04-02 17:16 ` [pve-devel] [PATCH proxmox-firewall 16/37] config: firewall: add conntrack helper types Stefan Hanreich
@ 2024-04-02 17:16 ` Stefan Hanreich
  2024-04-02 17:16 ` [pve-devel] [PATCH proxmox-firewall 18/37] nftables: add helpers Stefan Hanreich
                   ` (24 subsequent siblings)
  41 siblings, 0 replies; 67+ messages in thread
From: Stefan Hanreich @ 2024-04-02 17:16 UTC (permalink / raw)
  To: pve-devel; +Cc: Stefan Hanreich, Wolfgang Bumiller

Co-authored-by: Wolfgang Bumiller <w.bumiller@proxmox.com>
Signed-off-by: Stefan Hanreich <s.hanreich@proxmox.com>
---
 Cargo.toml                  |  1 +
 proxmox-nftables/Cargo.toml | 16 ++++++++++++++++
 proxmox-nftables/src/lib.rs |  0
 3 files changed, 17 insertions(+)
 create mode 100644 proxmox-nftables/Cargo.toml
 create mode 100644 proxmox-nftables/src/lib.rs

diff --git a/Cargo.toml b/Cargo.toml
index a8d33ab..877f103 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -1,4 +1,5 @@
 [workspace]
 members = [
     "proxmox-ve-config",
+    "proxmox-nftables",
 ]
diff --git a/proxmox-nftables/Cargo.toml b/proxmox-nftables/Cargo.toml
new file mode 100644
index 0000000..764e231
--- /dev/null
+++ b/proxmox-nftables/Cargo.toml
@@ -0,0 +1,16 @@
+[package]
+name = "proxmox-nftables"
+version = "0.1.0"
+edition = "2021"
+authors = [
+    "Wolfgang Bumiller <w.bumiller@proxmox.com>",
+    "Stefan Hanreich <s.hanreich@proxmox.com>",
+    "Proxmox Support Team <support@proxmox.com>",
+]
+description = "Proxmox VE nftables"
+license = "AGPL-3"
+
+[dependencies]
+log = "0.4"
+
+proxmox-ve-config = { path = "../proxmox-ve-config", optional = true }
diff --git a/proxmox-nftables/src/lib.rs b/proxmox-nftables/src/lib.rs
new file mode 100644
index 0000000..e69de29
-- 
2.39.2




^ permalink raw reply	[flat|nested] 67+ messages in thread

* [pve-devel] [PATCH proxmox-firewall 18/37] nftables: add helpers
  2024-04-02 17:15 [pve-devel] [RFC container/firewall/manager/proxmox-firewall/qemu-server 00/37] proxmox firewall nftables implementation Stefan Hanreich
                   ` (16 preceding siblings ...)
  2024-04-02 17:16 ` [pve-devel] [PATCH proxmox-firewall 17/37] nftables: add crate for libnftables bindings Stefan Hanreich
@ 2024-04-02 17:16 ` Stefan Hanreich
  2024-04-02 17:16 ` [pve-devel] [PATCH proxmox-firewall 19/37] nftables: expression: add types Stefan Hanreich
                   ` (23 subsequent siblings)
  41 siblings, 0 replies; 67+ messages in thread
From: Stefan Hanreich @ 2024-04-02 17:16 UTC (permalink / raw)
  To: pve-devel; +Cc: Stefan Hanreich, Wolfgang Bumiller

Several objects, statements and expressions in nftables-json require
null values, for instance:

    { "flush": { "ruleset": null }}

For this purpose we define our own Null type, which we can then easily
use for defining types that accept Null as value.

Several keys accept as value either a singular element (string or
object) if there is only one object, but an array if there are
multiple objects. For instance when adding a single element to a set:

           { "element": {
	       ...
	       "elem": "element1"
           }}

but when adding multiple elements:

           { "element": {
	       ...
	       "elem": ["element1", "element2"]
           }}

NfVec<T> is a wrapper for Vec<T> that serializes into T iff Vec
contains one element, otherwise it serializes like a Vec would
normally do.

Co-authored-by: Wolfgang Bumiller <w.bumiller@proxmox.com>
Signed-off-by: Stefan Hanreich <s.hanreich@proxmox.com>
---
 proxmox-nftables/Cargo.toml    |   4 +
 proxmox-nftables/src/helper.rs | 190 +++++++++++++++++++++++++++++++++
 proxmox-nftables/src/lib.rs    |   1 +
 3 files changed, 195 insertions(+)
 create mode 100644 proxmox-nftables/src/helper.rs

diff --git a/proxmox-nftables/Cargo.toml b/proxmox-nftables/Cargo.toml
index 764e231..ebece9d 100644
--- a/proxmox-nftables/Cargo.toml
+++ b/proxmox-nftables/Cargo.toml
@@ -13,4 +13,8 @@ license = "AGPL-3"
 [dependencies]
 log = "0.4"
 
+serde = { version = "1", features = [ "derive" ] }
+serde_json = "1"
+serde_plain = "1"
+
 proxmox-ve-config = { path = "../proxmox-ve-config", optional = true }
diff --git a/proxmox-nftables/src/helper.rs b/proxmox-nftables/src/helper.rs
new file mode 100644
index 0000000..77ce347
--- /dev/null
+++ b/proxmox-nftables/src/helper.rs
@@ -0,0 +1,190 @@
+use std::fmt;
+use std::marker::PhantomData;
+
+use serde::{Deserialize, Serialize};
+
+#[derive(Clone, Copy, Debug)]
+pub struct Null;
+
+impl<'de> Deserialize<'de> for Null {
+    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
+    where
+        D: serde::Deserializer<'de>,
+    {
+        use serde::de::Error;
+
+        match Option::<()>::deserialize(deserializer)? {
+            None => Ok(Self),
+            Some(_) => Err(D::Error::custom("expected null")),
+        }
+    }
+}
+
+impl Serialize for Null {
+    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
+    where
+        S: serde::Serializer,
+    {
+        serializer.serialize_none()
+    }
+}
+
+impl fmt::Display for Null {
+    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
+        f.write_str("null")
+    }
+}
+
+#[derive(Clone, Debug)]
+pub struct NfVec<T>(pub(crate) Vec<T>);
+
+impl<T> Default for NfVec<T> {
+    fn default() -> Self {
+        Self::new()
+    }
+}
+
+impl<T> NfVec<T> {
+    pub const fn new() -> Self {
+        Self(Vec::new())
+    }
+
+    pub fn one(value: T) -> Self {
+        Self(vec![value])
+    }
+}
+
+impl<T> From<Vec<T>> for NfVec<T> {
+    fn from(v: Vec<T>) -> Self {
+        Self(v)
+    }
+}
+
+impl<T> From<NfVec<T>> for Vec<T> {
+    fn from(v: NfVec<T>) -> Self {
+        v.0
+    }
+}
+
+impl<T> FromIterator<T> for NfVec<T> {
+    fn from_iter<I: IntoIterator<Item = T>>(iter: I) -> Self {
+        Self(iter.into_iter().collect())
+    }
+}
+
+impl<T> std::ops::Deref for NfVec<T> {
+    type Target = Vec<T>;
+
+    fn deref(&self) -> &Self::Target {
+        &self.0
+    }
+}
+
+impl<T> std::ops::DerefMut for NfVec<T> {
+    fn deref_mut(&mut self) -> &mut Self::Target {
+        &mut self.0
+    }
+}
+
+impl<T: Serialize> Serialize for NfVec<T> {
+    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
+    where
+        S: serde::Serializer,
+    {
+        if self.len() == 1 {
+            self[0].serialize(serializer)
+        } else {
+            self.0.serialize(serializer)
+        }
+    }
+}
+
+macro_rules! visit_value {
+    ($( ($visit:ident, $($ty:tt)+), )+) => {
+        $(
+            fn $visit<E>(self, value: $($ty)+) -> Result<Self::Value, E>
+            where
+                E: Error,
+            {
+                T::deserialize(value.into_deserializer()).map(NfVec::one)
+            }
+        )+
+    };
+}
+
+impl<'de, T: Deserialize<'de>> Deserialize<'de> for NfVec<T> {
+    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
+    where
+        D: serde::Deserializer<'de>,
+    {
+        use serde::de::{Error, IntoDeserializer};
+
+        struct V<T>(PhantomData<T>);
+
+        impl<'de, T: Deserialize<'de>> serde::de::Visitor<'de> for V<T> {
+            type Value = NfVec<T>;
+
+            fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result {
+                f.write_str("an array or single element")
+            }
+
+            fn visit_seq<A>(self, seq: A) -> Result<Self::Value, A::Error>
+            where
+                A: serde::de::SeqAccess<'de>,
+            {
+                Vec::<T>::deserialize(serde::de::value::SeqAccessDeserializer::new(seq)).map(NfVec)
+            }
+
+            fn visit_map<A>(self, map: A) -> Result<Self::Value, A::Error>
+            where
+                A: serde::de::MapAccess<'de>,
+            {
+                T::deserialize(serde::de::value::MapAccessDeserializer::new(map)).map(NfVec::one)
+            }
+
+            fn visit_none<E>(self) -> Result<Self::Value, E>
+            where
+                E: Error,
+            {
+                Ok(NfVec::new())
+            }
+
+            fn visit_unit<E>(self) -> Result<Self::Value, E>
+            where
+                E: Error,
+            {
+                Ok(NfVec::new())
+            }
+
+            fn visit_some<D>(self, deserializer: D) -> Result<Self::Value, D::Error>
+            where
+                D: serde::Deserializer<'de>,
+            {
+                deserializer.deserialize_any(self)
+            }
+
+            visit_value! {
+                (visit_bool, bool),
+                (visit_borrowed_bytes, &'de [u8]),
+                (visit_borrowed_str, &'de str),
+                (visit_byte_buf, Vec<u8>),
+                (visit_bytes, &[u8]),
+                (visit_char, char),
+                (visit_f32, f32),
+                (visit_f64, f64),
+                (visit_i8, i8),
+                (visit_i16, i16),
+                (visit_i32, i32),
+                (visit_i64, i64),
+                (visit_u8, u8),
+                (visit_u16, u16),
+                (visit_u32, u32),
+                (visit_u64, u64),
+                (visit_str, &str),
+                (visit_string, String),
+            }
+        }
+
+        deserializer.deserialize_any(V::<T>(PhantomData))
+    }
+}
diff --git a/proxmox-nftables/src/lib.rs b/proxmox-nftables/src/lib.rs
index e69de29..485bb81 100644
--- a/proxmox-nftables/src/lib.rs
+++ b/proxmox-nftables/src/lib.rs
@@ -0,0 +1 @@
+pub mod helper;
-- 
2.39.2




^ permalink raw reply	[flat|nested] 67+ messages in thread

* [pve-devel] [PATCH proxmox-firewall 19/37] nftables: expression: add types
  2024-04-02 17:15 [pve-devel] [RFC container/firewall/manager/proxmox-firewall/qemu-server 00/37] proxmox firewall nftables implementation Stefan Hanreich
                   ` (17 preceding siblings ...)
  2024-04-02 17:16 ` [pve-devel] [PATCH proxmox-firewall 18/37] nftables: add helpers Stefan Hanreich
@ 2024-04-02 17:16 ` Stefan Hanreich
  2024-04-02 17:16 ` [pve-devel] [PATCH proxmox-firewall 20/37] nftables: expression: implement conversion traits for firewall config Stefan Hanreich
                   ` (22 subsequent siblings)
  41 siblings, 0 replies; 67+ messages in thread
From: Stefan Hanreich @ 2024-04-02 17:16 UTC (permalink / raw)
  To: pve-devel; +Cc: Stefan Hanreich, Wolfgang Bumiller

Adds an enum containing most of the expressions defined in the
nftables-json schema [1].

[1] https://manpages.debian.org/bookworm/libnftables1/libnftables-json.5.en.html#EXPRESSIONS

Co-authored-by: Wolfgang Bumiller <w.bumiller@proxmox.com>
Signed-off-by: Stefan Hanreich <s.hanreich@proxmox.com>
---
 proxmox-nftables/Cargo.toml        |   2 +-
 proxmox-nftables/src/expression.rs | 268 +++++++++++++++++++++++++++++
 proxmox-nftables/src/lib.rs        |   4 +
 proxmox-nftables/src/types.rs      |  53 ++++++
 4 files changed, 326 insertions(+), 1 deletion(-)
 create mode 100644 proxmox-nftables/src/expression.rs
 create mode 100644 proxmox-nftables/src/types.rs

diff --git a/proxmox-nftables/Cargo.toml b/proxmox-nftables/Cargo.toml
index ebece9d..909869b 100644
--- a/proxmox-nftables/Cargo.toml
+++ b/proxmox-nftables/Cargo.toml
@@ -17,4 +17,4 @@ serde = { version = "1", features = [ "derive" ] }
 serde_json = "1"
 serde_plain = "1"
 
-proxmox-ve-config = { path = "../proxmox-ve-config", optional = true }
+proxmox-ve-config = { path = "../proxmox-ve-config" }
diff --git a/proxmox-nftables/src/expression.rs b/proxmox-nftables/src/expression.rs
new file mode 100644
index 0000000..da6e40f
--- /dev/null
+++ b/proxmox-nftables/src/expression.rs
@@ -0,0 +1,268 @@
+use crate::types::{ElemConfig, Verdict};
+use serde::{Deserialize, Serialize};
+use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
+
+use crate::helper::NfVec;
+
+#[derive(Clone, Debug, Deserialize, Serialize)]
+#[serde(rename_all = "lowercase")]
+pub enum Expression {
+    Concat(NfVec<Expression>),
+    Set(NfVec<Expression>),
+    Range(Box<(Expression, Expression)>),
+    Map(Box<Map>),
+    Prefix(Prefix),
+    Payload(Payload),
+    Meta(Meta),
+    Ct(Ct),
+    Elem(Box<Element>),
+
+    #[serde(rename = "|")]
+    Or(Box<(Expression, Expression)>),
+    #[serde(rename = "&")]
+    And(Box<(Expression, Expression)>),
+    #[serde(rename = "^")]
+    Xor(Box<(Expression, Expression)>),
+    #[serde(rename = "<<")]
+    ShiftLeft(Box<(Expression, Expression)>),
+    #[serde(rename = ">>")]
+    ShiftRight(Box<(Expression, Expression)>),
+
+    #[serde(untagged)]
+    List(Vec<Expression>),
+
+    #[serde(untagged)]
+    Verdict(Verdict),
+
+    #[serde(untagged)]
+    Bool(bool),
+    #[serde(untagged)]
+    Number(i64),
+    #[serde(untagged)]
+    String(String),
+}
+
+impl Expression {
+    pub fn set(expressions: impl IntoIterator<Item = Expression>) -> Self {
+        Expression::Set(NfVec::from_iter(expressions))
+    }
+
+    pub fn concat(expressions: impl IntoIterator<Item = Expression>) -> Self {
+        Expression::Concat(NfVec::from_iter(expressions))
+    }
+}
+
+impl From<bool> for Expression {
+    #[inline]
+    fn from(v: bool) -> Self {
+        Expression::Bool(v)
+    }
+}
+
+impl From<i64> for Expression {
+    #[inline]
+    fn from(v: i64) -> Self {
+        Expression::Number(v)
+    }
+}
+
+impl From<u16> for Expression {
+    #[inline]
+    fn from(v: u16) -> Self {
+        Expression::Number(v.into())
+    }
+}
+
+impl From<u8> for Expression {
+    #[inline]
+    fn from(v: u8) -> Self {
+        Expression::Number(v.into())
+    }
+}
+
+impl From<&str> for Expression {
+    #[inline]
+    fn from(v: &str) -> Self {
+        Expression::String(v.to_string())
+    }
+}
+
+impl From<String> for Expression {
+    #[inline]
+    fn from(v: String) -> Self {
+        Expression::String(v)
+    }
+}
+
+impl From<Meta> for Expression {
+    #[inline]
+    fn from(meta: Meta) -> Self {
+        Expression::Meta(meta)
+    }
+}
+
+impl From<Ct> for Expression {
+    #[inline]
+    fn from(ct: Ct) -> Self {
+        Expression::Ct(ct)
+    }
+}
+
+impl From<Payload> for Expression {
+    #[inline]
+    fn from(payload: Payload) -> Self {
+        Expression::Payload(payload)
+    }
+}
+
+impl From<Prefix> for Expression {
+    #[inline]
+    fn from(prefix: Prefix) -> Self {
+        Expression::Prefix(prefix)
+    }
+}
+
+impl From<Verdict> for Expression {
+    #[inline]
+    fn from(value: Verdict) -> Self {
+        Expression::Verdict(value)
+    }
+}
+
+impl From<&IpAddr> for Expression {
+    fn from(value: &IpAddr) -> Self {
+        Expression::String(value.to_string())
+    }
+}
+
+impl From<&Ipv6Addr> for Expression {
+    fn from(address: &Ipv6Addr) -> Self {
+        Expression::String(address.to_string())
+    }
+}
+
+impl From<&Ipv4Addr> for Expression {
+    fn from(address: &Ipv4Addr) -> Self {
+        Expression::String(address.to_string())
+    }
+}
+
+#[derive(Clone, Copy, Debug, Eq, PartialEq, Deserialize, Serialize)]
+#[serde(rename_all = "lowercase")]
+pub enum IpFamily {
+    Ip,
+    Ip6,
+}
+
+#[derive(Clone, Debug, Deserialize, Serialize)]
+pub struct Meta {
+    key: String,
+}
+
+impl Meta {
+    pub fn new(key: impl Into<String>) -> Self {
+        Self { key: key.into() }
+    }
+}
+
+#[derive(Clone, Debug, Deserialize, Serialize)]
+pub struct Map {
+    key: Expression,
+    data: Expression,
+}
+
+#[derive(Clone, Debug, Deserialize, Serialize)]
+pub struct Ct {
+    key: String,
+    #[serde(skip_serializing_if = "Option::is_none")]
+    family: Option<IpFamily>,
+    #[serde(skip_serializing_if = "Option::is_none")]
+    dir: Option<CtDirection>,
+}
+
+impl Ct {
+    pub fn new(key: impl Into<String>, family: impl Into<Option<IpFamily>>) -> Self {
+        Self {
+            key: key.into(),
+            family: family.into(),
+            dir: None,
+        }
+    }
+}
+
+#[derive(Clone, Copy, Debug, Eq, PartialEq, Deserialize, Serialize)]
+#[serde(rename_all = "lowercase")]
+pub enum CtDirection {
+    Original,
+    Reply,
+}
+serde_plain::derive_display_from_serialize!(CtDirection);
+#[derive(Clone, Debug, Deserialize, Serialize)]
+#[serde(untagged)]
+pub enum Payload {
+    Raw(PayloadRaw),
+    Field(PayloadField),
+}
+
+impl Payload {
+    pub fn field(protocol: impl Into<String>, field: impl Into<String>) -> Self {
+        Self::Field(PayloadField {
+            protocol: protocol.into(),
+            field: field.into(),
+        })
+    }
+}
+
+#[derive(Clone, Debug, Deserialize, Serialize)]
+pub enum PayloadBase {
+    #[serde(rename = "ll")]
+    Link,
+    #[serde(rename = "nh")]
+    Network,
+    #[serde(rename = "th")]
+    Transport,
+}
+
+#[derive(Clone, Debug, Deserialize, Serialize)]
+pub struct PayloadRaw {
+    base: PayloadBase,
+    offset: i64,
+    len: i64,
+}
+
+#[derive(Clone, Debug, Deserialize, Serialize)]
+pub struct PayloadField {
+    protocol: String,
+    field: String,
+}
+
+impl PayloadField {
+    pub fn protocol_for_ip_family(family: IpFamily) -> String {
+        match family {
+            IpFamily::Ip => "ip".to_string(),
+            IpFamily::Ip6 => "ip6".to_string(),
+        }
+    }
+}
+
+#[derive(Clone, Debug, Deserialize, Serialize)]
+pub struct Prefix {
+    addr: Box<Expression>,
+    len: u8,
+}
+
+impl Prefix {
+    pub fn new(addr: impl Into<Expression>, len: u8) -> Self {
+        Self {
+            addr: Box::new(addr.into()),
+            len,
+        }
+    }
+}
+
+#[derive(Clone, Debug, Deserialize, Serialize)]
+pub struct Element {
+    #[serde(flatten)]
+    config: ElemConfig,
+    val: Expression,
+}
diff --git a/proxmox-nftables/src/lib.rs b/proxmox-nftables/src/lib.rs
index 485bb81..712858b 100644
--- a/proxmox-nftables/src/lib.rs
+++ b/proxmox-nftables/src/lib.rs
@@ -1 +1,5 @@
+pub mod expression;
 pub mod helper;
+pub mod types;
+
+pub use expression::Expression;
diff --git a/proxmox-nftables/src/types.rs b/proxmox-nftables/src/types.rs
new file mode 100644
index 0000000..942c866
--- /dev/null
+++ b/proxmox-nftables/src/types.rs
@@ -0,0 +1,53 @@
+use std::fmt::Display;
+
+use serde::{Deserialize, Serialize};
+
+use crate::helper::Null;
+
+#[derive(Clone, Debug, Deserialize, Serialize)]
+#[serde(rename_all = "snake_case")]
+pub enum Verdict {
+    Accept(Null),
+    Drop(Null),
+    Continue(Null),
+    Return(Null),
+    Goto { target: String },
+    Jump { target: String },
+}
+
+impl Display for Verdict {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        let output = match self {
+            Verdict::Accept(_) => "ACCEPT",
+            Verdict::Drop(_) => "DROP",
+            Verdict::Continue(_) => "CONTINUE",
+            Verdict::Return(_) => "RETURN",
+            Verdict::Jump { .. } => "JUMP",
+            Verdict::Goto { .. } => "GOTO",
+        };
+
+        f.write_str(output)
+    }
+}
+
+#[derive(Clone, Debug, Deserialize, Serialize)]
+pub struct ElemConfig {
+    timeout: Option<i64>,
+    expires: Option<i64>,
+    comment: Option<String>,
+}
+
+impl ElemConfig {
+    pub fn new(
+        timeout: impl Into<Option<i64>>,
+        expires: impl Into<Option<i64>>,
+        comment: impl Into<Option<String>>,
+    ) -> Self {
+        Self {
+            timeout: timeout.into(),
+            expires: expires.into(),
+            comment: comment.into(),
+        }
+    }
+}
+
-- 
2.39.2




^ permalink raw reply	[flat|nested] 67+ messages in thread

* [pve-devel] [PATCH proxmox-firewall 20/37] nftables: expression: implement conversion traits for firewall config
  2024-04-02 17:15 [pve-devel] [RFC container/firewall/manager/proxmox-firewall/qemu-server 00/37] proxmox firewall nftables implementation Stefan Hanreich
                   ` (18 preceding siblings ...)
  2024-04-02 17:16 ` [pve-devel] [PATCH proxmox-firewall 19/37] nftables: expression: add types Stefan Hanreich
@ 2024-04-02 17:16 ` Stefan Hanreich
  2024-04-02 17:16 ` [pve-devel] [PATCH proxmox-firewall 21/37] nftables: statement: add types Stefan Hanreich
                   ` (21 subsequent siblings)
  41 siblings, 0 replies; 67+ messages in thread
From: Stefan Hanreich @ 2024-04-02 17:16 UTC (permalink / raw)
  To: pve-devel; +Cc: Stefan Hanreich, Wolfgang Bumiller

Some types from the firewall configuration map directly onto nftables
expressions. For those we implement conversion traits so we can
conveniently convert between the configuration types and the
respective nftables types.

Those are guarded behind a feature so the nftables crate can be used
standalone without having to pull in the proxmox-ve-config crate.

Co-authored-by: Wolfgang Bumiller <w.bumiller@proxmox.com>
Signed-off-by: Stefan Hanreich <s.hanreich@proxmox.com>
---
 proxmox-nftables/Cargo.toml        |   5 +-
 proxmox-nftables/src/expression.rs | 124 +++++++++++++++++++++++++++--
 2 files changed, 123 insertions(+), 6 deletions(-)

diff --git a/proxmox-nftables/Cargo.toml b/proxmox-nftables/Cargo.toml
index 909869b..7e607e8 100644
--- a/proxmox-nftables/Cargo.toml
+++ b/proxmox-nftables/Cargo.toml
@@ -10,6 +10,9 @@ authors = [
 description = "Proxmox VE nftables"
 license = "AGPL-3"
 
+[features]
+config-ext = ["dep:proxmox-ve-config"]
+
 [dependencies]
 log = "0.4"
 
@@ -17,4 +20,4 @@ serde = { version = "1", features = [ "derive" ] }
 serde_json = "1"
 serde_plain = "1"
 
-proxmox-ve-config = { path = "../proxmox-ve-config" }
+proxmox-ve-config = { path = "../proxmox-ve-config", optional = true }
diff --git a/proxmox-nftables/src/expression.rs b/proxmox-nftables/src/expression.rs
index da6e40f..067eccc 100644
--- a/proxmox-nftables/src/expression.rs
+++ b/proxmox-nftables/src/expression.rs
@@ -4,6 +4,15 @@ use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
 
 use crate::helper::NfVec;
 
+#[cfg(feature = "config-ext")]
+use proxmox_ve_config::firewall::types::address::{Family, IpEntry, IpList};
+#[cfg(feature = "config-ext")]
+use proxmox_ve_config::firewall::types::port::{PortEntry, PortList};
+#[cfg(feature = "config-ext")]
+use proxmox_ve_config::firewall::types::rule_match::{IcmpCode, IcmpType, Icmpv6Code, Icmpv6Type};
+#[cfg(feature = "config-ext")]
+use proxmox_ve_config::firewall::types::Cidr;
+
 #[derive(Clone, Debug, Deserialize, Serialize)]
 #[serde(rename_all = "lowercase")]
 pub enum Expression {
@@ -147,11 +156,88 @@ impl From<&Ipv4Addr> for Expression {
     }
 }
 
-#[derive(Clone, Copy, Debug, Eq, PartialEq, Deserialize, Serialize)]
-#[serde(rename_all = "lowercase")]
-pub enum IpFamily {
-    Ip,
-    Ip6,
+#[cfg(feature = "config-ext")]
+impl From<&IpList> for Expression {
+    fn from(value: &IpList) -> Self {
+        if value.len() == 1 {
+            return Expression::from(value.first().unwrap());
+        }
+
+        Expression::set(value.iter().map(Expression::from))
+    }
+}
+
+#[cfg(feature = "config-ext")]
+impl From<&IpEntry> for Expression {
+    fn from(value: &IpEntry) -> Self {
+        match value {
+            IpEntry::Cidr(cidr) => Expression::from(Prefix::from(cidr)),
+            IpEntry::Range(beg, end) => Expression::Range(Box::new((beg.into(), end.into()))),
+        }
+    }
+}
+
+#[cfg(feature = "config-ext")]
+impl From<&IcmpType> for Expression {
+    fn from(value: &IcmpType) -> Self {
+        match value {
+            IcmpType::Numeric(id) => Expression::from(*id),
+            IcmpType::Named(name) => Expression::from(*name),
+        }
+    }
+}
+
+#[cfg(feature = "config-ext")]
+impl From<&IcmpCode> for Expression {
+    fn from(value: &IcmpCode) -> Self {
+        match value {
+            IcmpCode::Numeric(id) => Expression::from(*id),
+            IcmpCode::Named(name) => Expression::from(*name),
+        }
+    }
+}
+
+#[cfg(feature = "config-ext")]
+impl From<&Icmpv6Type> for Expression {
+    fn from(value: &Icmpv6Type) -> Self {
+        match value {
+            Icmpv6Type::Numeric(id) => Expression::from(*id),
+            Icmpv6Type::Named(name) => Expression::from(*name),
+        }
+    }
+}
+
+#[cfg(feature = "config-ext")]
+impl From<&Icmpv6Code> for Expression {
+    fn from(value: &Icmpv6Code) -> Self {
+        match value {
+            Icmpv6Code::Numeric(id) => Expression::from(*id),
+            Icmpv6Code::Named(name) => Expression::from(*name),
+        }
+    }
+}
+
+#[cfg(feature = "config-ext")]
+impl From<&PortEntry> for Expression {
+    fn from(value: &PortEntry) -> Self {
+        match value {
+            PortEntry::Port(port) => Expression::from(*port),
+            PortEntry::Range(beg, end) => {
+                Expression::Range(Box::new(((*beg).into(), (*end).into())))
+            }
+        }
+    }
+}
+
+#[cfg(feature = "config-ext")]
+impl From<&PortList> for Expression {
+    fn from(value: &PortList) -> Self {
+        if value.len() == 1 {
+            return Expression::from(value.first().unwrap());
+        }
+
+        Expression::set(value.iter().map(Expression::from))
+    }
 }
 
 #[derive(Clone, Debug, Deserialize, Serialize)]
@@ -197,6 +283,24 @@ pub enum CtDirection {
     Reply,
 }
 serde_plain::derive_display_from_serialize!(CtDirection);
+
+#[derive(Clone, Copy, Debug, Eq, PartialEq, Deserialize, Serialize)]
+#[serde(rename_all = "lowercase")]
+pub enum IpFamily {
+    Ip,
+    Ip6,
+}
+
+#[cfg(feature = "config-ext")]
+impl From<Family> for IpFamily {
+    fn from(value: Family) -> Self {
+        match value {
+            Family::V4 => IpFamily::Ip,
+            Family::V6 => IpFamily::Ip6,
+        }
+    }
+}
+
 #[derive(Clone, Debug, Deserialize, Serialize)]
 #[serde(untagged)]
 pub enum Payload {
@@ -260,6 +364,16 @@ impl Prefix {
     }
 }
 
+#[cfg(feature = "config-ext")]
+impl From<&Cidr> for Prefix {
+    fn from(value: &Cidr) -> Self {
+        match value {
+            Cidr::Ipv4(cidr) => Self::new(cidr.address(), cidr.mask()),
+            Cidr::Ipv6(cidr) => Self::new(cidr.address(), cidr.mask()),
+        }
+    }
+}
+
 #[derive(Clone, Debug, Deserialize, Serialize)]
 pub struct Element {
     #[serde(flatten)]
-- 
2.39.2




^ permalink raw reply	[flat|nested] 67+ messages in thread

* [pve-devel] [PATCH proxmox-firewall 21/37] nftables: statement: add types
  2024-04-02 17:15 [pve-devel] [RFC container/firewall/manager/proxmox-firewall/qemu-server 00/37] proxmox firewall nftables implementation Stefan Hanreich
                   ` (19 preceding siblings ...)
  2024-04-02 17:16 ` [pve-devel] [PATCH proxmox-firewall 20/37] nftables: expression: implement conversion traits for firewall config Stefan Hanreich
@ 2024-04-02 17:16 ` Stefan Hanreich
  2024-04-03 10:47   ` Max Carrara
  2024-04-02 17:16 ` [pve-devel] [PATCH proxmox-firewall 22/37] nftables: statement: add conversion traits for config types Stefan Hanreich
                   ` (20 subsequent siblings)
  41 siblings, 1 reply; 67+ messages in thread
From: Stefan Hanreich @ 2024-04-02 17:16 UTC (permalink / raw)
  To: pve-devel; +Cc: Stefan Hanreich, Wolfgang Bumiller

Adds an enum containing most of the statements defined in the
nftables-json schema [1].

[1] https://manpages.debian.org/bookworm/libnftables1/libnftables-json.5.en.html#STATEMENTS

Co-authored-by: Wolfgang Bumiller <w.bumiller@proxmox.com>
Signed-off-by: Stefan Hanreich <s.hanreich@proxmox.com>
---
 proxmox-nftables/Cargo.toml       |   1 +
 proxmox-nftables/src/lib.rs       |   2 +
 proxmox-nftables/src/statement.rs | 321 ++++++++++++++++++++++++++++++
 proxmox-nftables/src/types.rs     |  17 ++
 4 files changed, 341 insertions(+)
 create mode 100644 proxmox-nftables/src/statement.rs

diff --git a/proxmox-nftables/Cargo.toml b/proxmox-nftables/Cargo.toml
index 7e607e8..153716d 100644
--- a/proxmox-nftables/Cargo.toml
+++ b/proxmox-nftables/Cargo.toml
@@ -15,6 +15,7 @@ config-ext = ["dep:proxmox-ve-config"]
 
 [dependencies]
 log = "0.4"
+anyhow = "1"
 
 serde = { version = "1", features = [ "derive" ] }
 serde_json = "1"
diff --git a/proxmox-nftables/src/lib.rs b/proxmox-nftables/src/lib.rs
index 712858b..40f6bab 100644
--- a/proxmox-nftables/src/lib.rs
+++ b/proxmox-nftables/src/lib.rs
@@ -1,5 +1,7 @@
 pub mod expression;
 pub mod helper;
+pub mod statement;
 pub mod types;
 
 pub use expression::Expression;
+pub use statement::Statement;
diff --git a/proxmox-nftables/src/statement.rs b/proxmox-nftables/src/statement.rs
new file mode 100644
index 0000000..e569f33
--- /dev/null
+++ b/proxmox-nftables/src/statement.rs
@@ -0,0 +1,321 @@
+use anyhow::{bail, Error};
+use serde::{Deserialize, Serialize};
+
+use crate::expression::Meta;
+use crate::helper::{NfVec, Null};
+use crate::types::{RateTimescale, RateUnit, Verdict};
+use crate::Expression;
+
+#[derive(Clone, Debug, Deserialize, Serialize)]
+#[serde(rename_all = "lowercase")]
+pub enum Statement {
+    Match(Match),
+    Mangle(Mangle),
+    Limit(Limit),
+    Notrack(Null),
+    Reject(Reject),
+    Set(Set),
+    Log(Log),
+    #[serde(rename = "ct helper")]
+    CtHelper(String),
+    Vmap(Vmap),
+    Comment(String),
+
+    #[serde(untagged)]
+    Verdict(Verdict),
+}
+
+impl Statement {
+    pub const fn make_accept() -> Self {
+        Statement::Verdict(Verdict::Accept(Null))
+    }
+
+    pub const fn make_drop() -> Self {
+        Statement::Verdict(Verdict::Drop(Null))
+    }
+
+    pub const fn make_return() -> Self {
+        Statement::Verdict(Verdict::Return(Null))
+    }
+
+    pub const fn make_continue() -> Self {
+        Statement::Verdict(Verdict::Continue(Null))
+    }
+
+    pub fn jump(target: impl Into<String>) -> Self {
+        Statement::Verdict(Verdict::Jump {
+            target: target.into(),
+        })
+    }
+
+    pub fn goto(target: impl Into<String>) -> Self {
+        Statement::Verdict(Verdict::Goto {
+            target: target.into(),
+        })
+    }
+}
+
+impl From<Match> for Statement {
+    #[inline]
+    fn from(m: Match) -> Statement {
+        Statement::Match(m)
+    }
+}
+
+impl From<Mangle> for Statement {
+    #[inline]
+    fn from(m: Mangle) -> Statement {
+        Statement::Mangle(m)
+    }
+}
+
+impl From<Reject> for Statement {
+    #[inline]
+    fn from(m: Reject) -> Statement {
+        Statement::Reject(m)
+    }
+}
+
+impl From<Set> for Statement {
+    #[inline]
+    fn from(m: Set) -> Statement {
+        Statement::Set(m)
+    }
+}
+
+impl From<Vmap> for Statement {
+    #[inline]
+    fn from(m: Vmap) -> Statement {
+        Statement::Vmap(m)
+    }
+}
+
+impl From<Log> for Statement {
+    #[inline]
+    fn from(log: Log) -> Statement {
+        Statement::Log(log)
+    }
+}
+
+impl<T: Into<Limit>> From<T> for Statement {
+    #[inline]
+    fn from(limit: T) -> Statement {
+        Statement::Limit(limit.into())
+    }
+}
+
+#[derive(Clone, Debug, Deserialize, Serialize)]
+#[serde(rename_all = "lowercase")]
+pub enum RejectType {
+    #[serde(rename = "tcp reset")]
+    TcpRst,
+    IcmpX,
+    Icmp,
+    IcmpV6,
+}
+
+#[derive(Clone, Debug, Default, Deserialize, Serialize)]
+pub struct Reject {
+    #[serde(rename = "type", skip_serializing_if = "Option::is_none")]
+    ty: Option<RejectType>,
+    #[serde(skip_serializing_if = "Option::is_none")]
+    expr: Option<Expression>,
+}
+
+#[derive(Clone, Debug, Default, Deserialize, Serialize)]
+#[serde(rename_all = "kebab-case")]
+pub struct Log {
+    #[serde(skip_serializing_if = "Option::is_none")]
+    prefix: Option<String>,
+
+    #[serde(skip_serializing_if = "Option::is_none")]
+    group: Option<i64>,
+
+    #[serde(skip_serializing_if = "Option::is_none")]
+    snaplen: Option<i64>,
+
+    #[serde(skip_serializing_if = "Option::is_none")]
+    queue_threshold: Option<i64>,
+
+    #[serde(skip_serializing_if = "Option::is_none")]
+    level: Option<LogLevel>,
+
+    #[serde(default, skip_serializing_if = "Vec::is_empty")]
+    flags: NfVec<LogFlag>,
+}
+
+impl Log {
+    pub fn new_nflog(prefix: String, group: i64) -> Self {
+        Self {
+            prefix: Some(prefix),
+            group: Some(group),
+            ..Default::default()
+        }
+    }
+}
+
+#[derive(Clone, Debug, Deserialize, Serialize)]
+#[serde(rename_all = "lowercase")]
+pub enum LogLevel {
+    Emerg,
+    Alert,
+    Crit,
+    Err,
+    Warn,
+    Notice,
+    Info,
+    Debug,
+    Audit,
+}
+
+impl LogLevel {
+    pub fn nflog_level(&self) -> u8 {
+        match self {
+            LogLevel::Emerg => 0,
+            LogLevel::Alert => 1,
+            LogLevel::Crit => 2,
+            LogLevel::Err => 3,
+            LogLevel::Warn => 4,
+            LogLevel::Notice => 5,
+            LogLevel::Info => 6,
+            LogLevel::Debug => 7,
+            LogLevel::Audit => 7,
+        }
+    }
+}
+
+#[derive(Clone, Debug, Deserialize, Serialize)]
+#[serde(rename_all = "lowercase")]
+pub enum LogFlag {
+    #[serde(rename = "tcp sequence")]
+    TcpSequence,
+    #[serde(rename = "tcp options")]
+    TcpOptions,
+    #[serde(rename = "ip options")]
+    IpOptions,
+
+    Skuid,
+    Ether,
+    All,
+}
+
+#[derive(Clone, Debug, Deserialize, Serialize)]
+#[serde(untagged)]
+pub enum Limit {
+    Named(String),
+    Anonymous(AnonymousLimit),
+}
+
+impl<T: Into<AnonymousLimit>> From<T> for Limit {
+    fn from(value: T) -> Self {
+        Limit::Anonymous(value.into())
+    }
+}
+
+#[derive(Clone, Debug, Deserialize, Serialize, Default)]
+pub struct AnonymousLimit {
+    pub rate: i64,
+
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub rate_unit: Option<RateUnit>,
+
+    pub per: RateTimescale,
+
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub burst: Option<i64>,
+
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub burst_unit: Option<RateUnit>,
+
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub inv: Option<bool>,
+}
+
+#[derive(Clone, Debug, Deserialize, Serialize)]
+pub struct Vmap {
+    key: Expression,
+    data: Expression,
+}
+
+#[derive(Clone, Debug, Deserialize, Serialize)]
+pub struct Match {
+    op: Operator,
+    left: Expression,
+    right: Expression,
+}
+
+impl Match {
+    pub fn new(op: Operator, left: impl Into<Expression>, right: impl Into<Expression>) -> Self {
+        Self {
+            op,
+            left: left.into(),
+            right: right.into(),
+        }
+    }
+
+    pub fn new_eq(left: impl Into<Expression>, right: impl Into<Expression>) -> Self {
+        Self::new(Operator::Eq, left, right)
+    }
+
+    pub fn new_ne(left: impl Into<Expression>, right: impl Into<Expression>) -> Self {
+        Self::new(Operator::Ne, left, right)
+    }
+}
+
+#[derive(Clone, Debug, Deserialize, Serialize)]
+pub enum Operator {
+    #[serde(rename = "&")]
+    And,
+    #[serde(rename = "|")]
+    Or,
+    #[serde(rename = "^")]
+    Xor,
+    #[serde(rename = "<<")]
+    ShiftLeft,
+    #[serde(rename = ">>")]
+    ShiftRight,
+    #[serde(rename = "==")]
+    Eq,
+    #[serde(rename = "!=")]
+    Ne,
+    #[serde(rename = "<")]
+    Lt,
+    #[serde(rename = ">")]
+    Gt,
+    #[serde(rename = "<=")]
+    Le,
+    #[serde(rename = ">=")]
+    Ge,
+    #[serde(rename = "in")]
+    In,
+}
+
+#[derive(Clone, Debug, Deserialize, Serialize)]
+pub struct Mangle {
+    pub key: Expression,
+    pub value: Expression,
+}
+
+impl Mangle {
+    pub fn set_mark(value: impl Into<Expression>) -> Self {
+        Self {
+            key: Meta::new("mark").into(),
+            value: value.into(),
+        }
+    }
+}
+
+#[derive(Clone, Debug, Deserialize, Serialize)]
+#[serde(rename_all = "lowercase")]
+pub enum SetOperation {
+    Add,
+    Update,
+}
+
+#[derive(Clone, Debug, Deserialize, Serialize)]
+pub struct Set {
+    pub op: SetOperation,
+    pub elem: Expression,
+    pub set: String,
+    pub stmt: Option<NfVec<Statement>>,
+}
diff --git a/proxmox-nftables/src/types.rs b/proxmox-nftables/src/types.rs
index 942c866..b99747b 100644
--- a/proxmox-nftables/src/types.rs
+++ b/proxmox-nftables/src/types.rs
@@ -30,6 +30,23 @@ impl Display for Verdict {
     }
 }
 
+#[derive(Clone, Debug, Deserialize, Serialize)]
+pub enum RateUnit {
+    Packets,
+    Bytes,
+}
+
+#[derive(Clone, Debug, Deserialize, Serialize, Default)]
+#[cfg_attr(test, derive(Eq, PartialEq))]
+#[serde(rename_all = "lowercase")]
+pub enum RateTimescale {
+    #[default]
+    Second,
+    Minute,
+    Hour,
+    Day,
+}
+
 #[derive(Clone, Debug, Deserialize, Serialize)]
 pub struct ElemConfig {
     timeout: Option<i64>,
-- 
2.39.2




^ permalink raw reply	[flat|nested] 67+ messages in thread

* [pve-devel] [PATCH proxmox-firewall 22/37] nftables: statement: add conversion traits for config types
  2024-04-02 17:15 [pve-devel] [RFC container/firewall/manager/proxmox-firewall/qemu-server 00/37] proxmox firewall nftables implementation Stefan Hanreich
                   ` (20 preceding siblings ...)
  2024-04-02 17:16 ` [pve-devel] [PATCH proxmox-firewall 21/37] nftables: statement: add types Stefan Hanreich
@ 2024-04-02 17:16 ` Stefan Hanreich
  2024-04-02 17:16 ` [pve-devel] [PATCH proxmox-firewall 23/37] nftables: commands: add types Stefan Hanreich
                   ` (19 subsequent siblings)
  41 siblings, 0 replies; 67+ messages in thread
From: Stefan Hanreich @ 2024-04-02 17:16 UTC (permalink / raw)
  To: pve-devel; +Cc: Stefan Hanreich, Wolfgang Bumiller

Some types from the firewall configuration map directly onto nftables
statements. For those we implement conversion traits so we can
conveniently convert between the configuration types and the
respective nftables types.

As with the expressions, those are guarded behind a feature so the
nftables crate can be used standalone without having to pull in the
proxmox-ve-config crate.

Co-authored-by: Wolfgang Bumiller <w.bumiller@proxmox.com>
Signed-off-by: Stefan Hanreich <s.hanreich@proxmox.com>
---
 proxmox-nftables/src/statement.rs | 69 +++++++++++++++++++++++++++++++
 1 file changed, 69 insertions(+)

diff --git a/proxmox-nftables/src/statement.rs b/proxmox-nftables/src/statement.rs
index e569f33..5347777 100644
--- a/proxmox-nftables/src/statement.rs
+++ b/proxmox-nftables/src/statement.rs
@@ -1,6 +1,15 @@
 use anyhow::{bail, Error};
 use serde::{Deserialize, Serialize};
 
+#[cfg(feature = "config-ext")]
+use proxmox_ve_config::firewall::types::log::LogLevel as ConfigLogLevel;
+#[cfg(feature = "config-ext")]
+use proxmox_ve_config::firewall::types::log::LogRateLimit;
+#[cfg(feature = "config-ext")]
+use proxmox_ve_config::firewall::types::rule::Verdict as ConfigVerdict;
+#[cfg(feature = "config-ext")]
+use proxmox_ve_config::guest::types::Vmid;
+
 use crate::expression::Meta;
 use crate::helper::{NfVec, Null};
 use crate::types::{RateTimescale, RateUnit, Verdict};
@@ -104,6 +113,17 @@ impl<T: Into<Limit>> From<T> for Statement {
     }
 }
 
+#[cfg(feature = "config-ext")]
+impl From<ConfigVerdict> for Statement {
+    fn from(value: ConfigVerdict) -> Self {
+        match value {
+            ConfigVerdict::Accept => Statement::make_accept(),
+            ConfigVerdict::Reject => Statement::make_drop(),
+            ConfigVerdict::Drop => Statement::make_drop(),
+        }
+    }
+}
+
 #[derive(Clone, Debug, Deserialize, Serialize)]
 #[serde(rename_all = "lowercase")]
 pub enum RejectType {
@@ -145,6 +165,22 @@ pub struct Log {
 }
 
 impl Log {
+    #[cfg(feature = "config-ext")]
+    pub fn generate_prefix(
+        vmid: impl Into<Option<Vmid>>,
+        log_level: LogLevel,
+        chain_name: &str,
+        verdict: ConfigVerdict,
+    ) -> String {
+        format!(
+            ":{}:{}:{}: {}: ",
+            vmid.into().unwrap_or(Vmid::new(0)),
+            log_level.nflog_level(),
+            chain_name,
+            verdict,
+        )
+    }
+
     pub fn new_nflog(prefix: String, group: i64) -> Self {
         Self {
             prefix: Some(prefix),
@@ -168,6 +204,25 @@ pub enum LogLevel {
     Audit,
 }
 
+#[cfg(feature = "config-ext")]
+impl TryFrom<ConfigLogLevel> for LogLevel {
+    type Error = Error;
+
+    fn try_from(value: ConfigLogLevel) -> Result<Self, Self::Error> {
+        match value {
+            ConfigLogLevel::Emergency => Ok(LogLevel::Emerg),
+            ConfigLogLevel::Alert => Ok(LogLevel::Alert),
+            ConfigLogLevel::Critical => Ok(LogLevel::Crit),
+            ConfigLogLevel::Error => Ok(LogLevel::Err),
+            ConfigLogLevel::Warning => Ok(LogLevel::Warn),
+            ConfigLogLevel::Notice => Ok(LogLevel::Notice),
+            ConfigLogLevel::Info => Ok(LogLevel::Info),
+            ConfigLogLevel::Debug => Ok(LogLevel::Debug),
+            _ => bail!("cannot convert config log level to nftables"),
+        }
+    }
+}
+
 impl LogLevel {
     pub fn nflog_level(&self) -> u8 {
         match self {
@@ -231,6 +286,20 @@ pub struct AnonymousLimit {
     pub inv: Option<bool>,
 }
 
+#[cfg(feature = "config-ext")]
+impl From<LogRateLimit> for AnonymousLimit {
+    fn from(config: LogRateLimit) -> Self {
+        AnonymousLimit {
+            rate: config.rate(),
+            per: config.per().into(),
+            rate_unit: None,
+            burst: Some(config.burst()),
+            burst_unit: None,
+            inv: None,
+        }
+    }
+}
+
 #[derive(Clone, Debug, Deserialize, Serialize)]
 pub struct Vmap {
     key: Expression,
-- 
2.39.2




^ permalink raw reply	[flat|nested] 67+ messages in thread

* [pve-devel] [PATCH proxmox-firewall 23/37] nftables: commands: add types
  2024-04-02 17:15 [pve-devel] [RFC container/firewall/manager/proxmox-firewall/qemu-server 00/37] proxmox firewall nftables implementation Stefan Hanreich
                   ` (21 preceding siblings ...)
  2024-04-02 17:16 ` [pve-devel] [PATCH proxmox-firewall 22/37] nftables: statement: add conversion traits for config types Stefan Hanreich
@ 2024-04-02 17:16 ` Stefan Hanreich
  2024-04-02 17:16 ` [pve-devel] [PATCH proxmox-firewall 24/37] nftables: types: add conversion traits Stefan Hanreich
                   ` (18 subsequent siblings)
  41 siblings, 0 replies; 67+ messages in thread
From: Stefan Hanreich @ 2024-04-02 17:16 UTC (permalink / raw)
  To: pve-devel; +Cc: Stefan Hanreich, Wolfgang Bumiller

Add rust types for most of the nftables commands as defined by
libnftables-json [1].

Different commands require different keys to be set for the same type
of object. E.g. deleting an object usually only requires a name +
name of the container (table/chain/rule). Creating an object usually
requires a few more keys, depending on the type of object created.

In order to be able to model the different objects for the different
commands, I've created specific models for a command where necessary.
Parts that are common across multiple commands (e.g. names) have been
moved to their own structs, so they can be reused.

[1] https://manpages.debian.org/bookworm/libnftables1/libnftables-json.5.en.html#COMMAND_OBJECTS

Co-authored-by: Wolfgang Bumiller <w.bumiller@proxmox.com>
Signed-off-by: Stefan Hanreich <s.hanreich@proxmox.com>
---
 proxmox-nftables/src/command.rs | 221 ++++++++++
 proxmox-nftables/src/lib.rs     |   2 +
 proxmox-nftables/src/types.rs   | 755 +++++++++++++++++++++++++++++++-
 3 files changed, 977 insertions(+), 1 deletion(-)
 create mode 100644 proxmox-nftables/src/command.rs

diff --git a/proxmox-nftables/src/command.rs b/proxmox-nftables/src/command.rs
new file mode 100644
index 0000000..59163bc
--- /dev/null
+++ b/proxmox-nftables/src/command.rs
@@ -0,0 +1,221 @@
+use std::ops::{Deref, DerefMut};
+
+use crate::helper::Null;
+use crate::types::*;
+use serde::{Deserialize, Serialize};
+
+#[derive(Clone, Debug, Default, Deserialize, Serialize)]
+pub struct Commands {
+    nftables: Vec<Command>,
+}
+
+impl Commands {
+    pub fn new(commands: Vec<Command>) -> Self {
+        Self { nftables: commands }
+    }
+}
+
+impl Deref for Commands {
+    type Target = Vec<Command>;
+
+    fn deref(&self) -> &Self::Target {
+        &self.nftables
+    }
+}
+
+impl DerefMut for Commands {
+    fn deref_mut(&mut self) -> &mut Self::Target {
+        &mut self.nftables
+    }
+}
+
+#[derive(Clone, Debug, Deserialize, Serialize)]
+#[serde(rename_all = "lowercase")]
+pub enum Command {
+    Add(Add),
+    Create(Add),
+    Delete(Delete),
+    Flush(Flush),
+    List(List),
+    // Insert(super::Rule),
+    // Rename(RenameChain),
+    // Replace(super::Rule),
+}
+
+#[derive(Clone, Debug, Deserialize, Serialize)]
+#[serde(rename_all = "lowercase")]
+pub enum List {
+    Chains(Null),
+}
+
+impl List {
+    #[inline]
+    pub fn chains() -> Command {
+        Command::List(List::Chains(Null))
+    }
+}
+
+#[derive(Clone, Debug, Deserialize, Serialize)]
+#[serde(rename_all = "lowercase")]
+pub enum Add {
+    Table(AddTable),
+    Chain(AddChain),
+    Rule(AddRule),
+    Set(AddSet),
+    Map(AddMap),
+    Limit(AddLimit),
+    Element(AddElement),
+    #[serde(rename = "ct helper")]
+    CtHelper(AddCtHelper),
+}
+
+impl Add {
+    #[inline]
+    pub fn table(table: impl Into<AddTable>) -> Command {
+        Command::Add(Add::Table(table.into()))
+    }
+
+    #[inline]
+    pub fn chain(chain: impl Into<AddChain>) -> Command {
+        Command::Add(Add::Chain(chain.into()))
+    }
+
+    #[inline]
+    pub fn rule(rule: impl Into<AddRule>) -> Command {
+        Command::Add(Add::Rule(rule.into()))
+    }
+
+    #[inline]
+    pub fn set(set: impl Into<AddSet>) -> Command {
+        Command::Add(Add::Set(set.into()))
+    }
+
+    #[inline]
+    pub fn map(map: impl Into<AddMap>) -> Command {
+        Command::Add(Add::Map(map.into()))
+    }
+
+    #[inline]
+    pub fn limit(limit: impl Into<AddLimit>) -> Command {
+        Command::Add(Add::Limit(limit.into()))
+    }
+
+    #[inline]
+    pub fn element(element: impl Into<AddElement>) -> Command {
+        Command::Add(Add::Element(element.into()))
+    }
+
+    #[inline]
+    pub fn ct_helper(ct_helper: impl Into<AddCtHelper>) -> Command {
+        Command::Add(Add::CtHelper(ct_helper.into()))
+    }
+}
+
+#[derive(Clone, Debug, Deserialize, Serialize)]
+#[serde(rename_all = "lowercase")]
+pub enum Flush {
+    Table(TableName),
+    Chain(ChainName),
+    Set(SetName),
+    Map(SetName),
+    Ruleset(Null),
+}
+
+impl Flush {
+    #[inline]
+    pub fn table(table: impl Into<TableName>) -> Command {
+        Command::Flush(Flush::Table(table.into()))
+    }
+
+    #[inline]
+    pub fn chain(chain: impl Into<ChainName>) -> Command {
+        Command::Flush(Flush::Chain(chain.into()))
+    }
+
+    #[inline]
+    pub fn set(set: impl Into<SetName>) -> Command {
+        Command::Flush(Flush::Set(set.into()))
+    }
+
+    #[inline]
+    pub fn map(map: impl Into<SetName>) -> Command {
+        Command::Flush(Flush::Map(map.into()))
+    }
+
+    #[inline]
+    pub fn ruleset() -> Command {
+        Command::Flush(Flush::Ruleset(Null))
+    }
+}
+
+impl From<TableName> for Flush {
+    #[inline]
+    fn from(value: TableName) -> Self {
+        Flush::Table(value)
+    }
+}
+
+impl From<ChainName> for Flush {
+    #[inline]
+    fn from(value: ChainName) -> Self {
+        Flush::Chain(value)
+    }
+}
+
+#[derive(Clone, Debug, Deserialize, Serialize)]
+#[serde(rename_all = "lowercase")]
+pub enum Delete {
+    Table(TableName),
+    Chain(ChainName),
+}
+
+impl Delete {
+    #[inline]
+    pub fn table(table: impl Into<TableName>) -> Command {
+        Command::Delete(Delete::Table(table.into()))
+    }
+
+    #[inline]
+    pub fn chain(chain: impl Into<ChainName>) -> Command {
+        Command::Delete(Delete::Chain(chain.into()))
+    }
+}
+
+impl From<TableName> for Delete {
+    #[inline]
+    fn from(value: TableName) -> Self {
+        Delete::Table(value)
+    }
+}
+
+impl From<ChainName> for Delete {
+    #[inline]
+    fn from(value: ChainName) -> Self {
+        Delete::Chain(value)
+    }
+}
+
+#[derive(Clone, Debug, Deserialize, Serialize)]
+#[serde(rename_all = "lowercase")]
+pub enum ListOutput {
+    Metainfo(serde_json::Value),
+    // Table(super::AddTable),
+    Chain(ListChain),
+    // Rule(super::Rule),
+    // Set(super::Set),
+    // Map(super::Map),
+    // Element(super::SetElement),
+}
+
+#[derive(Clone, Debug, Deserialize, Serialize)]
+pub struct CommandOutput {
+    pub nftables: Vec<ListOutput>,
+}
+
+impl Deref for CommandOutput {
+    type Target = Vec<ListOutput>;
+
+    fn deref(&self) -> &Self::Target {
+        &self.nftables
+    }
+}
diff --git a/proxmox-nftables/src/lib.rs b/proxmox-nftables/src/lib.rs
index 40f6bab..60ddb3f 100644
--- a/proxmox-nftables/src/lib.rs
+++ b/proxmox-nftables/src/lib.rs
@@ -1,7 +1,9 @@
+pub mod command;
 pub mod expression;
 pub mod helper;
 pub mod statement;
 pub mod types;
 
+pub use command::Command;
 pub use expression::Expression;
 pub use statement::Statement;
diff --git a/proxmox-nftables/src/types.rs b/proxmox-nftables/src/types.rs
index b99747b..f9dc9b6 100644
--- a/proxmox-nftables/src/types.rs
+++ b/proxmox-nftables/src/types.rs
@@ -1,8 +1,90 @@
 use std::fmt::Display;
+use std::ops::{Deref, DerefMut};
+
+use crate::expression::IpFamily;
+use crate::helper::{NfVec, Null};
+use crate::{Expression, Statement};
 
 use serde::{Deserialize, Serialize};
 
-use crate::helper::Null;
+
+#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)]
+pub struct Handle(i32);
+
+#[derive(Clone, Copy, Debug, Eq, PartialEq, Deserialize, Serialize)]
+#[serde(rename_all = "lowercase")]
+pub enum TableFamily {
+    Ip,
+    Ip6,
+    Inet,
+    Arp,
+    Bridge,
+    Netdev,
+}
+serde_plain::derive_display_from_serialize!(TableFamily);
+
+impl TableFamily {
+    pub fn ip_families(&self) -> Vec<IpFamily> {
+        match self {
+            TableFamily::Ip => vec![IpFamily::Ip],
+            TableFamily::Ip6 => vec![IpFamily::Ip6],
+            _ => vec![IpFamily::Ip, IpFamily::Ip6],
+        }
+    }
+}
+
+#[derive(Clone, Copy, Debug, Eq, PartialEq, Deserialize, Serialize)]
+#[serde(rename_all = "snake_case")]
+pub enum ElementType {
+    Ifname,
+    Ipv4Addr,
+    Ipv6Addr,
+}
+serde_plain::derive_display_from_serialize!(ElementType);
+
+#[derive(Clone, Copy, Debug, Eq, PartialEq, Deserialize, Serialize)]
+#[serde(rename_all = "lowercase")]
+pub enum ChainType {
+    Filter,
+    Nat,
+    Route,
+}
+serde_plain::derive_display_from_serialize!(ChainType);
+
+#[derive(Clone, Copy, Debug, Eq, PartialEq, Deserialize, Serialize)]
+#[serde(rename_all = "lowercase")]
+pub enum SetPolicy {
+    Performance,
+    Memory,
+}
+serde_plain::derive_display_from_serialize!(SetPolicy);
+
+#[derive(Clone, Copy, Debug, Eq, PartialEq, Deserialize, Serialize)]
+#[serde(rename_all = "lowercase")]
+pub enum SetFlag {
+    Constant,
+    Interval,
+    Timeout,
+}
+serde_plain::derive_display_from_serialize!(SetFlag);
+
+#[derive(Clone, Copy, Debug, Eq, PartialEq, Deserialize, Serialize)]
+#[serde(rename_all = "snake_case")]
+pub enum OutputType {
+    Verdict,
+    Type(ElementType),
+}
+
+#[derive(Clone, Copy, Debug, Eq, PartialEq, Deserialize, Serialize)]
+#[serde(rename_all = "lowercase")]
+pub enum Hook {
+    Prerouting,
+    Input,
+    Forward,
+    Output,
+    Postrouting,
+}
+serde_plain::derive_display_from_serialize!(Hook);
 
 #[derive(Clone, Debug, Deserialize, Serialize)]
 #[serde(rename_all = "snake_case")]
@@ -30,6 +112,32 @@ impl Display for Verdict {
     }
 }
 
+#[derive(Clone, Debug, Deserialize, Serialize)]
+#[serde(rename_all = "snake_case")]
+pub enum ChainPolicy {
+    Accept,
+    Drop,
+}
+
+#[derive(Clone, Debug, Deserialize, Serialize)]
+#[serde(rename_all = "lowercase")]
+pub enum PriorityKeyword {
+    Raw,
+    Mangle,
+    DstNat,
+    Filter,
+    Security,
+    SrcNat,
+    Out,
+}
+
+#[derive(Clone, Debug, Deserialize, Serialize)]
+#[serde(untagged)]
+pub enum Priority {
+    Keyword(PriorityKeyword),
+    Number(i64),
+}
+
 #[derive(Clone, Debug, Deserialize, Serialize)]
 pub enum RateUnit {
     Packets,
@@ -47,6 +155,529 @@ pub enum RateTimescale {
     Day,
 }
 
+#[derive(Clone, Debug, Deserialize, Serialize)]
+pub struct TableName {
+    family: TableFamily,
+    name: String,
+}
+
+impl TableName {
+    pub fn new(family: TableFamily, name: impl Into<String>) -> Self {
+        Self {
+            family,
+            name: name.into(),
+        }
+    }
+
+    pub fn family(&self) -> &TableFamily {
+        &self.family
+    }
+
+    pub fn name(&self) -> &str {
+        &self.name
+    }
+}
+
+#[derive(Clone, Debug, Deserialize, Serialize)]
+pub struct TablePart {
+    family: TableFamily,
+    table: String,
+}
+
+impl TablePart {
+    pub fn new(family: TableFamily, name: impl Into<String>) -> Self {
+        Self {
+            family,
+            table: name.into(),
+        }
+    }
+
+    pub fn family(&self) -> &TableFamily {
+        &self.family
+    }
+
+    pub fn table(&self) -> &str {
+        &self.table
+    }
+}
+
+impl From<TablePart> for TableName {
+    fn from(t: TablePart) -> Self {
+        Self {
+            family: t.family,
+            name: t.table,
+        }
+    }
+}
+
+impl From<TableName> for TablePart {
+    fn from(t: TableName) -> Self {
+        Self {
+            family: t.family,
+            table: t.name,
+        }
+    }
+}
+
+#[derive(Clone, Debug, Deserialize, Serialize)]
+pub struct ChainName {
+    #[serde(flatten)]
+    table: TablePart,
+    name: String,
+}
+
+impl From<AddChain> for ChainName {
+    fn from(value: AddChain) -> Self {
+        Self {
+            table: value.table,
+            name: value.name,
+        }
+    }
+}
+
+impl From<ListChain> for ChainName {
+    fn from(value: ListChain) -> Self {
+        Self {
+            table: value.table,
+            name: value.name,
+        }
+    }
+}
+
+#[derive(Clone, Debug, Deserialize, Serialize)]
+pub struct ChainPart {
+    #[serde(flatten)]
+    table: TablePart,
+    chain: String,
+}
+
+impl ChainPart {
+    pub fn new(table: TablePart, chain: impl Into<String>) -> Self {
+        Self {
+            table,
+            chain: chain.into(),
+        }
+    }
+
+    pub fn table(&self) -> &TablePart {
+        &self.table
+    }
+
+    pub fn name(&self) -> &str {
+        &self.chain
+    }
+}
+
+impl From<ChainName> for ChainPart {
+    fn from(c: ChainName) -> Self {
+        Self {
+            table: c.table,
+            chain: c.name,
+        }
+    }
+}
+
+impl From<ChainPart> for ChainName {
+    fn from(c: ChainPart) -> Self {
+        Self {
+            table: c.table,
+            name: c.chain,
+        }
+    }
+}
+
+#[derive(Clone, Debug, Deserialize, Serialize)]
+pub struct AddTable {
+    family: TableFamily,
+    name: String,
+
+    #[serde(skip_serializing_if = "Option::is_none")]
+    handle: Option<Handle>,
+}
+
+impl AddTable {
+    pub fn new(family: TableFamily, name: impl Into<String>) -> Self {
+        Self {
+            family,
+            name: name.into(),
+            handle: None,
+        }
+    }
+}
+
+#[derive(Clone, Debug, Deserialize, Serialize)]
+pub struct BaseChainConfig {
+    #[serde(rename = "type")]
+    ty: ChainType,
+    hook: Hook,
+    prio: Expression,
+    policy: ChainPolicy,
+
+    /// netdev family only
+    #[serde(skip_serializing_if = "Option::is_none")]
+    dev: Option<String>,
+}
+
+impl BaseChainConfig {
+    pub fn new(
+        ty: ChainType,
+        hook: Hook,
+        prio: impl Into<Expression>,
+        policy: ChainPolicy,
+    ) -> Self {
+        Self {
+            ty,
+            hook,
+            prio: prio.into(),
+            policy,
+            dev: None,
+        }
+    }
+}
+
+#[derive(Clone, Debug, Deserialize, Serialize)]
+pub struct AddChain {
+    #[serde(flatten)]
+    table: TablePart,
+    name: String,
+
+    #[serde(flatten, skip_serializing_if = "Option::is_none")]
+    config: Option<BaseChainConfig>,
+}
+
+impl AddChain {
+    pub fn new(table: TablePart, name: impl Into<String>) -> Self {
+        Self {
+            table,
+            name: name.into(),
+            config: None,
+        }
+    }
+
+    pub fn new_base_chain(
+        table: TablePart,
+        name: impl Into<String>,
+        config: BaseChainConfig,
+    ) -> Self {
+        Self {
+            table,
+            name: name.into(),
+            config: Some(config),
+        }
+    }
+}
+
+impl From<ChainPart> for AddChain {
+    #[inline]
+    fn from(part: ChainPart) -> Self {
+        Self::new(part.table, part.chain)
+    }
+}
+
+#[derive(Clone, Debug, Deserialize, Serialize)]
+pub struct AddRule {
+    #[serde(flatten)]
+    chain: ChainPart,
+
+    #[serde(skip_serializing_if = "Option::is_none")]
+    handle: Option<Handle>,
+
+    #[serde(skip_serializing_if = "Option::is_none")]
+    index: Option<u64>,
+
+    #[serde(skip_serializing_if = "Option::is_none")]
+    comment: Option<String>,
+
+    expr: Vec<Statement>,
+}
+
+impl Deref for AddRule {
+    type Target = Vec<Statement>;
+
+    fn deref(&self) -> &Self::Target {
+        &self.expr
+    }
+}
+
+impl DerefMut for AddRule {
+    fn deref_mut(&mut self) -> &mut Self::Target {
+        &mut self.expr
+    }
+}
+
+impl AddRule {
+    pub fn from_statement(chain: ChainPart, expression: impl Into<Statement>) -> Self {
+        Self {
+            chain,
+            expr: vec![expression.into()],
+            handle: None,
+            index: None,
+            comment: None,
+        }
+    }
+
+    pub fn from_statements<I: IntoIterator<Item = Statement>>(
+        chain: ChainPart,
+        expression: I,
+    ) -> Self {
+        Self {
+            chain,
+            expr: expression.into_iter().collect(),
+            handle: None,
+            index: None,
+            comment: None,
+        }
+    }
+
+    pub fn new(chain: ChainPart) -> Self {
+        Self {
+            chain,
+            expr: Vec::new(),
+            handle: None,
+            index: None,
+            comment: None,
+        }
+    }
+
+    pub fn with_comment(mut self, comment: impl Into<String>) -> Self {
+        self.comment = Some(comment.into());
+        self
+    }
+}
+
+#[derive(Clone, Debug, Deserialize, Serialize)]
+#[serde(rename_all = "kebab-case")]
+pub struct SetConfig {
+    #[serde(flatten)]
+    name: SetName,
+
+    #[serde(rename = "type", default, skip_serializing_if = "Vec::is_empty")]
+    ty: NfVec<ElementType>,
+
+    #[serde(skip_serializing_if = "Option::is_none")]
+    policy: Option<SetPolicy>,
+
+    #[serde(skip_serializing_if = "Vec::is_empty", default)]
+    flags: Vec<SetFlag>,
+
+    #[serde(skip_serializing_if = "Option::is_none")]
+    timeout: Option<i64>,
+
+    #[serde(skip_serializing_if = "Option::is_none")]
+    gc_interval: Option<i64>,
+
+    #[serde(skip_serializing_if = "Option::is_none")]
+    size: Option<i64>,
+}
+
+impl SetConfig {
+    pub fn new(name: impl Into<SetName>, ty: impl Into<NfVec<ElementType>>) -> Self {
+        Self {
+            name: name.into(),
+            ty: ty.into(),
+            flags: Vec::new(),
+            policy: None,
+            timeout: None,
+            gc_interval: None,
+            size: None,
+        }
+    }
+
+    pub fn name(&self) -> &SetName {
+        &self.name
+    }
+
+    pub fn with_flag(mut self, flag: SetFlag) -> Self {
+        self.flags.push(flag);
+        self
+    }
+}
+
+#[derive(Clone, Debug, Deserialize, Serialize)]
+#[serde(rename_all = "kebab-case")]
+pub struct AddMap {
+    #[serde(flatten)]
+    config: SetConfig,
+
+    map: OutputType,
+
+    #[serde(default, skip_serializing_if = "Vec::is_empty")]
+    elem: NfVec<MapElem>,
+}
+
+impl AddMap {
+    pub fn new(config: SetConfig, output_type: OutputType) -> Self {
+        Self {
+            config,
+            map: output_type,
+            elem: NfVec::new(),
+        }
+    }
+}
+
+impl Deref for AddMap {
+    type Target = Vec<MapElem>;
+
+    fn deref(&self) -> &Self::Target {
+        &self.elem
+    }
+}
+
+impl DerefMut for AddMap {
+    fn deref_mut(&mut self) -> &mut Self::Target {
+        &mut self.elem
+    }
+}
+
+#[derive(Clone, Debug, Deserialize, Serialize)]
+pub struct AddSet {
+    #[serde(flatten)]
+    config: SetConfig,
+
+    #[serde(default, skip_serializing_if = "Vec::is_empty")]
+    elem: NfVec<SetElem>,
+}
+
+impl From<SetConfig> for AddSet {
+    fn from(value: SetConfig) -> Self {
+        Self {
+            config: value,
+            elem: NfVec::new(),
+        }
+    }
+}
+
+impl Deref for AddSet {
+    type Target = Vec<SetElem>;
+
+    fn deref(&self) -> &Self::Target {
+        &self.elem
+    }
+}
+
+impl DerefMut for AddSet {
+    fn deref_mut(&mut self) -> &mut Self::Target {
+        &mut self.elem
+    }
+}
+
+impl AddSet {
+    pub fn new(config: impl Into<SetConfig>, elements: impl IntoIterator<Item = SetElem>) -> Self {
+        Self {
+            config: config.into(),
+            elem: NfVec::from(elements.into_iter().collect::<Vec<_>>()),
+        }
+    }
+}
+
+#[derive(Clone, Debug, Deserialize, Serialize)]
+pub struct SetName {
+    #[serde(flatten)]
+    table: TablePart,
+    name: String,
+}
+
+impl SetName {
+    pub fn new(table: TablePart, name: impl Into<String>) -> Self {
+        Self {
+            table,
+            name: name.into(),
+        }
+    }
+}
+
+#[derive(Clone, Debug, Deserialize, Serialize)]
+pub struct SetElem(Expression);
+
+impl From<Expression> for SetElem {
+    #[inline]
+    fn from(value: Expression) -> Self {
+        Self(value)
+    }
+}
+
+#[derive(Clone, Debug, Deserialize, Serialize)]
+#[serde(untagged)]
+pub enum MapValue {
+    Expression(Expression),
+    Verdict(Verdict),
+    // Concat
+}
+
+impl From<Verdict> for MapValue {
+    #[inline]
+    fn from(value: Verdict) -> Self {
+        Self::Verdict(value)
+    }
+}
+
+impl From<Expression> for MapValue {
+    #[inline]
+    fn from(value: Expression) -> Self {
+        Self::Expression(value)
+    }
+}
+
+#[derive(Clone, Debug, Deserialize, Serialize)]
+pub struct MapElem((Expression, MapValue));
+
+impl MapElem {
+    pub fn new(key: Expression, value: impl Into<MapValue>) -> Self {
+        Self((key, value.into()))
+    }
+}
+
+#[derive(Clone, Debug, Deserialize, Serialize)]
+pub struct AddSetElement {
+    #[serde(flatten)]
+    set: SetName,
+    #[serde(default, skip_serializing_if = "Vec::is_empty")]
+    elem: Vec<SetElement>,
+}
+
+#[derive(Clone, Debug, Deserialize, Serialize)]
+pub struct AddMapElement {
+    #[serde(flatten)]
+    map: SetName,
+    #[serde(default, skip_serializing_if = "Vec::is_empty")]
+    elem: Vec<MapElement>,
+}
+
+#[derive(Clone, Debug, Deserialize, Serialize)]
+#[serde(untagged)]
+pub enum AddElement {
+    Set(AddSetElement),
+    Map(AddMapElement),
+}
+
+impl AddElement {
+    pub fn map_from_expressions(
+        map: SetName,
+        elem: impl IntoIterator<Item = (Expression, MapValue)>,
+    ) -> Self {
+        Self::Map(AddMapElement {
+            map,
+            elem: Vec::from_iter(
+                elem.into_iter()
+                    .map(|(key, value)| MapElem::new(key, value).into()),
+            ),
+        })
+    }
+
+    pub fn set_from_expressions(set: SetName, elem: impl IntoIterator<Item = Expression>) -> Self {
+        Self::Set(AddSetElement {
+            set,
+            elem: Vec::from_iter(elem.into_iter().map(SetElement::from)),
+        })
+    }
+}
+
+impl From<AddSetElement> for AddElement {
+    fn from(value: AddSetElement) -> Self {
+        AddElement::Set(value)
+    }
+}
+
 #[derive(Clone, Debug, Deserialize, Serialize)]
 pub struct ElemConfig {
     timeout: Option<i64>,
@@ -68,3 +699,125 @@ impl ElemConfig {
     }
 }
 
+#[derive(Clone, Debug, Deserialize, Serialize)]
+pub struct SetElemObject {
+    #[serde(flatten)]
+    config: ElemConfig,
+    elem: SetElem,
+}
+
+#[derive(Clone, Debug, Deserialize, Serialize)]
+pub struct MapElemObject {
+    #[serde(flatten)]
+    config: ElemConfig,
+    elem: MapElem,
+}
+
+#[derive(Clone, Debug, Deserialize, Serialize)]
+pub enum MapElement {
+    #[serde(rename = "elem")]
+    Object(MapElemObject),
+    #[serde(untagged)]
+    Value(MapElem),
+}
+
+impl From<MapElem> for MapElement {
+    fn from(value: MapElem) -> Self {
+        Self::Value(value)
+    }
+}
+
+#[derive(Clone, Debug, Deserialize, Serialize)]
+pub enum SetElement {
+    #[serde(rename = "elem")]
+    Object(SetElemObject),
+    #[serde(untagged)]
+    Value(SetElem),
+}
+
+impl From<Expression> for SetElement {
+    #[inline]
+    fn from(value: Expression) -> Self {
+        Self::Value(SetElem::from(value))
+    }
+}
+
+#[derive(Clone, Debug, Deserialize, Serialize)]
+#[serde(rename_all = "lowercase")]
+pub struct AddLimit {
+    #[serde(flatten)]
+    table: TablePart,
+
+    name: String,
+
+    rate: i64,
+
+    #[serde(skip_serializing_if = "Option::is_none")]
+    unit: Option<RateUnit>,
+
+    #[serde(skip_serializing_if = "Option::is_none")]
+    per: Option<RateTimescale>,
+
+    #[serde(skip_serializing_if = "Option::is_none")]
+    burst: Option<i64>,
+
+    #[serde(skip_serializing_if = "Option::is_none")]
+    inv: Option<bool>,
+}
+
+impl AddLimit {
+    pub fn new(table: TablePart, name: String, rate: i64) -> Self {
+        Self {
+            table,
+            name,
+            rate,
+            unit: None,
+            per: None,
+            burst: None,
+            inv: None,
+        }
+    }
+}
+
+#[derive(Clone, Debug, Deserialize, Serialize)]
+#[serde(rename_all = "lowercase")]
+pub enum L3Protocol {
+    Ip,
+    Ip6,
+}
+
+#[derive(Clone, Debug, Deserialize, Serialize)]
+#[serde(rename_all = "lowercase")]
+pub enum CtHelperProtocol {
+    TCP,
+    UDP,
+}
+
+#[derive(Clone, Debug, Deserialize, Serialize)]
+#[serde(rename = "ct helper")]
+pub struct AddCtHelper {
+    #[serde(flatten)]
+    pub table: TablePart,
+    pub name: String,
+    #[serde(rename = "type")]
+    pub ty: String,
+    pub protocol: CtHelperProtocol,
+    pub l3proto: Option<L3Protocol>,
+}
+
+#[derive(Clone, Debug, Deserialize, Serialize)]
+pub struct ListChain {
+    #[serde(flatten)]
+    table: TablePart,
+    name: String,
+    handle: i64,
+
+    #[serde(flatten)]
+    config: Option<BaseChainConfig>,
+}
+
+impl ListChain {
+    pub fn name(&self) -> &str {
+        &self.name
+    }
+}
-- 
2.39.2




^ permalink raw reply	[flat|nested] 67+ messages in thread

* [pve-devel] [PATCH proxmox-firewall 24/37] nftables: types: add conversion traits
  2024-04-02 17:15 [pve-devel] [RFC container/firewall/manager/proxmox-firewall/qemu-server 00/37] proxmox firewall nftables implementation Stefan Hanreich
                   ` (22 preceding siblings ...)
  2024-04-02 17:16 ` [pve-devel] [PATCH proxmox-firewall 23/37] nftables: commands: add types Stefan Hanreich
@ 2024-04-02 17:16 ` Stefan Hanreich
  2024-04-02 17:16 ` [pve-devel] [PATCH proxmox-firewall 25/37] nftables: add libnftables bindings Stefan Hanreich
                   ` (17 subsequent siblings)
  41 siblings, 0 replies; 67+ messages in thread
From: Stefan Hanreich @ 2024-04-02 17:16 UTC (permalink / raw)
  To: pve-devel; +Cc: Stefan Hanreich, Wolfgang Bumiller

Some parts of the firewall config map directly to nftables objects, so
we introduce conversion traits for convenient conversion into the
respective nftables objects / types.

They are guarded behind a feature, so the nftables crate can be used
standalone without depending on the proxmox-ve-config crate.

Co-authored-by: Wolfgang Bumiller <w.bumiller@proxmox.com>
Signed-off-by: Stefan Hanreich <s.hanreich@proxmox.com>
---
 proxmox-nftables/src/types.rs | 54 +++++++++++++++++++++++++++++++++++
 1 file changed, 54 insertions(+)

diff --git a/proxmox-nftables/src/types.rs b/proxmox-nftables/src/types.rs
index f9dc9b6..10e569c 100644
--- a/proxmox-nftables/src/types.rs
+++ b/proxmox-nftables/src/types.rs
@@ -7,6 +7,11 @@ use crate::{Expression, Statement};
 
 use serde::{Deserialize, Serialize};
 
+#[cfg(feature = "config-ext")]
+use proxmox_ve_config::firewall::types::address::Family;
+
+#[cfg(feature = "config-ext")]
+use proxmox_ve_config::firewall::types::ipset::IpsetName;
 
 #[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)]
 pub struct Handle(i32);
@@ -31,6 +36,15 @@ impl TableFamily {
             _ => vec![IpFamily::Ip, IpFamily::Ip6],
         }
     }
+
+    #[cfg(feature = "config-ext")]
+    pub fn families(&self) -> Vec<Family> {
+        match self {
+            TableFamily::Ip => vec![Family::V4],
+            TableFamily::Ip6 => vec![Family::V6],
+            _ => vec![Family::V4, Family::V6],
+        }
+    }
 }
 
 #[derive(Clone, Copy, Debug, Eq, PartialEq, Deserialize, Serialize)]
@@ -155,6 +169,21 @@ pub enum RateTimescale {
     Day,
 }
 
+#[cfg(feature = "config-ext")]
+use proxmox_ve_config::firewall::types::log::LogRateLimitTimescale;
+
+#[cfg(feature = "config-ext")]
+impl From<LogRateLimitTimescale> for RateTimescale {
+    fn from(value: LogRateLimitTimescale) -> Self {
+        match value {
+            LogRateLimitTimescale::Second => RateTimescale::Second,
+            LogRateLimitTimescale::Minute => RateTimescale::Minute,
+            LogRateLimitTimescale::Hour => RateTimescale::Hour,
+            LogRateLimitTimescale::Day => RateTimescale::Day,
+        }
+    }
+}
+
 #[derive(Clone, Debug, Deserialize, Serialize)]
 pub struct TableName {
     family: TableFamily,
@@ -584,6 +613,21 @@ impl SetName {
             name: name.into(),
         }
     }
+
+    #[cfg(feature = "config-ext")]
+    pub fn ipset_name(family: Family, name: &IpsetName, nomatch: bool) -> String {
+        let prefix = match family {
+            Family::V4 => "v4",
+            Family::V6 => "v6",
+        };
+
+        let suffix = match nomatch {
+            true => "-nomatch",
+            false => "",
+        };
+
+        format!("{prefix}-{name}{suffix}")
+    }
 }
 
 #[derive(Clone, Debug, Deserialize, Serialize)]
@@ -786,6 +830,16 @@ pub enum L3Protocol {
     Ip6,
 }
 
+#[cfg(feature = "config-ext")]
+impl From<Family> for L3Protocol {
+    fn from(value: Family) -> Self {
+        match value {
+            Family::V4 => L3Protocol::Ip,
+            Family::V6 => L3Protocol::Ip6,
+        }
+    }
+}
+
 #[derive(Clone, Debug, Deserialize, Serialize)]
 #[serde(rename_all = "lowercase")]
 pub enum CtHelperProtocol {
-- 
2.39.2




^ permalink raw reply	[flat|nested] 67+ messages in thread

* [pve-devel] [PATCH proxmox-firewall 25/37] nftables: add libnftables bindings
  2024-04-02 17:15 [pve-devel] [RFC container/firewall/manager/proxmox-firewall/qemu-server 00/37] proxmox firewall nftables implementation Stefan Hanreich
                   ` (23 preceding siblings ...)
  2024-04-02 17:16 ` [pve-devel] [PATCH proxmox-firewall 24/37] nftables: types: add conversion traits Stefan Hanreich
@ 2024-04-02 17:16 ` Stefan Hanreich
  2024-04-02 17:16 ` [pve-devel] [PATCH proxmox-firewall 26/37] firewall: add firewall crate Stefan Hanreich
                   ` (16 subsequent siblings)
  41 siblings, 0 replies; 67+ messages in thread
From: Stefan Hanreich @ 2024-04-02 17:16 UTC (permalink / raw)
  To: pve-devel; +Cc: Stefan Hanreich, Wolfgang Bumiller

Add a thin wrapper around libnftables, which can be used to run
commands defined by the rust types.

Co-authored-by: Wolfgang Bumiller <w.bumiller@proxmox.com>
Signed-off-by: Stefan Hanreich <s.hanreich@proxmox.com>
---
 proxmox-nftables/src/context.rs | 243 ++++++++++++++++++++++++++++++++
 proxmox-nftables/src/error.rs   |  43 ++++++
 proxmox-nftables/src/lib.rs     |   3 +
 3 files changed, 289 insertions(+)
 create mode 100644 proxmox-nftables/src/context.rs
 create mode 100644 proxmox-nftables/src/error.rs

diff --git a/proxmox-nftables/src/context.rs b/proxmox-nftables/src/context.rs
new file mode 100644
index 0000000..9ab51fb
--- /dev/null
+++ b/proxmox-nftables/src/context.rs
@@ -0,0 +1,243 @@
+use std::ffi::CString;
+use std::os::raw::{c_int, c_uint};
+use std::path::Path;
+
+use crate::command::{CommandOutput, Commands};
+use crate::error::NftError;
+
+#[rustfmt::skip]
+pub mod debug {
+    use super::c_uint;
+
+    pub const SCANNER    : c_uint = 0x1;
+    pub const PARSER     : c_uint = 0x2;
+    pub const EVALUATION : c_uint = 0x4;
+    pub const NETLINK    : c_uint = 0x8;
+    pub const MNL        : c_uint = 0x10;
+    pub const PROTO_CTX  : c_uint = 0x20;
+    pub const SEGTREE    : c_uint = 0x40;
+}
+
+#[rustfmt::skip]
+pub mod output {
+    use super::c_uint;
+
+    pub const REVERSEDNS     : c_uint = 1;
+    pub const SERVICE        : c_uint = 1 << 1;
+    pub const STATELESS      : c_uint = 1 << 2;
+    pub const HANDLE         : c_uint = 1 << 3;
+    pub const JSON           : c_uint = 1 << 4;
+    pub const ECHO           : c_uint = 1 << 5;
+    pub const GUID           : c_uint = 1 << 6;
+    pub const NUMERIC_PROTO  : c_uint = 1 << 7;
+    pub const NUMERIC_PRIO   : c_uint = 1 << 8;
+    pub const NUMERIC_SYMBOL : c_uint = 1 << 9;
+    pub const NUMERIC_TIME   : c_uint = 1 << 10;
+    pub const TERSE          : c_uint = 1 << 11;
+
+    pub const NUMERIC_ALL    : c_uint = NUMERIC_PROTO | NUMERIC_PRIO | NUMERIC_SYMBOL;
+}
+
+#[link(name = "nftables")]
+extern "C" {
+    fn nft_ctx_new(flags: u32) -> RawNftCtx;
+    fn nft_ctx_free(ctx: RawNftCtx);
+
+    //fn nft_ctx_get_dry_run(ctx: RawNftCtx) -> bool;
+    fn nft_ctx_set_dry_run(ctx: RawNftCtx, dry: bool);
+
+    fn nft_ctx_output_get_flags(ctx: RawNftCtx) -> c_uint;
+    fn nft_ctx_output_set_flags(ctx: RawNftCtx, flags: c_uint);
+
+    // fn nft_ctx_output_get_debug(ctx: RawNftCtx) -> c_uint;
+    fn nft_ctx_output_set_debug(ctx: RawNftCtx, mask: c_uint);
+
+    //fn nft_ctx_set_output(ctx: RawNftCtx, file: RawCFile) -> RawCFile;
+    fn nft_ctx_buffer_output(ctx: RawNftCtx) -> c_int;
+    fn nft_ctx_unbuffer_output(ctx: RawNftCtx) -> c_int;
+    fn nft_ctx_get_output_buffer(ctx: RawNftCtx) -> *const i8;
+
+    fn nft_ctx_buffer_error(ctx: RawNftCtx) -> c_int;
+    fn nft_ctx_unbuffer_error(ctx: RawNftCtx) -> c_int;
+    fn nft_ctx_get_error_buffer(ctx: RawNftCtx) -> *const i8;
+
+    fn nft_run_cmd_from_buffer(ctx: RawNftCtx, buf: *const i8) -> c_int;
+    fn nft_run_cmd_from_filename(ctx: RawNftCtx, filename: *const i8) -> c_int;
+}
+
+#[derive(Clone, Copy)]
+#[repr(transparent)]
+struct RawNftCtx(*mut u8);
+
+pub struct NftCtx(RawNftCtx);
+
+impl Drop for NftCtx {
+    fn drop(&mut self) {
+        if !self.0 .0.is_null() {
+            unsafe {
+                nft_ctx_free(self.0);
+            }
+        }
+    }
+}
+
+impl NftCtx {
+    pub fn new() -> Result<Self, NftError> {
+        let mut this = Self(unsafe { nft_ctx_new(0) });
+
+        if this.0 .0.is_null() {
+            return Err(NftError::msg("failed to instantiate nft context"));
+        }
+
+        this.enable_json();
+
+        Ok(this)
+    }
+
+    fn modify_flags(&mut self, func: impl FnOnce(c_uint) -> c_uint) {
+        unsafe { nft_ctx_output_set_flags(self.0, func(nft_ctx_output_get_flags(self.0))) }
+    }
+
+    pub fn enable_debug(&mut self) {
+        unsafe { nft_ctx_output_set_debug(self.0, debug::PARSER | debug::SCANNER) }
+    }
+
+    pub fn disable_debug(&mut self) {
+        unsafe { nft_ctx_output_set_debug(self.0, 0) }
+    }
+
+    fn enable_json(&mut self) {
+        self.modify_flags(|flags| flags | output::JSON);
+    }
+
+    pub fn set_dry_run(&mut self, on: bool) {
+        unsafe { nft_ctx_set_dry_run(self.0, on) }
+    }
+
+    fn start_output_buffering(&mut self) -> Result<(), NftError> {
+        let rc = unsafe { nft_ctx_buffer_output(self.0) };
+        NftError::expect_zero(rc, || "failed to start output buffering")
+    }
+
+    fn stop_output_buffering(&mut self) {
+        let _ = unsafe { nft_ctx_unbuffer_output(self.0) };
+        // ignore errors
+    }
+
+    fn get_output_buffer(&mut self) -> Result<String, NftError> {
+        let buf = unsafe { nft_ctx_get_output_buffer(self.0) };
+
+        if buf.is_null() {
+            return Err(NftError::msg("failed to get output buffer"));
+        }
+
+        unsafe { std::ffi::CStr::from_ptr(buf) }
+            .to_str()
+            .map_err(NftError::msg)
+            .map(str::to_string)
+    }
+
+    fn start_error_buffering(&mut self) -> Result<(), NftError> {
+        let rc = unsafe { nft_ctx_buffer_error(self.0) };
+        NftError::expect_zero(rc, || "failed to start error buffering")
+    }
+
+    fn stop_error_buffering(&mut self) {
+        let _ = unsafe { nft_ctx_unbuffer_error(self.0) };
+        // ignore errors...
+    }
+
+    fn get_error_buffer(&mut self) -> Result<String, NftError> {
+        let buf = unsafe { nft_ctx_get_error_buffer(self.0) };
+
+        if buf.is_null() {
+            return Err(NftError::msg("failed to get error buffer"));
+        }
+
+        unsafe { std::ffi::CStr::from_ptr(buf) }
+            .to_str()
+            .map_err(NftError::msg)
+            .map(str::to_string)
+    }
+
+    fn start_buffering(&mut self) -> Result<(), NftError> {
+        self.start_output_buffering()?;
+        if let Err(err) = self.start_error_buffering() {
+            self.stop_output_buffering();
+            return Err(err);
+        }
+        Ok(())
+    }
+
+    fn stop_buffering(&mut self) {
+        self.stop_error_buffering();
+        self.stop_output_buffering();
+    }
+
+    fn buffered<F, R, E>(&mut self, func: F) -> Result<R, E>
+    where
+        E: From<NftError>,
+        F: FnOnce(&mut Self) -> Result<R, E>,
+    {
+        self.start_buffering()?;
+        let res = func(self);
+        self.stop_buffering();
+        res
+    }
+
+    fn current_error(&mut self) -> NftError {
+        match self.get_error_buffer() {
+            Ok(msg) => NftError(msg),
+            Err(err) => err,
+        }
+    }
+
+    pub unsafe fn run_buffer(&mut self, buffer: &CString) -> Result<String, NftError> {
+        self.buffered(|this| {
+            let rc = unsafe { nft_run_cmd_from_buffer(this.0, buffer.as_ptr()) };
+
+            if rc != 0 {
+                return Err(this.current_error());
+            }
+
+            this.get_output_buffer()
+        })
+    }
+
+    pub fn run_file<P: AsRef<Path>>(&mut self, filename: P) -> Result<String, NftError> {
+        use std::os::unix::ffi::OsStrExt;
+
+        let filename = CString::new(filename.as_ref().as_os_str().as_bytes())?;
+
+        self.buffered(move |this| {
+            let rc = unsafe { nft_run_cmd_from_filename(this.0, filename.as_ptr()) };
+            if rc != 0 {
+                return Err(this.current_error());
+            }
+            this.get_output_buffer()
+        })
+    }
+
+    pub fn run_nft_commands(&mut self, commands: &str) -> Result<String, NftError> {
+        let string = CString::new(commands).map_err(NftError::msg)?;
+
+        unsafe { self.run_buffer(&string) }
+    }
+
+    pub fn run_commands(&mut self, commands: &Commands) -> Result<Option<CommandOutput>, NftError> {
+        let json =
+            serde_json::to_vec(commands).expect("commands struct can be properly serialized");
+
+        let string = CString::new(json).expect("serialized json does not contain nul bytes");
+
+        let raw_output = unsafe { self.run_buffer(&string)? };
+
+        if raw_output.is_empty() {
+            return Ok(None);
+        }
+
+        serde_json::from_str::<CommandOutput>(&raw_output)
+            .map(Option::from)
+            .map_err(NftError::msg)
+    }
+}
diff --git a/proxmox-nftables/src/error.rs b/proxmox-nftables/src/error.rs
new file mode 100644
index 0000000..efdc13d
--- /dev/null
+++ b/proxmox-nftables/src/error.rs
@@ -0,0 +1,43 @@
+use std::fmt;
+use std::os::raw::c_int;
+
+#[derive(Debug)]
+pub struct NftError(pub(crate) String);
+
+impl std::error::Error for NftError {}
+
+impl fmt::Display for NftError {
+    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
+        write!(f, "nftables error: {}", self.0)
+    }
+}
+
+impl From<serde_json::Error> for NftError {
+    fn from(err: serde_json::Error) -> Self {
+        Self::msg(err)
+    }
+}
+
+impl NftError {
+    pub(crate) fn msg<T: fmt::Display>(msg: T) -> Self {
+        Self(msg.to_string())
+    }
+
+    pub(crate) fn expect_zero<F, T>(rc: c_int, or_else: F) -> Result<(), NftError>
+    where
+        F: FnOnce() -> T,
+        T: fmt::Display,
+    {
+        if rc == 0 {
+            Ok(())
+        } else {
+            Err(Self(or_else().to_string()))
+        }
+    }
+}
+
+impl From<std::ffi::NulError> for NftError {
+    fn from(err: std::ffi::NulError) -> Self {
+        Self::msg(err)
+    }
+}
diff --git a/proxmox-nftables/src/lib.rs b/proxmox-nftables/src/lib.rs
index 60ddb3f..61a6665 100644
--- a/proxmox-nftables/src/lib.rs
+++ b/proxmox-nftables/src/lib.rs
@@ -1,9 +1,12 @@
 pub mod command;
+pub mod context;
+pub mod error;
 pub mod expression;
 pub mod helper;
 pub mod statement;
 pub mod types;
 
 pub use command::Command;
+pub use context::NftCtx;
 pub use expression::Expression;
 pub use statement::Statement;
-- 
2.39.2




^ permalink raw reply	[flat|nested] 67+ messages in thread

* [pve-devel] [PATCH proxmox-firewall 26/37] firewall: add firewall crate
  2024-04-02 17:15 [pve-devel] [RFC container/firewall/manager/proxmox-firewall/qemu-server 00/37] proxmox firewall nftables implementation Stefan Hanreich
                   ` (24 preceding siblings ...)
  2024-04-02 17:16 ` [pve-devel] [PATCH proxmox-firewall 25/37] nftables: add libnftables bindings Stefan Hanreich
@ 2024-04-02 17:16 ` Stefan Hanreich
  2024-04-02 17:16 ` [pve-devel] [PATCH proxmox-firewall 27/37] firewall: add base ruleset Stefan Hanreich
                   ` (15 subsequent siblings)
  41 siblings, 0 replies; 67+ messages in thread
From: Stefan Hanreich @ 2024-04-02 17:16 UTC (permalink / raw)
  To: pve-devel; +Cc: Stefan Hanreich, Wolfgang Bumiller

Co-authored-by: Wolfgang Bumiller <w.bumiller@proxmox.com>
Signed-off-by: Stefan Hanreich <s.hanreich@proxmox.com>
---
 Cargo.toml                   |  1 +
 proxmox-firewall/Cargo.toml  | 17 +++++++++++++++++
 proxmox-firewall/src/main.rs |  5 +++++
 3 files changed, 23 insertions(+)
 create mode 100644 proxmox-firewall/Cargo.toml
 create mode 100644 proxmox-firewall/src/main.rs

diff --git a/Cargo.toml b/Cargo.toml
index 877f103..f353fbf 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -2,4 +2,5 @@
 members = [
     "proxmox-ve-config",
     "proxmox-nftables",
+    "proxmox-firewall",
 ]
diff --git a/proxmox-firewall/Cargo.toml b/proxmox-firewall/Cargo.toml
new file mode 100644
index 0000000..b59d973
--- /dev/null
+++ b/proxmox-firewall/Cargo.toml
@@ -0,0 +1,17 @@
+[package]
+name = "proxmox-firewall"
+version = "0.1.0"
+edition = "2021"
+authors = [
+    "Wolfgang Bumiller <w.bumiller@proxmox.com>",
+    "Stefan Hanreich <s.hanreich@proxmox.com>",
+    "Proxmox Support Team <support@proxmox.com>",
+]
+description = "Proxmox VE nftables firewall implementation"
+license = "AGPL-3"
+
+[dependencies]
+anyhow = "1"
+
+proxmox-nftables = { path = "../proxmox-nftables", features = ["config-ext"] }
+proxmox-ve-config = { path = "../proxmox-ve-config" }
diff --git a/proxmox-firewall/src/main.rs b/proxmox-firewall/src/main.rs
new file mode 100644
index 0000000..248ac39
--- /dev/null
+++ b/proxmox-firewall/src/main.rs
@@ -0,0 +1,5 @@
+use anyhow::Error;
+
+fn main() -> Result<(), Error> {
+    Ok(())
+}
-- 
2.39.2




^ permalink raw reply	[flat|nested] 67+ messages in thread

* [pve-devel] [PATCH proxmox-firewall 27/37] firewall: add base ruleset
  2024-04-02 17:15 [pve-devel] [RFC container/firewall/manager/proxmox-firewall/qemu-server 00/37] proxmox firewall nftables implementation Stefan Hanreich
                   ` (25 preceding siblings ...)
  2024-04-02 17:16 ` [pve-devel] [PATCH proxmox-firewall 26/37] firewall: add firewall crate Stefan Hanreich
@ 2024-04-02 17:16 ` Stefan Hanreich
  2024-04-02 17:16 ` [pve-devel] [PATCH proxmox-firewall 28/37] firewall: add config loader Stefan Hanreich
                   ` (14 subsequent siblings)
  41 siblings, 0 replies; 67+ messages in thread
From: Stefan Hanreich @ 2024-04-02 17:16 UTC (permalink / raw)
  To: pve-devel; +Cc: Stefan Hanreich, Wolfgang Bumiller

This is the skeleton for the firewall that contains all the base
chains required for the firewall.

The file applies atomically, which means that it flushes all objects
and recreates them - except for the cluster/host/guest chain. This
means that it can be run at any point in time, since it only updates
the chains that are not managed by the firewall itself.

This also means that when we change the rules in the chains (e.g.
during an update) we can always just re-run the nft-file and the
firewall should use the new chains while still retaining the
configuration generated by the firewall daemon.

This also means that when re-creating the firewall rules, the
cluster/host/guest chains need to be flushed manually before creating
new rules.

Co-authored-by: Wolfgang Bumiller <w.bumiller@proxmox.com>
Signed-off-by: Stefan Hanreich <s.hanreich@proxmox.com>
---
 .../resources/proxmox-firewall.nft            | 249 ++++++++++++++++++
 1 file changed, 249 insertions(+)
 create mode 100644 proxmox-firewall/resources/proxmox-firewall.nft

diff --git a/proxmox-firewall/resources/proxmox-firewall.nft b/proxmox-firewall/resources/proxmox-firewall.nft
new file mode 100644
index 0000000..08ebe15
--- /dev/null
+++ b/proxmox-firewall/resources/proxmox-firewall.nft
@@ -0,0 +1,249 @@
+#!/usr/sbin/nft -f
+
+define ipv6_mask = ffff:ffff:ffff:ffff::
+
+add table inet proxmox-firewall
+add table bridge proxmox-firewall-guests
+
+add chain inet proxmox-firewall do-reject
+add chain inet proxmox-firewall accept-management
+add chain inet proxmox-firewall block-synflood
+add chain inet proxmox-firewall log-drop-invalid-tcp
+add chain inet proxmox-firewall block-invalid-tcp
+add chain inet proxmox-firewall allow-ndp
+add chain inet proxmox-firewall block-conntrack-invalid
+add chain inet proxmox-firewall block-smurfs
+add chain inet proxmox-firewall log-drop-smurfs
+add chain inet proxmox-firewall default-in
+add chain inet proxmox-firewall default-out
+add chain inet proxmox-firewall input {type filter hook input priority filter; policy drop;}
+add chain inet proxmox-firewall output {type filter hook output priority filter; policy accept;}
+
+add chain bridge proxmox-firewall-guests allow-dhcp-in
+add chain bridge proxmox-firewall-guests allow-dhcp-out
+add chain bridge proxmox-firewall-guests allow-ndp
+add chain bridge proxmox-firewall-guests allow-ra
+add chain bridge proxmox-firewall-guests do-reject
+add chain bridge proxmox-firewall-guests vm-out {type filter hook prerouting priority 0; policy accept;}
+add chain bridge proxmox-firewall-guests vm-in {type filter hook postrouting priority 0; policy accept;}
+
+flush chain inet proxmox-firewall do-reject
+flush chain inet proxmox-firewall accept-management
+flush chain inet proxmox-firewall block-synflood
+flush chain inet proxmox-firewall log-drop-invalid-tcp
+flush chain inet proxmox-firewall block-invalid-tcp
+flush chain inet proxmox-firewall allow-ndp
+flush chain inet proxmox-firewall block-conntrack-invalid
+flush chain inet proxmox-firewall block-smurfs
+flush chain inet proxmox-firewall log-drop-smurfs
+flush chain inet proxmox-firewall default-in
+flush chain inet proxmox-firewall default-out
+flush chain inet proxmox-firewall input
+flush chain inet proxmox-firewall output
+
+flush chain bridge proxmox-firewall-guests allow-dhcp-in
+flush chain bridge proxmox-firewall-guests allow-dhcp-out
+flush chain bridge proxmox-firewall-guests allow-ndp
+flush chain bridge proxmox-firewall-guests allow-ra
+flush chain bridge proxmox-firewall-guests do-reject
+flush chain bridge proxmox-firewall-guests vm-out
+flush chain bridge proxmox-firewall-guests vm-in
+
+table inet proxmox-firewall {
+    chain do-reject {
+	meta pkttype broadcast drop
+	ip saddr 224.0.0.0/4 drop
+
+	meta l4proto tcp reject with tcp reset
+	meta l4proto icmp reject with icmp type port-unreachable
+	reject with icmp type host-prohibited
+    }
+
+    set v4-dc/management {
+        type ipv4_addr; flags interval; auto-merge
+    }
+
+    set v4-dc/management-nomatch {
+        type ipv4_addr; flags interval; auto-merge
+    }
+
+    set v6-dc/management {
+        type ipv6_addr; flags interval; auto-merge
+    }
+
+    set v6-dc/management-nomatch {
+        type ipv6_addr; flags interval; auto-merge
+    }
+
+    chain accept-management {
+	ip saddr @v4-dc/management ip saddr != @v4-dc/management-nomatch accept
+	ip6 saddr @v6-dc/management ip6 saddr != @v6-dc/management-nomatch accept
+    }
+
+    set v4-synflood-limit {
+	type ipv4_addr
+	timeout 60s
+	flags dynamic
+    }
+
+    set v6-synflood-limit {
+	type ipv6_addr
+	timeout 60s
+	flags dynamic
+    }
+
+    chain ratelimit-synflood {
+
+    }
+
+    # todo: move to prerouting
+    chain block-synflood {
+        tcp flags & (fin|syn|rst|ack) != syn return
+	jump ratelimit-synflood
+	drop
+    }
+
+    chain log-invalid-tcp {}
+
+    chain log-drop-invalid-tcp {
+	# looks weird but that way we can just flush the other chain
+	# when regenerating from the config
+	jump log-invalid-tcp
+	drop
+    }
+
+    chain block-invalid-tcp {
+        tcp flags & (fin|syn|rst|psh|ack|urg) == fin|psh|urg goto log-drop-invalid-tcp
+        tcp flags & (fin|syn|rst|psh|ack|urg) == 0x0 goto log-drop-invalid-tcp
+        tcp flags & (syn|rst) == syn|rst goto log-drop-invalid-tcp
+        tcp flags & (fin|syn) == fin|syn goto log-drop-invalid-tcp
+        tcp sport 0 tcp flags & (fin|syn|rst|ack) == syn goto log-drop-invalid-tcp
+    }
+
+    chain allow-ndp {
+	icmpv6 type { nd-router-solicit, nd-neighbor-solicit, nd-router-advert, nd-neighbor-advert } accept
+    }
+
+    chain block-conntrack-invalid {
+	ct state invalid drop
+    }
+
+    chain block-smurfs {
+	ip saddr 0.0.0.0/32 return
+        meta pkttype broadcast goto log-drop-smurfs
+        ip saddr 224.0.0.0/4 goto log-drop-smurfs
+    }
+
+    chain log-smurfs {}
+
+    chain log-drop-smurfs {
+	# looks weird but that way we can just flush the other chain
+	# when regenerating from the config
+	jump log-smurfs
+	drop
+    }
+
+    chain default-in {
+	iifname "lo" accept
+
+	ct state related,established accept
+
+	meta l4proto igmp accept
+
+	tcp dport { 8006, 5900-5999, 3128, 22 } jump accept-management
+	udp dport 5405-5412 accept
+
+	meta l4proto icmp icmp type { destination-unreachable, time-exceeded } accept
+
+        # Drop Microsoft SMB noise
+        udp dport { 135, 137-139, 445 } goto do-reject
+        udp sport 137 udp dport 1024-65535 goto do-reject
+        tcp dport { 135, 139, 445 } goto do-reject
+        udp dport 1900 drop
+
+        # Drop new/NotSyn traffic so that it doesn't get logged
+        # tcp flags & (fin | syn | rst | ack) == syn drop
+
+        # Drop DNS replies
+        udp sport 53 drop
+    }
+
+    chain default-out {
+	oifname "lo" accept
+
+	ct state invalid drop
+	ct state related,established accept
+    }
+
+    chain option-in {}
+    chain option-out {}
+
+    chain input {
+	type filter hook input priority filter; policy drop;
+	jump default-in
+	jump ct-in
+	jump option-in
+	jump host-in
+	jump cluster-in
+    }
+
+    chain output {
+	type filter hook output priority filter; policy accept;
+	jump default-out
+	jump option-out
+	jump host-out
+	jump cluster-out
+    }
+
+    chain cluster-in {}
+    chain cluster-out {}
+
+    chain host-in {}
+    chain host-out {}
+
+    chain ct-in {}
+}
+
+table bridge proxmox-firewall-guests {
+    map vm-map-in {
+	typeof oifname : verdict
+    }
+
+    map vm-map-out {
+	typeof iifname : verdict
+    }
+
+    chain allow-dhcp-in {
+	udp sport 67 udp dport 68 accept
+	udp sport 547 udp dport 546 accept
+    }
+
+    chain allow-dhcp-out {
+	udp sport 68 udp dport 67 accept
+	udp sport 546 udp dport 547 accept
+    }
+
+    chain allow-ndp {
+	icmpv6 type { nd-router-solicit, nd-neighbor-solicit, nd-router-advert, nd-neighbor-advert } accept
+    }
+
+    chain allow-ra {
+	icmpv6 type nd-router-advert accept
+    }
+
+    chain do-reject {
+	drop
+    }
+
+    chain vm-out {
+	type filter hook prerouting priority 0; policy accept;
+	ether type arp accept
+	iifname vmap @vm-map-out
+    }
+
+    chain vm-in {
+	type filter hook postrouting priority 0; policy accept;
+	ether type arp accept
+	oifname vmap @vm-map-in
+    }
+}
-- 
2.39.2




^ permalink raw reply	[flat|nested] 67+ messages in thread

* [pve-devel] [PATCH proxmox-firewall 28/37] firewall: add config loader
  2024-04-02 17:15 [pve-devel] [RFC container/firewall/manager/proxmox-firewall/qemu-server 00/37] proxmox firewall nftables implementation Stefan Hanreich
                   ` (26 preceding siblings ...)
  2024-04-02 17:16 ` [pve-devel] [PATCH proxmox-firewall 27/37] firewall: add base ruleset Stefan Hanreich
@ 2024-04-02 17:16 ` Stefan Hanreich
  2024-04-02 17:16 ` [pve-devel] [PATCH proxmox-firewall 29/37] firewall: add rule generation logic Stefan Hanreich
                   ` (13 subsequent siblings)
  41 siblings, 0 replies; 67+ messages in thread
From: Stefan Hanreich @ 2024-04-02 17:16 UTC (permalink / raw)
  To: pve-devel; +Cc: Stefan Hanreich, Wolfgang Bumiller

We load the firewall configuration from the default paths, as well as
only the guest configurations that are local to the node itself. In
the future we could change this to use pmxcfs directly instead.

We also load information from nftables directly about dynamically
created chains (mostly chains for the guest firewall).

Co-authored-by: Wolfgang Bumiller <w.bumiller@proxmox.com>
Signed-off-by: Stefan Hanreich <s.hanreich@proxmox.com>
---
 proxmox-firewall/Cargo.toml    |   2 +
 proxmox-firewall/src/config.rs | 163 +++++++++++++++++++++++++++++++++
 proxmox-firewall/src/main.rs   |   3 +
 3 files changed, 168 insertions(+)
 create mode 100644 proxmox-firewall/src/config.rs

diff --git a/proxmox-firewall/Cargo.toml b/proxmox-firewall/Cargo.toml
index b59d973..431e71a 100644
--- a/proxmox-firewall/Cargo.toml
+++ b/proxmox-firewall/Cargo.toml
@@ -11,6 +11,8 @@ description = "Proxmox VE nftables firewall implementation"
 license = "AGPL-3"
 
 [dependencies]
+log = "0.4"
+env_logger = "0.10"
 anyhow = "1"
 
 proxmox-nftables = { path = "../proxmox-nftables", features = ["config-ext"] }
diff --git a/proxmox-firewall/src/config.rs b/proxmox-firewall/src/config.rs
new file mode 100644
index 0000000..212d650
--- /dev/null
+++ b/proxmox-firewall/src/config.rs
@@ -0,0 +1,163 @@
+use std::collections::HashMap;
+use std::default::Default;
+use std::io;
+
+use anyhow::{anyhow, format_err, Error};
+
+use proxmox_ve_config::firewall::cluster::Config as ClusterConfig;
+use proxmox_ve_config::firewall::guest::Config as GuestConfig;
+use proxmox_ve_config::firewall::host::Config as HostConfig;
+use proxmox_ve_config::firewall::types::alias::{Alias, AliasName, AliasScope};
+
+use proxmox_ve_config::guest::types::Vmid;
+use proxmox_ve_config::guest::GuestMap;
+
+use proxmox_nftables::command::{Commands, List, ListOutput};
+use proxmox_nftables::types::ListChain;
+use proxmox_nftables::NftCtx;
+
+#[derive(Debug, Default)]
+pub struct NftConfig {
+    pub(crate) chains: HashMap<String, ListChain>,
+}
+
+impl NftConfig {
+    pub fn load() -> Result<Self, Error> {
+        let mut nft = NftCtx::new()?;
+
+        let commands = Commands::new(vec![List::chains()]);
+        let output = nft
+            .run_commands(&commands)?
+            .ok_or_else(|| format_err!("got no response from nft"))?;
+
+        let mut chains = HashMap::new();
+
+        for element in output.nftables {
+            if let ListOutput::Chain(chain) = element {
+                chains.insert(chain.name().to_owned(), chain);
+            }
+        }
+
+        Ok(Self { chains })
+    }
+}
+
+const CLUSTER_CONFIG_PATH: &str = "/etc/pve/firewall/cluster.fw";
+const HOST_CONFIG_PATH: &str = "/etc/pve/local/host.fw";
+
+#[derive(Debug)]
+pub struct FirewallConfig {
+    cluster: ClusterConfig,
+    guests: HashMap<Vmid, GuestConfig>,
+    host: HostConfig,
+    nft: NftConfig,
+}
+
+fn read_config_file(path: &str) -> Result<Option<Vec<u8>>, Error> {
+    match std::fs::read(path) {
+        Ok(data) => Ok(Some(data)),
+        Err(err) if err.kind() == io::ErrorKind::NotFound => {
+            log::debug!("config file not found: {path}");
+            Ok(None)
+        }
+        Err(err) => Err(anyhow!(err)),
+    }
+}
+
+impl FirewallConfig {
+    pub fn load() -> Result<Self, Error> {
+        log::debug!("loading cluster config");
+        let cluster_config = read_config_file(CLUSTER_CONFIG_PATH)?;
+
+        let cluster = match cluster_config {
+            Some(data) => ClusterConfig::parse(data.as_slice())?,
+            None => ClusterConfig::default(),
+        };
+
+        log::debug!("loading host config");
+        let host_config = read_config_file(HOST_CONFIG_PATH)?;
+
+        let host = match host_config {
+            Some(data) => HostConfig::parse(data.as_slice())?,
+            None => HostConfig::default(),
+        };
+
+        let guest_map = GuestMap::load()?;
+        let mut guests = HashMap::new();
+
+        for (vmid, guest) in guest_map.iter() {
+            if !guest.is_local() {
+                log::trace!("#{vmid} is not a local VM - skipping");
+                continue;
+            }
+
+            log::debug!("loading guest #{vmid} config");
+            let firewall_config = read_config_file(&guest_map.firewall_config_path(vmid))?;
+
+            if let Some(data) = firewall_config {
+                let config_path = guest_map
+                    .config_path_local(vmid)
+                    .ok_or_else(|| format_err!("could not find config for guest #{vmid}"))?;
+
+                let guest_config = std::fs::read(config_path)?;
+                let config = GuestConfig::parse(
+                    vmid,
+                    guest.ty().iface_prefix(),
+                    data.as_slice(),
+                    guest_config.as_slice(),
+                )?;
+
+                guests.insert(*vmid, config);
+            };
+        }
+
+        log::debug!("loading nft config");
+        let nft = NftConfig::load()?;
+
+        Ok(Self {
+            cluster,
+            guests,
+            host,
+            nft,
+        })
+    }
+
+    pub fn cluster(&self) -> &ClusterConfig {
+        &self.cluster
+    }
+
+    pub fn host(&self) -> &HostConfig {
+        &self.host
+    }
+
+    pub fn guests(&self) -> &HashMap<Vmid, GuestConfig> {
+        &self.guests
+    }
+
+    pub fn nft(&self) -> &NftConfig {
+        &self.nft
+    }
+
+    pub fn is_enabled(&self) -> bool {
+        self.cluster.is_enabled() && self.host.nftables()
+    }
+
+    pub fn alias(&self, name: &AliasName, vmid: Option<Vmid>) -> Option<&Alias> {
+        log::trace!("getting alias {name:?}");
+
+        match name.scope() {
+            AliasScope::Datacenter => self.cluster.alias(name.name()),
+            AliasScope::Guest => {
+                if let Some(vmid) = vmid {
+                    if let Some(entry) = self.guests.get(&vmid) {
+                        return entry.alias(name);
+                    }
+
+                    log::warn!("trying to get alias {name} for non-existing guest: #{vmid}");
+                }
+
+                None
+            }
+        }
+    }
+}
diff --git a/proxmox-firewall/src/main.rs b/proxmox-firewall/src/main.rs
index 248ac39..656ac15 100644
--- a/proxmox-firewall/src/main.rs
+++ b/proxmox-firewall/src/main.rs
@@ -1,5 +1,8 @@
 use anyhow::Error;
 
+mod config;
+
 fn main() -> Result<(), Error> {
+    env_logger::init();
     Ok(())
 }
-- 
2.39.2




^ permalink raw reply	[flat|nested] 67+ messages in thread

* [pve-devel] [PATCH proxmox-firewall 29/37] firewall: add rule generation logic
  2024-04-02 17:15 [pve-devel] [RFC container/firewall/manager/proxmox-firewall/qemu-server 00/37] proxmox firewall nftables implementation Stefan Hanreich
                   ` (27 preceding siblings ...)
  2024-04-02 17:16 ` [pve-devel] [PATCH proxmox-firewall 28/37] firewall: add config loader Stefan Hanreich
@ 2024-04-02 17:16 ` Stefan Hanreich
  2024-04-02 17:16 ` [pve-devel] [PATCH proxmox-firewall 30/37] firewall: add object " Stefan Hanreich
                   ` (12 subsequent siblings)
  41 siblings, 0 replies; 67+ messages in thread
From: Stefan Hanreich @ 2024-04-02 17:16 UTC (permalink / raw)
  To: pve-devel; +Cc: Stefan Hanreich, Wolfgang Bumiller

ToNftRules is basically a conversion trait for firewall config structs
to convert them into the respective nftables statements.

We are passing a list of rules to the method, which then modifies the
list of rules such that all relevant rules in the list have statements
appended that apply the configured constraints from the firewall
config.

This is particularly relevant for the rule generation logic for
ipsets. Due to how sets work in nftables we need to generate two rules
for every ipset: a rule for the v4 ipset and a rule for the v6 ipset.
This is because sets can only contain either v4 or v6 addresses. By
passing a list of all generated rules we can duplicate all rules and
then add a statement for the v4 or v6 set respectively.

This also enables us to start with multiple rules, which is required
for using log statements in conjunction with limit statements.

Co-authored-by: Wolfgang Bumiller <w.bumiller@proxmox.com>
Signed-off-by: Stefan Hanreich <s.hanreich@proxmox.com>
---
 proxmox-firewall/src/main.rs       |   1 +
 proxmox-firewall/src/rule.rs       | 659 +++++++++++++++++++++++++++++
 proxmox-nftables/src/expression.rs |   4 +
 3 files changed, 664 insertions(+)
 create mode 100644 proxmox-firewall/src/rule.rs

diff --git a/proxmox-firewall/src/main.rs b/proxmox-firewall/src/main.rs
index 656ac15..ae832e3 100644
--- a/proxmox-firewall/src/main.rs
+++ b/proxmox-firewall/src/main.rs
@@ -1,6 +1,7 @@
 use anyhow::Error;
 
 mod config;
+mod rule;
 
 fn main() -> Result<(), Error> {
     env_logger::init();
diff --git a/proxmox-firewall/src/rule.rs b/proxmox-firewall/src/rule.rs
new file mode 100644
index 0000000..eea79e3
--- /dev/null
+++ b/proxmox-firewall/src/rule.rs
@@ -0,0 +1,659 @@
+use std::ops::{Deref, DerefMut};
+
+use anyhow::{format_err, Error};
+use proxmox_nftables::{
+    expression::{Ct, IpFamily, Meta, Payload, Prefix},
+    statement::{Log, LogLevel, Match},
+    types::{AddRule, ChainPart, SetName},
+    Expression, Statement,
+};
+use proxmox_ve_config::{
+    firewall::{
+        ct_helper::CtHelperMacro,
+        fw_macros::{get_macro, FwMacro},
+        types::{
+            address::Family,
+            alias::AliasName,
+            ipset::{Ipfilter, IpsetName},
+            log::LogRateLimit,
+            rule::{Direction, Kind, RuleGroup},
+            rule_match::{
+                Icmp, Icmpv6, IpAddrMatch, IpMatch, Ports, Protocol, RuleMatch, Sctp, Tcp, Udp,
+            },
+            Alias, Rule,
+        },
+    },
+    guest::types::Vmid,
+};
+
+use crate::config::FirewallConfig;
+
+#[derive(Debug, Clone)]
+pub(crate) struct NftRule {
+    family: Option<Family>,
+    statements: Vec<Statement>,
+    terminal_statements: Vec<Statement>,
+}
+
+impl NftRule {
+    pub fn from_terminal_statements(terminal_statements: Vec<Statement>) -> Self {
+        Self {
+            family: None,
+            statements: Vec::new(),
+            terminal_statements,
+        }
+    }
+
+    pub fn new(terminal_statement: Statement) -> Self {
+        Self {
+            family: None,
+            statements: Vec::new(),
+            terminal_statements: vec![terminal_statement],
+        }
+    }
+
+    pub fn from_config_rule(rule: &Rule, env: &NftRuleEnv) -> Result<Vec<NftRule>, Error> {
+        let mut rules = Vec::new();
+
+        if rule.disabled() {
+            return Ok(rules);
+        }
+
+        rule.to_nft_rules(&mut rules, env)?;
+
+        Ok(rules)
+    }
+
+    pub fn from_ct_helper(
+        ct_helper: &CtHelperMacro,
+        env: &NftRuleEnv,
+    ) -> Result<Vec<NftRule>, Error> {
+        let mut rules = Vec::new();
+        ct_helper.to_nft_rules(&mut rules, env)?;
+        Ok(rules)
+    }
+
+    pub fn from_ipfilter(ipfilter: &Ipfilter, env: &NftRuleEnv) -> Result<Vec<NftRule>, Error> {
+        let mut rules = Vec::new();
+        ipfilter.to_nft_rules(&mut rules, env)?;
+        Ok(rules)
+    }
+}
+
+impl Deref for NftRule {
+    type Target = Vec<Statement>;
+
+    fn deref(&self) -> &Self::Target {
+        &self.statements
+    }
+}
+
+impl DerefMut for NftRule {
+    fn deref_mut(&mut self) -> &mut Self::Target {
+        &mut self.statements
+    }
+}
+
+impl NftRule {
+    pub fn into_add_rule(self, chain: ChainPart) -> AddRule {
+        let statements = self.statements.into_iter().chain(self.terminal_statements);
+
+        AddRule::from_statements(chain, statements)
+    }
+
+    pub fn family(&self) -> Option<Family> {
+        self.family
+    }
+
+    pub fn set_family(&mut self, family: Family) {
+        self.family = Some(family);
+    }
+}
+
+pub(crate) struct NftRuleEnv<'a> {
+    pub(crate) chain: ChainPart,
+    pub(crate) direction: Direction,
+    pub(crate) firewall_config: &'a FirewallConfig,
+    pub(crate) vmid: Option<Vmid>,
+}
+
+impl NftRuleEnv<'_> {
+    fn alias(&self, name: &AliasName) -> Option<&Alias> {
+        self.firewall_config.alias(name, self.vmid)
+    }
+
+    fn iface_name(&self, rule_iface: &str) -> String {
+        match &self.vmid {
+            Some(vmid) => {
+                if let Some(config) = self.firewall_config.guests().get(vmid) {
+                    if let Ok(name) = config.iface_name_by_key(rule_iface) {
+                        return name;
+                    }
+                }
+
+                log::warn!("Unable to resolve interface name {rule_iface} for VM #{vmid}");
+
+                rule_iface.to_string()
+            }
+            None => rule_iface.to_string(),
+        }
+    }
+
+    fn default_log_limit(&self) -> Option<LogRateLimit> {
+        self.firewall_config.cluster().log_ratelimit()
+    }
+
+    fn contains_family(&self, family: Family) -> bool {
+        self.chain.table().family().families().contains(&family)
+    }
+}
+
+pub(crate) trait ToNftRules {
+    fn to_nft_rules(&self, rules: &mut Vec<NftRule>, env: &NftRuleEnv) -> Result<(), Error>;
+}
+
+impl ToNftRules for Rule {
+    fn to_nft_rules(&self, rules: &mut Vec<NftRule>, env: &NftRuleEnv) -> Result<(), Error> {
+        match self.kind() {
+            Kind::Match(rule) => rule.to_nft_rules(rules, env)?,
+            Kind::Group(group) => group.to_nft_rules(rules, env)?,
+        };
+
+        Ok(())
+    }
+}
+
+fn handle_iface(rules: &mut [NftRule], env: &NftRuleEnv, name: &str) -> Result<(), Error> {
+    let iface_key = match (env.vmid, env.direction) {
+        (Some(_), Direction::In) => "oifname",
+        (Some(_), Direction::Out) => "iifname",
+        (None, Direction::In) => "iifname",
+        (None, Direction::Out) => "oifname",
+    };
+
+    let iface_name = env.iface_name(name);
+
+    for rule in rules.iter_mut() {
+        rule.push(
+            Match::new_eq(
+                Expression::from(Meta::new(iface_key.to_string())),
+                Expression::from(iface_name.clone()),
+            )
+            .into(),
+        )
+    }
+
+    Ok(())
+}
+
+impl ToNftRules for RuleGroup {
+    fn to_nft_rules(&self, rules: &mut Vec<NftRule>, env: &NftRuleEnv) -> Result<(), Error> {
+        let chain_name = format!("group-{}-{}", self.group(), env.direction);
+
+        rules.push(NftRule::new(Statement::jump(chain_name)));
+
+        if let Some(name) = &self.iface() {
+            handle_iface(rules, env, name)?;
+        }
+
+        Ok(())
+    }
+}
+
+impl ToNftRules for RuleMatch {
+    fn to_nft_rules(&self, rules: &mut Vec<NftRule>, env: &NftRuleEnv) -> Result<(), Error> {
+        if env.direction != self.direction() {
+            return Ok(());
+        }
+
+        if let Some(log) = self.log() {
+            if let Ok(log_level) = LogLevel::try_from(log) {
+                let mut terminal_statements = Vec::new();
+
+                if let Some(limit) = env.default_log_limit() {
+                    terminal_statements.push(Statement::from(limit));
+                }
+
+                terminal_statements.push(
+                    Log::new_nflog(
+                        Log::generate_prefix(env.vmid, log_level, env.chain.name(), self.verdict()),
+                        0,
+                    )
+                    .into(),
+                );
+
+                rules.push(NftRule::from_terminal_statements(terminal_statements));
+            }
+        }
+
+        rules.push(NftRule::new(Statement::from(self.verdict())));
+
+        if let Some(name) = &self.iface() {
+            handle_iface(rules, env, name)?;
+        }
+
+        if let Some(protocol) = self.proto() {
+            protocol.to_nft_rules(rules, env)?;
+        }
+
+        if let Some(name) = self.fw_macro() {
+            let fw_macro =
+                get_macro(name).ok_or_else(|| format_err!("cannot find macro {name}"))?;
+
+            fw_macro.to_nft_rules(rules, env)?;
+        }
+
+        if let Some(ip) = self.ip() {
+            ip.to_nft_rules(rules, env)?;
+        }
+
+        Ok(())
+    }
+}
+
+fn handle_set(
+    rules: &mut Vec<NftRule>,
+    name: &IpsetName,
+    field_name: &str,
+    env: &NftRuleEnv,
+) -> Result<(), Error> {
+    let mut new_rules = rules
+        .drain(..)
+        .flat_map(|rule| {
+            let mut new_rules = Vec::new();
+
+            if matches!(rule.family(), Some(Family::V4) | None) && env.contains_family(Family::V4) {
+                let field = Payload::field("ip", field_name);
+
+                let mut rule = rule.clone();
+                rule.set_family(Family::V4);
+
+                rule.append(&mut vec![
+                    Match::new_eq(
+                        field.clone(),
+                        Expression::set_name(&SetName::ipset_name(Family::V4, name, false)),
+                    )
+                    .into(),
+                    Match::new_ne(
+                        field,
+                        Expression::set_name(&SetName::ipset_name(Family::V4, name, true)),
+                    )
+                    .into(),
+                ]);
+
+                new_rules.push(rule);
+            }
+
+            if matches!(rule.family(), Some(Family::V6) | None) && env.contains_family(Family::V6) {
+                let field = Payload::field("ip6", field_name);
+
+                let mut rule = rule;
+                rule.set_family(Family::V6);
+
+                rule.append(&mut vec![
+                    Match::new_eq(
+                        field.clone(),
+                        Expression::set_name(&SetName::ipset_name(Family::V6, name, false)),
+                    )
+                    .into(),
+                    Match::new_ne(
+                        field,
+                        Expression::set_name(&SetName::ipset_name(Family::V6, name, true)),
+                    )
+                    .into(),
+                ]);
+
+                new_rules.push(rule);
+            }
+
+            new_rules
+        })
+        .collect::<Vec<NftRule>>();
+
+    rules.append(&mut new_rules);
+
+    Ok(())
+}
+
+fn handle_match(
+    rules: &mut Vec<NftRule>,
+    ip: &IpAddrMatch,
+    field_name: &str,
+    env: &NftRuleEnv,
+) -> Result<(), Error> {
+    match ip {
+        IpAddrMatch::Ip(list) => {
+            if !env.contains_family(list.family()) {
+                return Ok(());
+            }
+
+            let field = match list.family() {
+                Family::V4 => Payload::field("ip", field_name),
+                Family::V6 => Payload::field("ip6", field_name),
+            };
+
+            for rule in rules {
+                match rule.family() {
+                    None => {
+                        rule.push(Match::new_eq(field.clone(), Expression::from(list)).into());
+
+                        rule.set_family(list.family());
+                    }
+                    Some(rule_family) if rule_family == list.family() => {
+                        rule.push(Match::new_eq(field.clone(), Expression::from(list)).into());
+                    }
+                    _ => (),
+                };
+            }
+
+            Ok(())
+        }
+        IpAddrMatch::Alias(alias_name) => {
+            let alias = env
+                .alias(alias_name)
+                .ok_or_else(|| format_err!("could not find alias {alias_name}"))?;
+
+            if !env.contains_family(alias.address().family()) {
+                return Ok(());
+            }
+
+            let field = match alias.address().family() {
+                Family::V4 => Payload::field("ip", field_name),
+                Family::V6 => Payload::field("ip6", field_name),
+            };
+
+            for rule in rules {
+                match rule.family() {
+                    None => {
+                        rule.push(
+                            Match::new_eq(
+                                field.clone(),
+                                Expression::from(Prefix::from(alias.address())),
+                            )
+                            .into(),
+                        );
+
+                        rule.set_family(alias.address().family());
+                    }
+                    Some(rule_family) if rule_family == alias.address().family() => {
+                        rule.push(
+                            Match::new_eq(
+                                field.clone(),
+                                Expression::from(Prefix::from(alias.address())),
+                            )
+                            .into(),
+                        );
+                    }
+                    _ => (),
+                }
+            }
+
+            Ok(())
+        }
+        IpAddrMatch::Set(name) => handle_set(rules, name, field_name, env),
+    }
+}
+
+impl ToNftRules for IpMatch {
+    fn to_nft_rules(&self, rules: &mut Vec<NftRule>, env: &NftRuleEnv) -> Result<(), Error> {
+        if let Some(src) = self.src() {
+            handle_match(rules, src, "saddr", env)?;
+        }
+
+        if let Some(dst) = self.dst() {
+            handle_match(rules, dst, "daddr", env)?;
+        }
+
+        Ok(())
+    }
+}
+
+fn handle_protocol(rules: &mut [NftRule], _env: &NftRuleEnv, name: &str) -> Result<(), Error> {
+    for rule in rules.iter_mut() {
+        rule.push(Match::new_eq(Meta::new("l4proto"), Expression::from(name)).into());
+    }
+
+    Ok(())
+}
+
+impl ToNftRules for Protocol {
+    fn to_nft_rules(&self, rules: &mut Vec<NftRule>, env: &NftRuleEnv) -> Result<(), Error> {
+        match self {
+            Protocol::Tcp(tcp) => tcp.to_nft_rules(rules, env),
+            Protocol::Udp(udp) => udp.to_nft_rules(rules, env),
+            Protocol::Dccp(ports) => {
+                handle_protocol(rules, env, "dccp")?;
+                ports.to_nft_rules(rules, env)
+            }
+            Protocol::UdpLite(ports) => {
+                handle_protocol(rules, env, "udplite")?;
+                ports.to_nft_rules(rules, env)
+            }
+            Protocol::Sctp(sctp) => sctp.to_nft_rules(rules, env),
+            Protocol::Icmp(icmp) => icmp.to_nft_rules(rules, env),
+            Protocol::Icmpv6(icmpv6) => icmpv6.to_nft_rules(rules, env),
+            Protocol::Named(name) => handle_protocol(rules, env, name),
+            Protocol::Numeric(id) => {
+                for rule in rules.iter_mut() {
+                    rule.push(Match::new_eq(Meta::new("l4proto"), Expression::from(*id)).into());
+                }
+
+                Ok(())
+            }
+        }
+    }
+}
+
+impl ToNftRules for Tcp {
+    fn to_nft_rules(&self, rules: &mut Vec<NftRule>, env: &NftRuleEnv) -> Result<(), Error> {
+        handle_protocol(rules, env, "tcp")?;
+        self.ports().to_nft_rules(rules, env)
+    }
+}
+
+impl ToNftRules for Udp {
+    fn to_nft_rules(&self, rules: &mut Vec<NftRule>, env: &NftRuleEnv) -> Result<(), Error> {
+        handle_protocol(rules, env, "udp")?;
+        self.ports().to_nft_rules(rules, env)
+    }
+}
+
+impl ToNftRules for Sctp {
+    fn to_nft_rules(&self, rules: &mut Vec<NftRule>, env: &NftRuleEnv) -> Result<(), Error> {
+        handle_protocol(rules, env, "sctp")?;
+        self.ports().to_nft_rules(rules, env)
+    }
+}
+
+impl ToNftRules for Icmp {
+    fn to_nft_rules(&self, rules: &mut Vec<NftRule>, _env: &NftRuleEnv) -> Result<(), Error> {
+        for rule in rules.iter_mut() {
+            if matches!(rule.family(), Some(Family::V4) | None) {
+                if let Some(icmp_code) = self.code() {
+                    rule.push(
+                        Match::new_eq(Payload::field("icmp", "code"), Expression::from(icmp_code))
+                            .into(),
+                    );
+                } else if let Some(icmp_type) = self.ty() {
+                    rule.push(
+                        Match::new_eq(Payload::field("icmp", "type"), Expression::from(icmp_type))
+                            .into(),
+                    );
+                } else {
+                    rule.push(Match::new_eq(Meta::new("l4proto"), Expression::from("icmp")).into());
+                }
+
+                rule.set_family(Family::V4);
+            }
+        }
+
+        Ok(())
+    }
+}
+
+impl ToNftRules for Icmpv6 {
+    fn to_nft_rules(&self, rules: &mut Vec<NftRule>, _env: &NftRuleEnv) -> Result<(), Error> {
+        log::trace!("applying icmpv6: {:?}", self);
+
+        for rule in rules.iter_mut() {
+            if matches!(rule.family(), Some(Family::V6) | None) {
+                if let Some(icmp_code) = self.code() {
+                    rule.push(
+                        Match::new_eq(
+                            Payload::field("icmpv6", "code"),
+                            Expression::from(icmp_code),
+                        )
+                        .into(),
+                    );
+                } else if let Some(icmp_type) = self.ty() {
+                    rule.push(
+                        Match::new_eq(
+                            Payload::field("icmpv6", "type"),
+                            Expression::from(icmp_type),
+                        )
+                        .into(),
+                    );
+                } else {
+                    rule.push(
+                        Match::new_eq(Meta::new("l4proto"), Expression::from("icmpv6")).into(),
+                    );
+                }
+
+                rule.set_family(Family::V6);
+            }
+        }
+
+        Ok(())
+    }
+}
+
+impl ToNftRules for Ports {
+    fn to_nft_rules(&self, rules: &mut Vec<NftRule>, _env: &NftRuleEnv) -> Result<(), Error> {
+        for rule in rules {
+            if let Some(sport) = self.sport() {
+                rule.push(
+                    Match::new_eq(
+                        Expression::from(Payload::field("th", "sport")),
+                        Expression::from(sport),
+                    )
+                    .into(),
+                )
+            }
+
+            if let Some(dport) = self.dport() {
+                rule.push(
+                    Match::new_eq(
+                        Expression::from(Payload::field("th", "dport")),
+                        Expression::from(dport),
+                    )
+                    .into(),
+                )
+            }
+        }
+
+        Ok(())
+    }
+}
+
+impl ToNftRules for Ipfilter<'_> {
+    fn to_nft_rules(&self, rules: &mut Vec<NftRule>, env: &NftRuleEnv) -> Result<(), Error> {
+        let vmid = env
+            .vmid
+            .ok_or_else(|| format_err!("can only create ipfilter for guests"))?;
+
+        let guest_config = env
+            .firewall_config
+            .guests()
+            .get(&vmid)
+            .ok_or_else(|| format_err!("no guest config found!"))?;
+
+        if !guest_config.ipfilter() {
+            return Ok(());
+        }
+
+        let mut base_rule = NftRule::new(Statement::make_drop());
+
+        base_rule.push(
+            Match::new_eq(
+                Expression::from(Meta::new("iifname")),
+                guest_config.iface_name_by_index(self.index()),
+            )
+            .into(),
+        );
+
+        handle_set(rules, self.ipset().name(), "saddr", env)
+    }
+}
+
+impl ToNftRules for CtHelperMacro {
+    fn to_nft_rules(&self, rules: &mut Vec<NftRule>, env: &NftRuleEnv) -> Result<(), Error> {
+        if let Some(family) = self.family() {
+            if !env.contains_family(family) {
+                return Ok(());
+            }
+        }
+
+        if self.tcp().is_none() && self.udp().is_none() {
+            return Ok(());
+        }
+
+        let ip_family = self.family().map(IpFamily::from);
+
+        if let Some(protocol) = self.tcp() {
+            let base_rule = NftRule::from_terminal_statements(vec![
+                Match::new_eq(
+                    Ct::new("state", None),
+                    Expression::List(vec!["new".into(), "established".into()]),
+                )
+                .into(),
+                Statement::make_accept(),
+            ]);
+
+            let helper_rule = NftRule::new(Statement::CtHelper(self.tcp_helper_name()));
+
+            let mut ct_rules = vec![base_rule, helper_rule];
+            protocol.to_nft_rules(&mut ct_rules, env)?;
+            rules.append(&mut ct_rules);
+        }
+
+        if let Some(protocol) = self.udp() {
+            let base_rule = NftRule::from_terminal_statements(vec![
+                Match::new_eq(
+                    Ct::new("state", None),
+                    Expression::List(vec!["new".into(), "established".into()]),
+                )
+                .into(),
+                Statement::make_accept(),
+            ]);
+
+            let helper_rule = NftRule::new(Statement::CtHelper(self.udp_helper_name()));
+
+            let mut ct_rules = vec![base_rule, helper_rule];
+            protocol.to_nft_rules(&mut ct_rules, env)?;
+            rules.append(&mut ct_rules);
+        }
+
+        let mut ct_helper_rule = NftRule::new(Statement::make_accept());
+
+        ct_helper_rule.push(Match::new_eq(Ct::new("helper", ip_family), self.name()).into());
+
+        rules.push(ct_helper_rule);
+
+        Ok(())
+    }
+}
+
+impl ToNftRules for FwMacro {
+    fn to_nft_rules(&self, rules: &mut Vec<NftRule>, env: &NftRuleEnv) -> Result<(), Error> {
+        let initial_rules: Vec<NftRule> = rules.drain(..).collect();
+
+        for protocol in &self.code {
+            let mut new_rules = initial_rules.to_vec();
+            protocol.to_nft_rules(&mut new_rules, env)?;
+
+            rules.append(&mut new_rules);
+        }
+
+        Ok(())
+    }
+}
diff --git a/proxmox-nftables/src/expression.rs b/proxmox-nftables/src/expression.rs
index 067eccc..dadaf92 100644
--- a/proxmox-nftables/src/expression.rs
+++ b/proxmox-nftables/src/expression.rs
@@ -59,6 +59,10 @@ impl Expression {
     pub fn concat(expressions: impl IntoIterator<Item = Expression>) -> Self {
         Expression::Concat(NfVec::from_iter(expressions))
     }
+
+    pub fn set_name(name: &str) -> Self {
+        Expression::String(format!("@{name}"))
+    }
 }
 
 impl From<bool> for Expression {
-- 
2.39.2




^ permalink raw reply	[flat|nested] 67+ messages in thread

* [pve-devel] [PATCH proxmox-firewall 30/37] firewall: add object generation logic
  2024-04-02 17:15 [pve-devel] [RFC container/firewall/manager/proxmox-firewall/qemu-server 00/37] proxmox firewall nftables implementation Stefan Hanreich
                   ` (28 preceding siblings ...)
  2024-04-02 17:16 ` [pve-devel] [PATCH proxmox-firewall 29/37] firewall: add rule generation logic Stefan Hanreich
@ 2024-04-02 17:16 ` Stefan Hanreich
  2024-04-02 17:16 ` [pve-devel] [PATCH proxmox-firewall 31/37] firewall: add ruleset " Stefan Hanreich
                   ` (11 subsequent siblings)
  41 siblings, 0 replies; 67+ messages in thread
From: Stefan Hanreich @ 2024-04-02 17:16 UTC (permalink / raw)
  To: pve-devel; +Cc: Stefan Hanreich, Wolfgang Bumiller

ToNftObjects is basically a conversion trait that converts firewall
config structs into nftables objects. It returns a list of commands
that create the respective nftables objects.

Co-authored-by: Wolfgang Bumiller <w.bumiller@proxmox.com>
Signed-off-by: Stefan Hanreich <s.hanreich@proxmox.com>
---
 proxmox-firewall/src/main.rs   |   1 +
 proxmox-firewall/src/object.rs | 139 +++++++++++++++++++++++++++++++++
 2 files changed, 140 insertions(+)
 create mode 100644 proxmox-firewall/src/object.rs

diff --git a/proxmox-firewall/src/main.rs b/proxmox-firewall/src/main.rs
index ae832e3..a4979a7 100644
--- a/proxmox-firewall/src/main.rs
+++ b/proxmox-firewall/src/main.rs
@@ -1,6 +1,7 @@
 use anyhow::Error;
 
 mod config;
+mod object;
 mod rule;
 
 fn main() -> Result<(), Error> {
diff --git a/proxmox-firewall/src/object.rs b/proxmox-firewall/src/object.rs
new file mode 100644
index 0000000..ec0c0b1
--- /dev/null
+++ b/proxmox-firewall/src/object.rs
@@ -0,0 +1,139 @@
+use anyhow::{format_err, Error};
+use proxmox_nftables::{
+    command::{Add, Flush},
+    expression::Prefix,
+    types::{
+        AddCtHelper, AddElement, CtHelperProtocol, ElementType, L3Protocol, SetConfig, SetFlag,
+        SetName, TablePart,
+    },
+    Command, Expression,
+};
+use proxmox_ve_config::{
+    firewall::{
+        ct_helper::CtHelperMacro,
+        types::{address::Family, alias::AliasName, ipset::IpsetAddress, Alias, Ipset},
+    },
+    guest::types::Vmid,
+};
+
+use crate::config::FirewallConfig;
+
+pub(crate) struct NftObjectEnv<'a, 'b> {
+    pub(crate) table: &'a TablePart,
+    pub(crate) firewall_config: &'b FirewallConfig,
+    pub(crate) vmid: Option<Vmid>,
+}
+
+impl NftObjectEnv<'_, '_> {
+    pub(crate) fn alias(&self, name: &AliasName) -> Option<&Alias> {
+        self.firewall_config.alias(name, self.vmid)
+    }
+}
+
+pub(crate) trait ToNftObjects {
+    fn to_nft_objects(&self, env: &NftObjectEnv) -> Result<Vec<Command>, Error>;
+}
+
+impl ToNftObjects for CtHelperMacro {
+    fn to_nft_objects(&self, env: &NftObjectEnv) -> Result<Vec<Command>, Error> {
+        let mut commands = Vec::new();
+
+        if let Some(_protocol) = self.tcp() {
+            commands.push(Add::ct_helper(AddCtHelper {
+                table: env.table.clone(),
+                name: self.tcp_helper_name(),
+                ty: self.name().to_string(),
+                protocol: CtHelperProtocol::TCP,
+                l3proto: self.family().map(L3Protocol::from),
+            }));
+        }
+
+        if let Some(_protocol) = self.udp() {
+            commands.push(Add::ct_helper(AddCtHelper {
+                table: env.table.clone(),
+                name: self.udp_helper_name(),
+                ty: self.name().to_string(),
+                protocol: CtHelperProtocol::UDP,
+                l3proto: self.family().map(L3Protocol::from),
+            }));
+        }
+
+        Ok(commands)
+    }
+}
+
+impl ToNftObjects for Ipset {
+    fn to_nft_objects(&self, env: &NftObjectEnv) -> Result<Vec<Command>, Error> {
+        let mut commands = Vec::new();
+
+        for family in env.table.family().families() {
+            let mut elements = Vec::new();
+            let mut nomatch_elements = Vec::new();
+
+            for element in self.iter() {
+                let cidr = match &element.address {
+                    IpsetAddress::Cidr(cidr) => cidr,
+                    IpsetAddress::Alias(alias) => env
+                        .alias(alias)
+                        .ok_or(format_err!("could not find alias {alias} in environment"))?
+                        .address(),
+                };
+
+                if family != cidr.family() {
+                    continue;
+                }
+
+                let expression = Expression::from(Prefix::from(cidr));
+
+                if element.nomatch {
+                    nomatch_elements.push(expression);
+                } else {
+                    elements.push(expression);
+                }
+            }
+
+            let element_type = match family {
+                Family::V4 => ElementType::Ipv4Addr,
+                Family::V6 => ElementType::Ipv6Addr,
+            };
+
+            let set_name = SetName::new(
+                env.table.clone(),
+                SetName::ipset_name(family, self.name(), false),
+            );
+
+            let set_config =
+                SetConfig::new(set_name.clone(), vec![element_type]).with_flag(SetFlag::Interval);
+
+            let nomatch_name = SetName::new(
+                env.table.clone(),
+                SetName::ipset_name(family, self.name(), true),
+            );
+
+            let nomatch_config = SetConfig::new(nomatch_name.clone(), vec![element_type])
+                .with_flag(SetFlag::Interval);
+
+            commands.append(&mut vec![
+                Add::set(set_config),
+                Flush::set(set_name.clone()),
+                Add::set(nomatch_config),
+                Flush::set(nomatch_name.clone()),
+            ]);
+
+            if !elements.is_empty() {
+                commands.push(Add::element(AddElement::set_from_expressions(
+                    set_name, elements,
+                )));
+            }
+
+            if !nomatch_elements.is_empty() {
+                commands.push(Add::element(AddElement::set_from_expressions(
+                    nomatch_name,
+                    nomatch_elements,
+                )));
+            }
+        }
+
+        Ok(commands)
+    }
+}
-- 
2.39.2




^ permalink raw reply	[flat|nested] 67+ messages in thread

* [pve-devel] [PATCH proxmox-firewall 31/37] firewall: add ruleset generation logic
  2024-04-02 17:15 [pve-devel] [RFC container/firewall/manager/proxmox-firewall/qemu-server 00/37] proxmox firewall nftables implementation Stefan Hanreich
                   ` (29 preceding siblings ...)
  2024-04-02 17:16 ` [pve-devel] [PATCH proxmox-firewall 30/37] firewall: add object " Stefan Hanreich
@ 2024-04-02 17:16 ` Stefan Hanreich
  2024-04-02 17:16 ` [pve-devel] [PATCH proxmox-firewall 32/37] firewall: add proxmox-firewall binary Stefan Hanreich
                   ` (10 subsequent siblings)
  41 siblings, 0 replies; 67+ messages in thread
From: Stefan Hanreich @ 2024-04-02 17:16 UTC (permalink / raw)
  To: pve-devel; +Cc: Stefan Hanreich, Wolfgang Bumiller

We create the rules from the firewall config by utilizing the
ToNftRules and ToNftObjects traits to convert the firewall config
structs to nftables objects/chains/rules.

Co-authored-by: Wolfgang Bumiller <w.bumiller@proxmox.com>
Signed-off-by: Stefan Hanreich <s.hanreich@proxmox.com>
---
 proxmox-firewall/Cargo.toml      |   3 +
 proxmox-firewall/src/firewall.rs | 726 +++++++++++++++++++++++++++++++
 proxmox-firewall/src/main.rs     |   1 +
 3 files changed, 730 insertions(+)
 create mode 100644 proxmox-firewall/src/firewall.rs

diff --git a/proxmox-firewall/Cargo.toml b/proxmox-firewall/Cargo.toml
index 431e71a..1e6a4b8 100644
--- a/proxmox-firewall/Cargo.toml
+++ b/proxmox-firewall/Cargo.toml
@@ -15,5 +15,8 @@ log = "0.4"
 env_logger = "0.10"
 anyhow = "1"
 
+serde = { version = "1", features = [ "derive" ] }
+serde_json = "1"
+
 proxmox-nftables = { path = "../proxmox-nftables", features = ["config-ext"] }
 proxmox-ve-config = { path = "../proxmox-ve-config" }
diff --git a/proxmox-firewall/src/firewall.rs b/proxmox-firewall/src/firewall.rs
new file mode 100644
index 0000000..9947bee
--- /dev/null
+++ b/proxmox-firewall/src/firewall.rs
@@ -0,0 +1,726 @@
+use std::collections::HashMap;
+use std::fs;
+
+use anyhow::Error;
+
+use proxmox_nftables::command::{Add, Commands, Delete, Flush};
+use proxmox_nftables::expression::{Meta, Payload};
+use proxmox_nftables::helper::NfVec;
+use proxmox_nftables::statement::{AnonymousLimit, Log, LogLevel, Match, Set, SetOperation};
+use proxmox_nftables::types::{
+    AddElement, AddRule, ChainPart, MapValue, RateTimescale, SetName, TableFamily, TablePart,
+    Verdict,
+};
+use proxmox_nftables::{Expression, Statement};
+
+use proxmox_ve_config::firewall::ct_helper::get_cthelper;
+use proxmox_ve_config::firewall::guest::Config as GuestConfig;
+use proxmox_ve_config::firewall::host::Config as HostConfig;
+
+use proxmox_ve_config::firewall::types::ipset::{Ipset, IpsetEntry, IpsetScope};
+use proxmox_ve_config::firewall::types::log::{LogLevel as ConfigLogLevel, LogRateLimit};
+use proxmox_ve_config::firewall::types::rule::{Direction, Verdict as ConfigVerdict};
+use proxmox_ve_config::firewall::types::Group;
+use proxmox_ve_config::guest::types::Vmid;
+
+use crate::config::FirewallConfig;
+use crate::object::{NftObjectEnv, ToNftObjects};
+use crate::rule::{NftRule, NftRuleEnv};
+
+static CLUSTER_TABLE_NAME: &str = "proxmox-firewall";
+static HOST_TABLE_NAME: &str = "proxmox-firewall";
+static GUEST_TABLE_NAME: &str = "proxmox-firewall-guests";
+
+static NF_CONNTRACK_MAX_FILE: &str = "/proc/sys/net/netfilter/nf_conntrack_max";
+static NF_CONNTRACK_TCP_TIMEOUT_ESTABLISHED: &str =
+    "/proc/sys/net/netfilter/nf_conntrack_tcp_timeout_established";
+static NF_CONNTRACK_TCP_TIMEOUT_SYN_RECV: &str =
+    "/proc/sys/net/netfilter/nf_conntrack_tcp_timeout_syn_recv";
+static LOG_CONNTRACK_FILE: &str = "/var/lib/pve-firewall/log_nf_conntrack";
+
+#[derive(Debug)]
+pub struct Firewall {
+    config: FirewallConfig,
+}
+
+impl Firewall {
+    pub fn new() -> Result<Self, Error> {
+        Ok(Self {
+            config: FirewallConfig::load()?,
+        })
+    }
+
+    pub fn is_enabled(&self) -> bool {
+        self.config.is_enabled()
+    }
+
+    fn cluster_table(&self) -> TablePart {
+        TablePart::new(TableFamily::Inet, CLUSTER_TABLE_NAME)
+    }
+
+    fn host_table(&self) -> TablePart {
+        TablePart::new(TableFamily::Inet, HOST_TABLE_NAME)
+    }
+
+    fn guest_table(&self) -> TablePart {
+        TablePart::new(TableFamily::Bridge, GUEST_TABLE_NAME)
+    }
+
+    fn guest_vmap(&self, dir: Direction) -> SetName {
+        SetName::new(self.guest_table(), format!("vm-map-{dir}"))
+    }
+
+    fn cluster_chain(&self, dir: Direction) -> ChainPart {
+        ChainPart::new(self.cluster_table(), format!("cluster-{dir}"))
+    }
+
+    fn host_chain(&self, dir: Direction) -> ChainPart {
+        ChainPart::new(self.host_table(), format!("host-{dir}"))
+    }
+
+    fn guest_chain(&self, dir: Direction, vmid: Vmid) -> ChainPart {
+        ChainPart::new(self.guest_table(), format!("guest-{vmid}-{dir}"))
+    }
+
+    fn group_chain(&self, table: TablePart, name: &str, dir: Direction) -> ChainPart {
+        ChainPart::new(table, format!("group-{name}-{dir}"))
+    }
+
+    fn host_conntrack_chain(&self) -> ChainPart {
+        ChainPart::new(self.host_table(), "ct-in".to_string())
+    }
+
+    fn host_option_chain(&self, dir: Direction) -> ChainPart {
+        ChainPart::new(self.host_table(), format!("option-{dir}"))
+    }
+
+    fn synflood_limit_chain(&self) -> ChainPart {
+        ChainPart::new(self.host_table(), "ratelimit-synflood")
+    }
+
+    fn log_invalid_tcp_chain(&self) -> ChainPart {
+        ChainPart::new(self.host_table(), "log-invalid-tcp")
+    }
+
+    fn log_smurfs_chain(&self) -> ChainPart {
+        ChainPart::new(self.host_table(), "log-smurfs")
+    }
+
+    fn default_log_limit(&self) -> Option<LogRateLimit> {
+        self.config.cluster().log_ratelimit()
+    }
+
+    fn reset_firewall(&self, commands: &mut Commands) {
+        commands.append(&mut vec![
+            Flush::chain(self.cluster_chain(Direction::In)),
+            Flush::chain(self.cluster_chain(Direction::Out)),
+            Add::chain(self.host_chain(Direction::In)),
+            Flush::chain(self.host_chain(Direction::In)),
+            Flush::chain(self.host_option_chain(Direction::In)),
+            Add::chain(self.host_chain(Direction::Out)),
+            Flush::chain(self.host_chain(Direction::Out)),
+            Flush::chain(self.host_option_chain(Direction::Out)),
+            Flush::map(self.guest_vmap(Direction::In)),
+            Flush::map(self.guest_vmap(Direction::Out)),
+            Flush::chain(self.host_conntrack_chain()),
+            Flush::chain(self.synflood_limit_chain()),
+            Flush::chain(self.log_invalid_tcp_chain()),
+            Flush::chain(self.log_smurfs_chain()),
+        ]);
+
+        // we need to remove guest chains before group chains
+        for prefix in ["guest-", "group-"] {
+            for (name, chain) in &self.config.nft().chains {
+                if name.starts_with(prefix) {
+                    commands.push(Delete::chain(chain.clone()))
+                }
+            }
+        }
+    }
+
+    pub fn remove_firewall(&self) -> Commands {
+        Commands::new(vec![
+            Delete::table(self.cluster_table()),
+            Delete::table(self.guest_table()),
+        ])
+    }
+
+    fn create_management_ipset(&self, commands: &mut Commands) -> Result<(), Error> {
+        if self.config.cluster().ipsets().get("management").is_none() {
+            let management_ips = HostConfig::management_ips()?;
+
+            let mut ipset = Ipset::from_parts(IpsetScope::Datacenter, "management");
+            ipset.reserve(management_ips.len());
+
+            let entries = management_ips.into_iter().map(IpsetEntry::from);
+
+            ipset.extend(entries);
+
+            let env = NftObjectEnv {
+                table: &self.cluster_table(),
+                firewall_config: &self.config,
+                vmid: None,
+            };
+
+            commands.append(&mut ipset.to_nft_objects(&env)?);
+        }
+
+        Ok(())
+    }
+
+    pub fn full_host_fw(&self) -> Result<Commands, Error> {
+        let mut commands = Commands::default();
+
+        if !self.config.is_enabled() {
+            return Ok(commands);
+        }
+
+        self.reset_firewall(&mut commands);
+
+        let cluster_host_table = self.cluster_table();
+        let guest_table = self.guest_table();
+
+        self.create_management_ipset(&mut commands)?;
+
+        for table in [&cluster_host_table, &guest_table] {
+            self.create_ipsets(&mut commands, self.config.cluster().ipsets(), table, None)?;
+
+            for (name, group) in self.config.cluster().groups() {
+                self.create_group_chain(&mut commands, table, group, name, Direction::In)?;
+                self.create_group_chain(&mut commands, table, group, name, Direction::Out)?;
+            }
+        }
+
+        self.create_cluster_rules(&mut commands, Direction::In)?;
+        self.create_cluster_rules(&mut commands, Direction::Out)?;
+
+        if self.config.host().is_enabled() {
+            log::debug!("Generating host firewall config");
+
+            self.setup_ct_helper(&mut commands)?;
+
+            self.handle_host_options(&mut commands)?;
+
+            self.create_host_rules(&mut commands, Direction::In)?;
+            self.create_host_rules(&mut commands, Direction::Out)?;
+        }
+
+        for (vmid, config) in self.config.guests() {
+            if !config.is_enabled() {
+                log::debug!("Firewall disabled for VM #{vmid} - not generating config");
+                continue;
+            }
+
+            log::debug!("Generating firewall config for VM #{vmid}");
+
+            self.create_ipsets(&mut commands, config.ipsets(), &guest_table, *vmid)?;
+
+            self.create_guest_chain(&mut commands, *vmid, Direction::In)?;
+            self.create_guest_chain(&mut commands, *vmid, Direction::Out)?;
+
+            self.handle_guest_options(&mut commands, *vmid, config)?;
+
+            self.create_guest_rules(&mut commands, *vmid, config, Direction::In)?;
+            self.create_guest_rules(&mut commands, *vmid, config, Direction::Out)?;
+        }
+
+        Ok(commands)
+    }
+
+    fn handle_host_options(&self, commands: &mut Commands) -> Result<(), Error> {
+        log::debug!("setting host options");
+
+        let chain_in = self.host_option_chain(Direction::In);
+        let chain_out = self.host_option_chain(Direction::Out);
+
+        if self.config.host().allow_ndp() {
+            log::debug!("set allow_ndp");
+            let statement = Statement::jump("allow-ndp");
+
+            commands.append(&mut vec![
+                Add::rule(AddRule::from_statement(chain_in.clone(), statement.clone())),
+                Add::rule(AddRule::from_statement(chain_out, statement)),
+            ]);
+        }
+
+        if self.config.host().block_synflood() {
+            log::debug!("set block_synflood");
+
+            let rate_limit = Statement::from(AnonymousLimit {
+                rate: self.config.host().synflood_rate(),
+                per: RateTimescale::Second,
+                burst: Some(self.config.host().synflood_burst()),
+                inv: Some(true),
+                ..Default::default()
+            });
+
+            let synflood_limit_chain = self.synflood_limit_chain();
+
+            let v4_rule = AddRule::from_statements(
+                synflood_limit_chain.clone(),
+                [
+                    Statement::Set(Set {
+                        op: SetOperation::Update,
+                        elem: Expression::from(Payload::field("ip", "saddr")),
+                        stmt: Some(NfVec::one(rate_limit.clone())),
+                        set: "@v4-synflood-limit".to_string(),
+                    }),
+                    Statement::make_drop(),
+                ],
+            );
+
+            let v6_rule = AddRule::from_statements(
+                synflood_limit_chain,
+                [
+                    Statement::Set(Set {
+                        op: SetOperation::Update,
+                        elem: Expression::from(Payload::field("ip6", "saddr")),
+                        stmt: Some(NfVec::one(rate_limit)),
+                        set: "@v6-synflood-limit".to_string(),
+                    }),
+                    Statement::make_drop(),
+                ],
+            );
+
+            commands.append(&mut vec![
+                Add::rule(AddRule::from_statement(
+                    chain_in.clone(),
+                    Statement::jump("block-synflood"),
+                )),
+                Add::rule(v4_rule),
+                Add::rule(v6_rule),
+            ])
+        }
+
+        if self.config.host().block_invalid_tcp() {
+            log::debug!("set block_invalid_tcp");
+
+            commands.push(Add::rule(AddRule::from_statement(
+                chain_in.clone(),
+                Statement::jump("block-invalid-tcp"),
+            )));
+
+            self.create_log_rule(
+                commands,
+                self.config.host().block_invalid_tcp_log_level(),
+                self.log_invalid_tcp_chain(),
+                ConfigVerdict::Drop,
+                None,
+            )?;
+        }
+
+        if self.config.host().block_smurfs() {
+            log::debug!("set block_smurfs");
+
+            commands.push(Add::rule(AddRule::from_statement(
+                chain_in.clone(),
+                Statement::jump("block-smurfs"),
+            )));
+
+            self.create_log_rule(
+                commands,
+                self.config.host().block_smurfs_log_level(),
+                self.log_smurfs_chain(),
+                ConfigVerdict::Drop,
+                None,
+            )?;
+        }
+
+        if self.config.host().block_invalid_conntrack() {
+            log::debug!("set block_invalid_conntrack");
+
+            commands.push(Add::rule(AddRule::from_statement(
+                chain_in,
+                Statement::jump("block-conntrack-invalid"),
+            )));
+        }
+
+        if let Some(value) = self.config.host().nf_conntrack_max() {
+            log::debug!("set nf_conntrack_max");
+            fs::write(NF_CONNTRACK_MAX_FILE, value.to_string()).map_err(anyhow::Error::msg)?;
+        }
+
+        if let Some(value) = self.config.host().nf_conntrack_tcp_timeout_established() {
+            log::debug!("set nf_conntrack_tcp_timeout_established");
+            fs::write(NF_CONNTRACK_TCP_TIMEOUT_ESTABLISHED, value.to_string())
+                .map_err(anyhow::Error::msg)?;
+        }
+
+        if let Some(value) = self.config.host().nf_conntrack_tcp_timeout_syn_recv() {
+            log::debug!("set nf_conntrack_tcp_timeout_syn_recv");
+            fs::write(NF_CONNTRACK_TCP_TIMEOUT_SYN_RECV, value.to_string())
+                .map_err(anyhow::Error::msg)?;
+        }
+
+        let value = (self.config.host().log_nf_conntrack() as u8).to_string();
+        fs::write(LOG_CONNTRACK_FILE, value).map_err(anyhow::Error::msg)?;
+
+        /*
+        CliCommand::new("systemctl")
+            .args(["try-reload-or-restart", "pvefw-logger.service"])
+            .output()
+            .map_err(anyhow::Error::msg)?;
+        */
+
+        Ok(())
+    }
+
+    fn handle_guest_options(
+        &self,
+        commands: &mut Commands,
+        vmid: Vmid,
+        config: &GuestConfig,
+    ) -> Result<(), Error> {
+        let chain_in = self.guest_chain(Direction::In, vmid);
+        let chain_out = self.guest_chain(Direction::Out, vmid);
+
+        if config.macfilter() {
+            let mac_address_set =
+                config
+                    .network_config()
+                    .network_devices()
+                    .iter()
+                    .map(|(index, device)| {
+                        Expression::concat([
+                            Expression::from(config.iface_name_by_index(*index)),
+                            Expression::from(device.mac_address().to_string()),
+                        ])
+                    });
+
+            let macfilter_rule = AddRule::from_statements(
+                chain_out.clone(),
+                [
+                    Match::new_ne(
+                        Expression::concat([
+                            Expression::from(Meta::new("iifname")),
+                            Expression::from(Payload::field("ether", "saddr")),
+                        ]),
+                        Expression::set(mac_address_set),
+                    )
+                    .into(),
+                    Statement::make_drop(),
+                ],
+            );
+
+            commands.push(Add::rule(macfilter_rule));
+        }
+
+        if config.allow_dhcp() {
+            commands.append(&mut vec![
+                Add::rule(AddRule::from_statement(
+                    chain_in.clone(),
+                    Statement::jump("allow-dhcp-in"),
+                )),
+                Add::rule(AddRule::from_statement(
+                    chain_out.clone(),
+                    Statement::jump("allow-dhcp-out"),
+                )),
+            ]);
+        }
+
+        if config.allow_ndp() {
+            let statement = Statement::jump("allow-ndp");
+
+            commands.append(&mut vec![
+                Add::rule(AddRule::from_statement(chain_in.clone(), statement.clone())),
+                Add::rule(AddRule::from_statement(chain_out.clone(), statement)),
+            ]);
+        }
+
+        if config.allow_ra() {
+            let statement = Statement::jump("allow-ra");
+
+            commands.append(&mut vec![
+                Add::rule(AddRule::from_statement(chain_in, statement.clone())),
+                Add::rule(AddRule::from_statement(chain_out, statement)),
+            ]);
+        }
+
+        Ok(())
+    }
+
+    fn setup_ct_helper(&self, commands: &mut Commands) -> Result<(), Error> {
+        let chain_in = self.host_conntrack_chain();
+
+        if let Some(helpers) = self.config.host().conntrack_helpers() {
+            log::trace!("adding conntrack helpers: {helpers:?}");
+
+            let object_env = NftObjectEnv {
+                table: chain_in.table(),
+                firewall_config: &self.config,
+                vmid: None,
+            };
+
+            let rule_env = NftRuleEnv {
+                chain: chain_in.clone(),
+                direction: Direction::In,
+                firewall_config: &self.config,
+                vmid: None,
+            };
+
+            for helper in helpers {
+                let helper_macro = get_cthelper(&helper.to_string());
+
+                if let Some(helper_macro) = helper_macro {
+                    commands.append(&mut helper_macro.to_nft_objects(&object_env)?);
+
+                    // todo: use vmap
+                    for rule in NftRule::from_ct_helper(helper_macro, &rule_env)? {
+                        commands.push(Add::rule(rule.into_add_rule(chain_in.clone())));
+                    }
+                } else {
+                    log::warn!("provided invalid helper macro name: {:?}", helper);
+                }
+            }
+        }
+
+        Ok(())
+    }
+
+    fn create_ipsets(
+        &self,
+        commands: &mut Commands,
+        ipsets: &HashMap<String, Ipset>,
+        table: &TablePart,
+        vmid: impl Into<Option<Vmid>>,
+    ) -> Result<(), Error> {
+        let env = NftObjectEnv {
+            table,
+            vmid: vmid.into(),
+            firewall_config: &self.config,
+        };
+
+        for (name, ipset) in ipsets {
+            log::debug!("Creating ipset {name} in table {table:?}");
+
+            commands.append(&mut ipset.to_nft_objects(&env)?);
+
+            if let (Some(vmid), Some(ipfilter)) = (env.vmid, ipset.ipfilter()) {
+                log::debug!("Creating IP filter rules for VM {vmid}");
+
+                let chain = self.guest_chain(Direction::Out, vmid);
+
+                let rule_env = NftRuleEnv {
+                    chain: chain.clone(),
+                    direction: Direction::Out,
+                    firewall_config: &self.config,
+                    vmid: Some(vmid),
+                };
+
+                for rule in NftRule::from_ipfilter(&ipfilter, &rule_env)? {
+                    commands.push(Add::rule(rule.into_add_rule(chain.clone())));
+                }
+            }
+        }
+
+        Ok(())
+    }
+
+    fn create_cluster_rules(
+        &self,
+        commands: &mut Commands,
+        direction: Direction,
+    ) -> Result<(), Error> {
+        log::info!("Creating cluster chain {direction} family");
+
+        let chain = self.cluster_chain(direction);
+
+        let env = NftRuleEnv {
+            chain: chain.clone(),
+            direction,
+            firewall_config: &self.config,
+            vmid: None,
+        };
+
+        let rules = self.config.cluster().rules();
+
+        commands.reserve(rules.len());
+
+        for config_rule in rules {
+            for rule in NftRule::from_config_rule(config_rule, &env)? {
+                commands.push(Add::rule(rule.into_add_rule(chain.clone())));
+            }
+        }
+
+        let default_policy = self.config.cluster().default_policy(direction);
+
+        self.create_log_rule(
+            commands,
+            self.config.host().log_level(direction),
+            chain.clone(),
+            default_policy,
+            None,
+        )?;
+
+        commands.push(Add::rule(AddRule::from_statement(chain, default_policy)));
+
+        Ok(())
+    }
+
+    fn create_host_rules(
+        &self,
+        commands: &mut Commands,
+        direction: Direction,
+    ) -> Result<(), Error> {
+        log::info!("Creating host chain {direction} family");
+
+        let chain = self.host_chain(direction);
+
+        let env = NftRuleEnv {
+            chain: chain.clone(),
+            direction,
+            firewall_config: &self.config,
+            vmid: None,
+        };
+
+        let rules = self.config.host().rules();
+        commands.reserve(rules.len());
+
+        for config_rule in rules {
+            for rule in NftRule::from_config_rule(config_rule, &env)? {
+                commands.push(Add::rule(rule.into_add_rule(chain.clone())));
+            }
+        }
+
+        Ok(())
+    }
+
+    fn create_guest_chain(
+        &self,
+        commands: &mut Commands,
+        vmid: Vmid,
+        direction: Direction,
+    ) -> Result<(), Error> {
+        log::info!("Creating guest chain (vmid {vmid}) {direction}");
+
+        let chain = self.guest_chain(direction, vmid);
+
+        commands.append(&mut vec![Add::chain(chain.clone()), Flush::chain(chain)]);
+
+        Ok(())
+    }
+
+    fn create_guest_rules(
+        &self,
+        commands: &mut Commands,
+        vmid: Vmid,
+        config: &GuestConfig,
+        direction: Direction,
+    ) -> Result<(), Error> {
+        log::info!("Creating guest rules (vmid {vmid}) {direction}");
+
+        let chain = self.guest_chain(direction, vmid);
+
+        let env = NftRuleEnv {
+            chain: chain.clone(),
+            direction,
+            firewall_config: &self.config,
+            vmid: Some(vmid),
+        };
+
+        commands.reserve(config.rules().len());
+
+        for config_rule in config.rules() {
+            for rule in NftRule::from_config_rule(config_rule, &env)? {
+                commands.push(Add::rule(rule.into_add_rule(chain.clone())))
+            }
+        }
+
+        let network_devices = config.network_config().network_devices();
+
+        if !network_devices.is_empty() {
+            let map_elements = network_devices
+                .iter()
+                .filter(|(_, device)| device.has_firewall())
+                .map(|(index, _)| {
+                    (
+                        Expression::from(config.iface_name_by_index(*index)),
+                        MapValue::from(Verdict::Goto {
+                            target: chain.name().to_string(),
+                        }),
+                    )
+                });
+
+            commands.push(Add::element(AddElement::map_from_expressions(
+                self.guest_vmap(direction),
+                map_elements,
+            )));
+        }
+
+        self.create_log_rule(
+            commands,
+            config.log_level(direction),
+            chain.clone(),
+            config.default_policy(direction),
+            vmid,
+        )?;
+
+        commands.push(Add::rule(AddRule::from_statement(
+            chain,
+            config.default_policy(direction),
+        )));
+
+        Ok(())
+    }
+
+    fn create_group_chain(
+        &self,
+        commands: &mut Commands,
+        table: &TablePart,
+        group: &Group,
+        name: &str,
+        direction: Direction,
+    ) -> Result<(), Error> {
+        log::info!("Creating group chain (table {table:?}) {direction}");
+
+        let chain = self.group_chain(table.clone(), name, direction);
+
+        let env = NftRuleEnv {
+            chain: chain.clone(),
+            direction,
+            firewall_config: &self.config,
+            vmid: None,
+        };
+
+        commands.append(&mut vec![
+            Add::chain(chain.clone()),
+            Flush::chain(chain.clone()),
+        ]);
+
+        for rule in group.rules() {
+            for firewall_rule in NftRule::from_config_rule(rule, &env)? {
+                commands.push(Add::rule(firewall_rule.into_add_rule(chain.clone())))
+            }
+        }
+
+        Ok(())
+    }
+
+    fn create_log_rule(
+        &self,
+        commands: &mut Commands,
+        log_level: ConfigLogLevel,
+        chain: ChainPart,
+        verdict: ConfigVerdict,
+        vmid: impl Into<Option<Vmid>>,
+    ) -> Result<(), Error> {
+        if let Ok(log_level) = LogLevel::try_from(log_level) {
+            let mut log_rule = AddRule::new(chain.clone());
+
+            if let Some(limit) = self.default_log_limit() {
+                log_rule.push(Statement::from(limit));
+            }
+
+            let log_statement = Log::new_nflog(
+                Log::generate_prefix(vmid, log_level, chain.name(), verdict),
+                0,
+            );
+
+            log_rule.push(Statement::from(log_statement));
+
+            commands.push(Add::rule(log_rule));
+        }
+
+        Ok(())
+    }
+}
diff --git a/proxmox-firewall/src/main.rs b/proxmox-firewall/src/main.rs
index a4979a7..53c1289 100644
--- a/proxmox-firewall/src/main.rs
+++ b/proxmox-firewall/src/main.rs
@@ -1,6 +1,7 @@
 use anyhow::Error;
 
 mod config;
+mod firewall;
 mod object;
 mod rule;
 
-- 
2.39.2




^ permalink raw reply	[flat|nested] 67+ messages in thread

* [pve-devel] [PATCH proxmox-firewall 32/37] firewall: add proxmox-firewall binary
  2024-04-02 17:15 [pve-devel] [RFC container/firewall/manager/proxmox-firewall/qemu-server 00/37] proxmox firewall nftables implementation Stefan Hanreich
                   ` (30 preceding siblings ...)
  2024-04-02 17:16 ` [pve-devel] [PATCH proxmox-firewall 31/37] firewall: add ruleset " Stefan Hanreich
@ 2024-04-02 17:16 ` Stefan Hanreich
  2024-04-02 17:16 ` [pve-devel] [PATCH proxmox-firewall 33/37] firewall: add files for debian packaging Stefan Hanreich
                   ` (9 subsequent siblings)
  41 siblings, 0 replies; 67+ messages in thread
From: Stefan Hanreich @ 2024-04-02 17:16 UTC (permalink / raw)
  To: pve-devel; +Cc: Stefan Hanreich, Wolfgang Bumiller

Co-authored-by: Wolfgang Bumiller <w.bumiller@proxmox.com>
Signed-off-by: Stefan Hanreich <s.hanreich@proxmox.com>
---
 proxmox-firewall/src/main.rs | 34 ++++++++++++++++++++++++++++++++++
 1 file changed, 34 insertions(+)

diff --git a/proxmox-firewall/src/main.rs b/proxmox-firewall/src/main.rs
index 53c1289..28aecdf 100644
--- a/proxmox-firewall/src/main.rs
+++ b/proxmox-firewall/src/main.rs
@@ -5,7 +5,41 @@ mod firewall;
 mod object;
 mod rule;
 
+use firewall::Firewall;
+use proxmox_nftables::NftCtx;
+
+const RULE_BASE: &str = include_str!("../resources/proxmox-firewall.nft");
+
 fn main() -> Result<(), Error> {
     env_logger::init();
+
+    let mut nft = NftCtx::new()?;
+    let firewall = Firewall::new()?;
+
+    if !firewall.is_enabled() {
+        log::info!("Removing existing firewall rules");
+        let commands = firewall.remove_firewall();
+
+        // can ignore failures, since it fails when table does not exist
+        let _ = nft.run_commands(&commands);
+
+        return Ok(());
+    }
+
+    let commands = firewall.full_host_fw()?;
+
+    log::info!("Running proxmox-firewall.nft");
+    let got = nft.run_nft_commands(RULE_BASE)?;
+    log::info!("got response from nftables: {got:?}");
+
+    log::info!("Running proxmox-firewall commands");
+
+    for (idx, c) in commands.iter().enumerate() {
+        log::debug!("cmd #{idx} {}", serde_json::to_string(&c)?);
+    }
+
+    let got = nft.run_commands(&commands)?;
+    log::info!("got response from nftables: {got:?}");
+
     Ok(())
 }
-- 
2.39.2




^ permalink raw reply	[flat|nested] 67+ messages in thread

* [pve-devel] [PATCH proxmox-firewall 33/37] firewall: add files for debian packaging
  2024-04-02 17:15 [pve-devel] [RFC container/firewall/manager/proxmox-firewall/qemu-server 00/37] proxmox firewall nftables implementation Stefan Hanreich
                   ` (31 preceding siblings ...)
  2024-04-02 17:16 ` [pve-devel] [PATCH proxmox-firewall 32/37] firewall: add proxmox-firewall binary Stefan Hanreich
@ 2024-04-02 17:16 ` Stefan Hanreich
  2024-04-03 13:14   ` Fabian Grünbichler
  2024-04-02 17:16 ` [pve-devel] [PATCH qemu-server 34/37] firewall: add handling for new nft firewall Stefan Hanreich
                   ` (8 subsequent siblings)
  41 siblings, 1 reply; 67+ messages in thread
From: Stefan Hanreich @ 2024-04-02 17:16 UTC (permalink / raw)
  To: pve-devel

Signed-off-by: Stefan Hanreich <s.hanreich@proxmox.com>
---
 Makefile                        | 93 +++++++++++++++++++++++++++++++++
 debian/changelog                |  5 ++
 debian/control                  | 31 +++++++++++
 debian/copyright                | 16 ++++++
 debian/proxmox-firewall.service | 16 ++++++
 debian/proxmox-firewall.timer   | 11 ++++
 debian/rules                    | 14 +++++
 debian/source/format            |  1 +
 defines.mk                      | 13 +++++
 9 files changed, 200 insertions(+)
 create mode 100644 Makefile
 create mode 100644 debian/changelog
 create mode 100644 debian/control
 create mode 100644 debian/copyright
 create mode 100644 debian/proxmox-firewall.service
 create mode 100644 debian/proxmox-firewall.timer
 create mode 100644 debian/rules
 create mode 100644 debian/source/format
 create mode 100644 defines.mk

diff --git a/Makefile b/Makefile
new file mode 100644
index 0000000..984c318
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,93 @@
+include /usr/share/dpkg/pkg-info.mk
+include /usr/share/dpkg/architecture.mk
+include defines.mk
+
+PACKAGE=proxmox-firewall
+BUILDDIR ?= $(PACKAGE)-$(DEB_VERSION_UPSTREAM)
+
+
+DEB=$(PACKAGE)_$(DEB_VERSION_UPSTREAM_REVISION)_$(DEB_HOST_ARCH).deb
+DBG_DEB=$(PACKAGE)-dbgsym_$(DEB_VERSION_UPSTREAM_REVISION)_$(DEB_HOST_ARCH).deb
+DSC=rust-$(PACKAGE)_$(DEB_VERSION_UPSTREAM_REVISION).dsc
+
+DEBS = $(DEB) $(DBG_DEB)
+
+ifeq ($(BUILD_MODE), release)
+CARGO_BUILD_ARGS += --release
+COMPILEDIR := target/release
+else
+COMPILEDIR := target/debug
+endif
+
+USR_BIN := \
+	proxmox-firewall
+
+COMPILED_BINS := \
+	$(addprefix $(COMPILEDIR)/,$(USR_BIN))
+
+all: cargo-build
+
+.PHONY: cargo-build
+cargo-build:
+	cargo build $(CARGO_BUILD_ARGS)
+
+$(COMPILED_BINS): cargo-build
+
+install: $(COMPILED_BINS)
+	install -dm755 $(DESTDIR)$(SBINDIR)
+	$(foreach i,$(USR_BIN), \
+	    install -m755 $(COMPILEDIR)/$(i) $(DESTDIR)$(SBINDIR)/ ;)
+
+update-dcontrol: #$(BUILDDIR)
+	debcargo package \
+	  --config debian/debcargo.toml \
+	  --changelog-ready \
+	  --no-overlay-write-back \
+	  --directory $(BUILDDIR) \
+	  $(PACKAGE) \
+	  $(shell dpkg-parsechangelog -l debian/changelog -SVersion | sed -e 's/-.*//')
+	cat $(BUILDDIR)/debian/control debian/control.extra > debian/control
+	wrap-and-sort -t -k -f debian/control
+
+.PHONY: build
+build: $(BUILDDIR)
+$(BUILDDIR):
+	rm -rf $@ $@.tmp; mkdir $@.tmp
+	cp -a proxmox-firewall proxmox-nftables proxmox-ve-config debian Cargo.toml Makefile defines.mk $@.tmp/
+	mv $@.tmp $@
+
+.PHONY: deb
+deb: $(DEB)
+$(HELPER_DEB) $(DBG_DEB) $(HELPER_DBG_DEB) $(DOC_DEB): $(DEB)
+$(DEB): $(BUILDDIR)
+	cd $(BUILDDIR); dpkg-buildpackage -b -us -uc --no-pre-clean
+	lintian $(DEB) $(DOC_DEB) $(HELPER_DEB)
+
+.PHONY: test
+test:
+	cargo test
+
+.PHONY: dsc
+dsc:
+	rm -rf $(BUILDDIR) $(DSC)
+	$(MAKE) $(DSC)
+	lintian $(DSC)
+$(DSC): $(BUILDDIR)
+	cd $(BUILDDIR); dpkg-buildpackage -S -us -uc -d -nc
+
+sbuild: $(DSC)
+	sbuild $<
+
+.PHONY: dinstall
+dinstall: $(DEB)
+	dpkg -i $(DEB) $(DBG_DEB) $(DOC_DEB)
+
+.PHONY: distclean
+distclean: clean
+
+.PHONY: clean
+clean:
+	cargo clean
+	rm -f *.deb *.build *.buildinfo *.changes *.dsc rust-$(PACKAGE)*.tar*
+	rm -rf $(PACKAGE)-[0-9]*/
+	find . -name '*~' -exec rm {} ';'
diff --git a/debian/changelog b/debian/changelog
new file mode 100644
index 0000000..7918ec9
--- /dev/null
+++ b/debian/changelog
@@ -0,0 +1,5 @@
+proxmox-firewall (0.1-1) UNRELEASED; urgency=medium
+
+  * Initial release.
+
+ -- Stefan Hanreich <s.hanreich@proxmox.com>  Thu, 07 Mar 2024 10:15:10 +0100
diff --git a/debian/control b/debian/control
new file mode 100644
index 0000000..e04ce68
--- /dev/null
+++ b/debian/control
@@ -0,0 +1,31 @@
+Source: proxmox-firewall
+Section: admin
+Priority: optional
+Maintainer: Proxmox Support Team <support@proxmox.com>
+Build-Depends: cargo:native,
+	       debhelper-compat (= 13),
+	       dh-cargo (>= 25),
+	       librust-anyhow-1+default-dev,
+	       librust-env-logger-0.10+default-dev,
+	       librust-log-0.4+default-dev (>= 0.4.17-~~),
+	       librust-nix-0.26+default-dev (>= 0.26.1-~~),
+	       librust-serde-1+default-dev,
+	       librust-serde-1+derive-dev,
+	       librust-serde-json-1+default-dev,
+	       librust-serde-plain-1+default-dev,
+	       librust-serde-plain-1+default-dev,
+	       librust-serde-with+default-dev,
+	       librust-libc-0.2+default-dev,
+	       librust-proxmox-schema-3+default-dev,
+Standards-Version: 4.6.2
+Homepage: https://www.proxmox.com
+
+Package: proxmox-firewall
+Architecture: any
+Conflicts: ulogd,
+Depends: ${misc:Depends}, ${shlibs:Depends},
+	 pve-firewall,
+	 nftables,
+Description: Proxmox VE nft Firewall
+ This package contains a nftables-based implementation of the Proxmox VE
+ Firewall
diff --git a/debian/copyright b/debian/copyright
new file mode 100644
index 0000000..fe09a1b
--- /dev/null
+++ b/debian/copyright
@@ -0,0 +1,16 @@
+Copyright (C) 2018-2024 Proxmox Server Solutions GmbH
+
+This software is written by Proxmox Server Solutions GmbH <support@proxmox.com>
+
+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 <http://www.gnu.org/licenses/>.
diff --git a/debian/proxmox-firewall.service b/debian/proxmox-firewall.service
new file mode 100644
index 0000000..5f9bf4b
--- /dev/null
+++ b/debian/proxmox-firewall.service
@@ -0,0 +1,16 @@
+[Unit]
+Description=Proxmox VE nftables firewall
+ConditionPathExists=/usr/sbin/proxmox-firewall
+Wants=pve-cluster.service pvefw-logger.service
+After=pvefw-logger.service pve-cluster.service network.target systemd-modules-load.service
+DefaultDependencies=no
+Before=shutdown.target
+Conflicts=shutdown.target
+
+[Service]
+ExecStart=/usr/sbin/proxmox-firewall
+Type=oneshot
+
+[Install]
+WantedBy=multi-user.target
+
diff --git a/debian/proxmox-firewall.timer b/debian/proxmox-firewall.timer
new file mode 100644
index 0000000..d051102
--- /dev/null
+++ b/debian/proxmox-firewall.timer
@@ -0,0 +1,11 @@
+[Unit]
+Description=Proxmox VE nft Firewall timer
+
+[Timer]
+OnBootSec=1s
+OnUnitInactiveSec=5s
+Unit=proxmox-firewall.service
+
+[Install]
+WantedBy=timers.target
+
diff --git a/debian/rules b/debian/rules
new file mode 100644
index 0000000..5539a00
--- /dev/null
+++ b/debian/rules
@@ -0,0 +1,14 @@
+#!/usr/bin/make -f
+
+# Uncomment this to turn on verbose mode.
+#export DH_VERBOSE=1
+
+%:
+	dh $@
+
+override_dh_installsystemd:
+	dh_installsystemd --no-start proxmox-firewall.service
+	dh_installsystemd proxmox-firewall.timer
+
+override_dh_installinit:
+
diff --git a/debian/source/format b/debian/source/format
new file mode 100644
index 0000000..89ae9db
--- /dev/null
+++ b/debian/source/format
@@ -0,0 +1 @@
+3.0 (native)
diff --git a/defines.mk b/defines.mk
new file mode 100644
index 0000000..e01164d
--- /dev/null
+++ b/defines.mk
@@ -0,0 +1,13 @@
+PREFIX = /usr
+BINDIR = $(PREFIX)/bin
+SBINDIR = $(PREFIX)/sbin
+LIBDIR = $(PREFIX)/lib
+LIBEXECDIR = $(LIBDIR)
+DATAROOTDIR = $(PREFIX)/share
+MAN1DIR = $(PREFIX)/share/man/man1
+MAN5DIR = $(PREFIX)/share/man/man5
+SYSCONFDIR = /etc
+
+# For local overrides
+-include local.mak
+
-- 
2.39.2




^ permalink raw reply	[flat|nested] 67+ messages in thread

* [pve-devel] [PATCH qemu-server 34/37] firewall: add handling for new nft firewall
  2024-04-02 17:15 [pve-devel] [RFC container/firewall/manager/proxmox-firewall/qemu-server 00/37] proxmox firewall nftables implementation Stefan Hanreich
                   ` (32 preceding siblings ...)
  2024-04-02 17:16 ` [pve-devel] [PATCH proxmox-firewall 33/37] firewall: add files for debian packaging Stefan Hanreich
@ 2024-04-02 17:16 ` Stefan Hanreich
  2024-04-02 17:16 ` [pve-devel] [PATCH pve-container 35/37] " Stefan Hanreich
                   ` (7 subsequent siblings)
  41 siblings, 0 replies; 67+ messages in thread
From: Stefan Hanreich @ 2024-04-02 17:16 UTC (permalink / raw)
  To: pve-devel

When the nftables firewall is enabled, we do not need to create
firewall bridges.

Signed-off-by: Stefan Hanreich <s.hanreich@proxmox.com>
---
 vm-network-scripts/pve-bridge | 9 +++++++--
 1 file changed, 7 insertions(+), 2 deletions(-)

diff --git a/vm-network-scripts/pve-bridge b/vm-network-scripts/pve-bridge
index 85997a0..ac2eb3b 100755
--- a/vm-network-scripts/pve-bridge
+++ b/vm-network-scripts/pve-bridge
@@ -6,6 +6,7 @@ use warnings;
 use PVE::QemuServer;
 use PVE::Tools qw(run_command);
 use PVE::Network;
+use PVE::Firewall;
 
 my $have_sdn;
 eval {
@@ -44,13 +45,17 @@ die "unable to get network config '$netid'\n"
 my $net = PVE::QemuServer::parse_net($netconf);
 die "unable to parse network config '$netid'\n" if !$net;
 
+my $cluster_fw_conf = PVE::Firewall::load_clusterfw_conf();
+my $host_fw_conf = PVE::Firewall::load_hostfw_conf($cluster_fw_conf);
+my $firewall = $net->{firewall} && !($host_fw_conf->{options}->{nftables} // 0);
+
 if ($have_sdn) {
     PVE::Network::SDN::Vnets::add_dhcp_mapping($net->{bridge}, $net->{macaddr}, $vmid, $conf->{name});
     PVE::Network::SDN::Zones::tap_create($iface, $net->{bridge});
-    PVE::Network::SDN::Zones::tap_plug($iface, $net->{bridge}, $net->{tag}, $net->{firewall}, $net->{trunks}, $net->{rate});
+    PVE::Network::SDN::Zones::tap_plug($iface, $net->{bridge}, $net->{tag}, $firewall, $net->{trunks}, $net->{rate});
 } else {
     PVE::Network::tap_create($iface, $net->{bridge});
-    PVE::Network::tap_plug($iface, $net->{bridge}, $net->{tag}, $net->{firewall}, $net->{trunks}, $net->{rate});
+    PVE::Network::tap_plug($iface, $net->{bridge}, $net->{tag}, $firewall, $net->{trunks}, $net->{rate});
 }
 
 exit 0;
-- 
2.39.2




^ permalink raw reply	[flat|nested] 67+ messages in thread

* [pve-devel] [PATCH pve-container 35/37] firewall: add handling for new nft firewall
  2024-04-02 17:15 [pve-devel] [RFC container/firewall/manager/proxmox-firewall/qemu-server 00/37] proxmox firewall nftables implementation Stefan Hanreich
                   ` (33 preceding siblings ...)
  2024-04-02 17:16 ` [pve-devel] [PATCH qemu-server 34/37] firewall: add handling for new nft firewall Stefan Hanreich
@ 2024-04-02 17:16 ` Stefan Hanreich
  2024-04-02 17:16 ` [pve-devel] [PATCH pve-firewall 36/37] add configuration option for new nftables firewall Stefan Hanreich
                   ` (6 subsequent siblings)
  41 siblings, 0 replies; 67+ messages in thread
From: Stefan Hanreich @ 2024-04-02 17:16 UTC (permalink / raw)
  To: pve-devel

When the nftables firewall is enabled, we do not need to create
firewall bridges.

Signed-off-by: Stefan Hanreich <s.hanreich@proxmox.com>
---
 src/PVE/LXC.pm | 5 +++++
 1 file changed, 5 insertions(+)

diff --git a/src/PVE/LXC.pm b/src/PVE/LXC.pm
index 7883cfb..a5d389a 100644
--- a/src/PVE/LXC.pm
+++ b/src/PVE/LXC.pm
@@ -18,6 +18,7 @@ use PVE::AccessControl;
 use PVE::CGroup;
 use PVE::CpuSet;
 use PVE::Exception qw(raise_perm_exc);
+use PVE::Firewall;
 use PVE::GuestHelpers qw(check_vnet_access safe_string_ne safe_num_ne safe_boolean_ne);
 use PVE::INotify;
 use PVE::JSONSchema qw(get_standard_option);
@@ -959,6 +960,10 @@ sub net_tap_plug : prototype($$) {
     my ($bridge, $tag, $firewall, $trunks, $rate, $hwaddr) =
 	$net->@{'bridge', 'tag', 'firewall', 'trunks', 'rate', 'hwaddr'};
 
+    my $cluster_fw_conf = PVE::Firewall::load_clusterfw_conf();
+    my $host_fw_conf = PVE::Firewall::load_hostfw_conf($cluster_fw_conf);
+    $firewall = $net->{firewall} && !($host_fw_conf->{options}->{nftables} // 0);
+
     if ($have_sdn) {
 	PVE::Network::SDN::Zones::tap_plug($iface, $bridge, $tag, $firewall, $trunks, $rate);
 	PVE::Network::SDN::Zones::add_bridge_fdb($iface, $hwaddr, $bridge);
-- 
2.39.2




^ permalink raw reply	[flat|nested] 67+ messages in thread

* [pve-devel] [PATCH pve-firewall 36/37] add configuration option for new nftables firewall
  2024-04-02 17:15 [pve-devel] [RFC container/firewall/manager/proxmox-firewall/qemu-server 00/37] proxmox firewall nftables implementation Stefan Hanreich
                   ` (34 preceding siblings ...)
  2024-04-02 17:16 ` [pve-devel] [PATCH pve-container 35/37] " Stefan Hanreich
@ 2024-04-02 17:16 ` Stefan Hanreich
  2024-04-02 17:16 ` [pve-devel] [PATCH pve-manager 37/37] firewall: expose " Stefan Hanreich
                   ` (5 subsequent siblings)
  41 siblings, 0 replies; 67+ messages in thread
From: Stefan Hanreich @ 2024-04-02 17:16 UTC (permalink / raw)
  To: pve-devel

Introduces new nftables configuration option that en/disables the new
nftables firewall.

pve-firewall reads this option and only generates iptables rules when
nftables is set to `0`. Conversely proxmox-firewall only generates
nftables rules when the option is set to `1`.

Signed-off-by: Stefan Hanreich <s.hanreich@proxmox.com>
---
 src/PVE/Firewall.pm | 20 ++++++++++++++++----
 1 file changed, 16 insertions(+), 4 deletions(-)

diff --git a/src/PVE/Firewall.pm b/src/PVE/Firewall.pm
index 77cbaf4..5438b80 100644
--- a/src/PVE/Firewall.pm
+++ b/src/PVE/Firewall.pm
@@ -1408,6 +1408,12 @@ our $host_option_properties = {
 	default => 0,
 	optional => 1
     },
+    nftables => {
+	description => "Enable nftables based firewall",
+	type => 'boolean',
+	default => 0,
+	optional => 1,
+    },
 };
 
 our $vm_option_properties = {
@@ -2929,7 +2935,7 @@ sub parse_hostfw_option {
 
     my $loglevels = "emerg|alert|crit|err|warning|notice|info|debug|nolog";
 
-    if ($line =~ m/^(enable|nosmurfs|tcpflags|ndp|log_nf_conntrack|nf_conntrack_allow_invalid|protection_synflood):\s*(0|1)\s*$/i) {
+    if ($line =~ m/^(enable|nosmurfs|tcpflags|ndp|log_nf_conntrack|nf_conntrack_allow_invalid|protection_synflood|nftables):\s*(0|1)\s*$/i) {
 	$opt = lc($1);
 	$value = int($2);
     } elsif ($line =~ m/^(log_level_in|log_level_out|tcp_flags_log_level|smurf_log_level):\s*(($loglevels)\s*)?$/i) {
@@ -4676,7 +4682,11 @@ sub remove_pvefw_chains_ebtables {
 sub init {
     my $cluster_conf = load_clusterfw_conf();
     my $cluster_options = $cluster_conf->{options};
-    my $enable = $cluster_options->{enable};
+
+    my $host_conf = load_hostfw_conf($cluster_conf);
+    my $host_options = $host_conf->{options};
+
+    my $enable = $cluster_options->{enable} && !$host_options->{nftables};
 
     return if !$enable;
 
@@ -4689,12 +4699,14 @@ sub update {
 	my $cluster_conf = load_clusterfw_conf();
 	my $cluster_options = $cluster_conf->{options};
 
-	if (!$cluster_options->{enable}) {
+	my $hostfw_conf = load_hostfw_conf($cluster_conf);
+	my $host_options = $hostfw_conf->{options};
+
+	if (!$cluster_options->{enable} || $host_options->{nftables}) {
 	    PVE::Firewall::remove_pvefw_chains();
 	    return;
 	}
 
-	my $hostfw_conf = load_hostfw_conf($cluster_conf);
 
 	my ($ruleset, $ipset_ruleset, $rulesetv6, $ebtables_ruleset) = compile($cluster_conf, $hostfw_conf);
 
-- 
2.39.2




^ permalink raw reply	[flat|nested] 67+ messages in thread

* [pve-devel] [PATCH pve-manager 37/37] firewall: expose configuration option for new nftables firewall
  2024-04-02 17:15 [pve-devel] [RFC container/firewall/manager/proxmox-firewall/qemu-server 00/37] proxmox firewall nftables implementation Stefan Hanreich
                   ` (35 preceding siblings ...)
  2024-04-02 17:16 ` [pve-devel] [PATCH pve-firewall 36/37] add configuration option for new nftables firewall Stefan Hanreich
@ 2024-04-02 17:16 ` Stefan Hanreich
  2024-04-02 20:47 ` [pve-devel] [RFC container/firewall/manager/proxmox-firewall/qemu-server 00/37] proxmox firewall nftables implementation Laurent GUERBY
                   ` (4 subsequent siblings)
  41 siblings, 0 replies; 67+ messages in thread
From: Stefan Hanreich @ 2024-04-02 17:16 UTC (permalink / raw)
  To: pve-devel

Signed-off-by: Stefan Hanreich <s.hanreich@proxmox.com>
---
 www/manager6/grid/FirewallOptions.js | 1 +
 1 file changed, 1 insertion(+)

diff --git a/www/manager6/grid/FirewallOptions.js b/www/manager6/grid/FirewallOptions.js
index 0ac9979c4..2d21d45d8 100644
--- a/www/manager6/grid/FirewallOptions.js
+++ b/www/manager6/grid/FirewallOptions.js
@@ -83,6 +83,7 @@ Ext.define('PVE.FirewallOptions', {
 	    add_log_row('log_level_out');
 	    add_log_row('tcp_flags_log_level', 120);
 	    add_log_row('smurf_log_level');
+	    add_boolean_row('nftables', gettext('nftables support (EXPERIMENTAL)'), 0);
 	} else if (me.fwtype === 'vm') {
 	    me.rows.enable = {
 		required: true,
-- 
2.39.2




^ permalink raw reply	[flat|nested] 67+ messages in thread

* Re: [pve-devel] [RFC container/firewall/manager/proxmox-firewall/qemu-server 00/37] proxmox firewall nftables implementation
  2024-04-02 17:15 [pve-devel] [RFC container/firewall/manager/proxmox-firewall/qemu-server 00/37] proxmox firewall nftables implementation Stefan Hanreich
                   ` (36 preceding siblings ...)
  2024-04-02 17:16 ` [pve-devel] [PATCH pve-manager 37/37] firewall: expose " Stefan Hanreich
@ 2024-04-02 20:47 ` Laurent GUERBY
  2024-04-03  7:33   ` Stefan Hanreich
       [not found] ` <mailman.54.1712122640.450.pve-devel@lists.proxmox.com>
                   ` (3 subsequent siblings)
  41 siblings, 1 reply; 67+ messages in thread
From: Laurent GUERBY @ 2024-04-02 20:47 UTC (permalink / raw)
  To: Proxmox VE development discussion

On Tue, 2024-04-02 at 19:15 +0200, Stefan Hanreich wrote:
> 
> ## Known Issues
> There is currently one major issue that we still need to solve: REJECTing
> packets from the guest firewalls is currently not possible for incoming traffic
> (it will instead be dropped).
> 
> This is due to the fact that we are using the postrouting hook of nftables in a
> table with type bridge for incoming traffic. In the bridge table in the
> postrouting hook we cannot tell whether the packet has also been sent to other
> ports in the bridge (e.g. when a MAC has not yet been learned and the packet
> then gets flooded to all bridge ports). If we would then REJECT a packet in the
> postrouting hook this can lead to a bug where the firewall rules for one guest
> REJECT a packet and send a response (RST for TCP, ICMP port/host-unreachable
> otherwise).
> 
> This has also been explained in the respective commit introducing the
> restriction [1].
> 
> We were able to circumvent this restriction in the old firewall due to using
> firewall bridges and rejecting in the firewall bridge itself. Doing this leads
> to the behavior described above, which has tripped up some of our users before
> [2] [3] and which is, frankly, wrong.
> 
> I currently see two possible solutions for this, both of which carry downsides.
> Your input on this matter would be much appreciated, particularly if you can
> think of another solution which I cannot currently see:
> 
> 1. Only REJECT packets in the prerouting chain of the firewall bridge with the
> destination MAC address set to the MAC address of the network device, otherwise
> DROP
> 
> The downside of this is that we, once again, will have to resort to using
> firewall bridges, which we wanted to eliminate. This would also be the sole
> reason for still having to resort to using firewall bridges.
> 
> 2. Only allow DROP in the guest firewall for incoming traffic
> 
> This would be quite awkward since, well, rejecting traffic would be quite nice
> for a firewall I'd say ;)
> 
> I'm happy for all input regarding this matter.

Hi,

REJECT is a L3 IP feature, to implement it properly in all cases your
firewall rule needs to know both about IP adresses involved (and the
corresponding MAC too in the ethernet case). 

I don't currently use the proxmox VE firewalling capabilities (I was
waiting for nftables to look at it :) but may be a compromise would be
to warn during the transition from iptables to nftables (or from
version N to N+1) that if a REJECT rule is found without explicit IP
and MAC that it will just be transformed to DROP, and if the user wants
a REJECT the user needs to add explicit IP and MAC pairs. 

Then the Promox VE firewalling can be done in "ip" tables which know
how to match ether MAC ("type ipv4_addr . ether_addr" to match both IP
and MAC at the same time) and no firewall bridge needed.

Sincerely,

Laurent GUERBY





^ permalink raw reply	[flat|nested] 67+ messages in thread

* Re: [pve-devel] [RFC container/firewall/manager/proxmox-firewall/qemu-server 00/37] proxmox firewall nftables implementation
  2024-04-02 20:47 ` [pve-devel] [RFC container/firewall/manager/proxmox-firewall/qemu-server 00/37] proxmox firewall nftables implementation Laurent GUERBY
@ 2024-04-03  7:33   ` Stefan Hanreich
  0 siblings, 0 replies; 67+ messages in thread
From: Stefan Hanreich @ 2024-04-03  7:33 UTC (permalink / raw)
  To: pve-devel

On 4/2/24 22:47, Laurent GUERBY wrote:

> REJECT is a L3 IP feature, to implement it properly in all cases your
> firewall rule needs to know both about IP adresses involved (and the
> corresponding MAC too in the ethernet case). 

Yes indeed, although we have L3 and L4 information available in the
bridge table, otherwise REJECT would also be impossible in the
prerouting / input hooks. REJECTing in the bridge table simply sends a
packet with source and destination IP of the initial packet flipped [1].

Nevertheless, we cannot tell whether the destination IP address in the
packet is actually the IP address of the guest (in the case of VMs) -
even if the MAC address is from the guest. So we might need to combine
this with the ipfilter ipsets if we want to make sure to only send
REJECTs with IP addresses that are actually configured in the guest.


> I don't currently use the proxmox VE firewalling capabilities (I was
> waiting for nftables to look at it :) but may be a compromise would be
> to warn during the transition from iptables to nftables (or from
> version N to N+1) that if a REJECT rule is found without explicit IP
> and MAC that it will just be transformed to DROP, and if the user wants
> a REJECT the user needs to add explicit IP and MAC pairs. 

See my point above regarding ipfilters.


> Then the Promox VE firewalling can be done in "ip" tables which know
> how to match ether MAC ("type ipv4_addr . ether_addr" to match both IP
> and MAC at the same time) and no firewall bridge needed.

Host firewalling is already done in the inet tables (which is ip + ip6),
but for guest firewalling we will have to use the bridge table one way
or another, since it is the only viable hook for handling the guest
traffic (see [2]), which usually runs via bridges.


[1]
https://git.kernel.org/pub/scm/linux/kernel/git/stable/linux.git/tree/net/ipv4/netfilter/nf_reject_ipv4.c?h=v6.8.2#n168
[2] https://wiki.nftables.org/wiki-nftables/index.php/Netfilter_hooks




^ permalink raw reply	[flat|nested] 67+ messages in thread

* Re: [pve-devel] [RFC container/firewall/manager/proxmox-firewall/qemu-server 00/37] proxmox firewall nftables implementation
       [not found] ` <mailman.54.1712122640.450.pve-devel@lists.proxmox.com>
@ 2024-04-03  7:52   ` Stefan Hanreich
  2024-04-03 12:26   ` Stefan Hanreich
  1 sibling, 0 replies; 67+ messages in thread
From: Stefan Hanreich @ 2024-04-03  7:52 UTC (permalink / raw)
  To: pve-devel

On 4/3/24 07:37, DERUMIER, Alexandre via pve-devel wrote:
> I'll really take time to test it (I was super busy theses last month
> with a datacenter migration), as I wait for nftables since a while.
> 
> Can't help too much with rust, but I really appriciate it, as I had
> some servers with a lot of vms && rules, take more than 10s to generate
> the rules with current perl implementation).

Thanks! I'd be really interested in how this performs with a large
ruleset since I haven't really tried with an excessively large ruleset
so far. I have only done very basic checks of the performance, but it
looked quite promising. 50% of the time is actually spent in
libnftables, so I'd be interested to see how this changes with large
rulesets.

There is also still some room for performance improvements, since
performance wasn't my main concern so far. For instance I am currently
reading the guest configuration files 1:1 via the filesystem, but I
wanted to implement getting the configuration via pmxcfs where one call
would then suffice to retrieve the network configuration of all guests.

If you have a large configuration I could use for testing, that you'd be
willing to share, then I could run tests myself. Otherwise I will
probably use a script to generate a huge config myself at some point.

> I really would like to not have fwbr bridge anymore, because I have
> seen a big performance bug with them: 

I agree 100%, getting rid of those would eliminate several bugs and issues.

> I'll try your code, see the generated rules, and try to see if I can
> get reject working.

Thanks! Maybe you can come up with something. Otherwise we might have to
implement a configuration option that switches between firewall bridge
on/off and people have to make choices about the tradeoffs themselves.




^ permalink raw reply	[flat|nested] 67+ messages in thread

* Re: [pve-devel] [RFC container/firewall/manager/proxmox-firewall/qemu-server 00/37] proxmox firewall nftables implementation
       [not found] ` <mailman.56.1712124362.450.pve-devel@lists.proxmox.com>
@ 2024-04-03  8:15   ` Stefan Hanreich
       [not found]     ` <mailman.77.1712145853.450.pve-devel@lists.proxmox.com>
  0 siblings, 1 reply; 67+ messages in thread
From: Stefan Hanreich @ 2024-04-03  8:15 UTC (permalink / raw)
  To: pve-devel



On 4/3/24 08:05, DERUMIER, Alexandre via pve-devel wrote:
> Personnaly, I'm not sure than using reject / tcp-reset in a bridged is
> a good idea.  (Even if personally I'm using it production, I don't have
> problem to switch to DROP, if I can avoid other problems)

Yes, I tend to agree. But there certainly will be users who want to use
REJECT for guest firewalls and it certainly makes sense to support that
- especially since we have supported it since the beginning. It's hard
to take away features. I also feel like this is something a firewall
should support one way or another and if it's not there we are missing a
really basic feature.


> Maybe it is time to disable dynamic mac-learning  by default ? 
> The code is already here and works fine.
> 
> AFAIK, other hypervisor like vmware disable port flooding by default
> with static mac registration too.

Might be a good idea, although it still wouldn't solve the problem -
sadly (since we're still not allowed to do REJECT then).




^ permalink raw reply	[flat|nested] 67+ messages in thread

* Re: [pve-devel] [RFC container/firewall/manager/proxmox-firewall/qemu-server 00/37] proxmox firewall nftables implementation
  2024-04-02 17:15 [pve-devel] [RFC container/firewall/manager/proxmox-firewall/qemu-server 00/37] proxmox firewall nftables implementation Stefan Hanreich
                   ` (39 preceding siblings ...)
       [not found] ` <mailman.56.1712124362.450.pve-devel@lists.proxmox.com>
@ 2024-04-03 10:46 ` Max Carrara
  2024-04-09  9:21   ` Stefan Hanreich
  2024-04-10 10:25 ` Lukas Wagner
  41 siblings, 1 reply; 67+ messages in thread
From: Max Carrara @ 2024-04-03 10:46 UTC (permalink / raw)
  To: Proxmox VE development discussion

On Tue Apr 2, 2024 at 7:15 PM CEST, Stefan Hanreich wrote:
> ## Introduction
> This RFC provides a drop-in replacement for the current pve-firewall package
> that is based on Rust and nftables.

I've now read through all of the code and I think it's safe to say that
this looks absolutely pristine to me - I've only got a couple of very
minor things to add, some of which are mentioned here, some others can
be found as replies to individual patches.

First and foremost, all of the structs and enums as well as their
respective implementations are very straightforward and easy to follow;
there are no surprises to be found anywhere in the code. This is what
a proper typesafe abstraction should look like.

The vast majority of the code is easy to follow, even for someone like
me who isn't as versed in networking like you. Additional context is
provided by comments where necessary, which is also great (I *despise*
useless comments).

Furthermore, it's always a pleasure to see tests - and you've addded
quite a lot of them! As I've also smoke-tested this series (we discussed
all that off list before already) I can also vouch that this series
worked as a perfect drop-in replacement for the existing firewall I had
running in a VM.

All in all I think this is great - there may be a couple little things
to polish and some issues to take care of, as you mentioned below, but
I'm very confident that those will eventually be resolved.

Otherwise, there are some more comments below and also inline.

>

Four overall things I want to mention:

  1. IMO a lot of the `pub` items should eventually be documented,
     preferably once the actual series is out. I don't think we need to
     be as thorough as e.g. the Rust STL's documentation, but I don't
     think it would hurt if the overall functionality of things was
     documented. (Of course, e.g. saying that `pub fn hostname()`
     "gets the hostname" isn't necessary; but you get what I mean :P )

  2. Constants and defaults should also be documented, simply because
     it makes it easier to refer to those defaults if necessary. On top
     of that, it's also more obvious if those constants / defaults ever
     have to be changed for some reason. That way we would avoid
     accidental semver-breakage. There's a more specific example inline.

  3. Would it perhaps actually make sense to use `thiserror` instead of
     `anyhow`? I know we've speculated a little off list about this
     already - I still am not 100% convinced that `thiserror` is
     necessary, but then again, it would be quite nice in the library
     crates, as you don't really need to propagate any `anyhow::Context`
     anyways ...

     There's already `NftError` in proxmox-nftables that *could perhaps*
     just be implemented via `thiserror`, I think.

  4. Some of the types (in particular in `proxmox-ve-config` and
     `proxmox-nftables`) could use some more trait-deriving - a lot of
     the structs and enums could benefit from deriving `PartialOrd`,
     `Ord` and `Hash` for interoperability's sake [0]. While it's
     probably unlikely that some types will ever be used as keys in a
     hashmap, deriving the trait IMO doesn't hurt.

     A lot of types also implement `PartialEq` and `Eq` only for tests,
     but IMO those traits could theoretically just always be implemented
     for most of them.

     As this affects a lot of types I've decided to just sum this up
     here by the way; if you need more concrete examples, please let me
     know and I'll add respective comments inline.

[0]: https://rust-lang.github.io/api-guidelines/interoperability.html

> It consists of three crates:
> * proxmox-ve-config
>   for parsing firewall and guest configuration files, as well as some helpers
>   to access host configuration (particularly networking)
> * proxmox-nftables
>   contains bindings for libnftables as well as types that implement the JSON
>   schema defined by libnftables-json
> * proxmox-firewall
>   uses the other two crates to read the firewall configuration and create the
>   respective nftables configuration
>
>
> ## Installation
> * Build & install all deb packages on your PVE instance
> * Enable the nftables firewall by going to
>   Web UI > <Host> > Firewall > Options > proxmox-nftables
> * Enable the firewall datacenter-wide if you haven't already
> * Restarting running VMs/CTs is required so the changes to the fwbr creation
>   go into effect
>
> For your convenience I have provided pre-built packages on our share under
> `shanreich-proxmox-firewall`.
>
> The source code is also available on my staff repo as `proxmox-firewall`.
>
>
> ## Configuration
> The firewall should work as a drop-in replacement for the pve-firewall, so you
> should be able to configure the firewall as usual via the Web UI or
> configuration files.
>
>
> ## Known Issues
> There is currently one major issue that we still need to solve: REJECTing
> packets from the guest firewalls is currently not possible for incoming traffic
> (it will instead be dropped).
>
> This is due to the fact that we are using the postrouting hook of nftables in a
> table with type bridge for incoming traffic. In the bridge table in the
> postrouting hook we cannot tell whether the packet has also been sent to other
> ports in the bridge (e.g. when a MAC has not yet been learned and the packet
> then gets flooded to all bridge ports). If we would then REJECT a packet in the
> postrouting hook this can lead to a bug where the firewall rules for one guest
> REJECT a packet and send a response (RST for TCP, ICMP port/host-unreachable
> otherwise).
>
> This has also been explained in the respective commit introducing the
> restriction [1].
>
> We were able to circumvent this restriction in the old firewall due to using
> firewall bridges and rejecting in the firewall bridge itself. Doing this leads
> to the behavior described above, which has tripped up some of our users before
> [2] [3] and which is, frankly, wrong.
>
> I currently see two possible solutions for this, both of which carry downsides.
> Your input on this matter would be much appreciated, particularly if you can
> think of another solution which I cannot currently see:
>
> 1. Only REJECT packets in the prerouting chain of the firewall bridge with the
> destination MAC address set to the MAC address of the network device, otherwise
> DROP
>
> The downside of this is that we, once again, will have to resort to using
> firewall bridges, which we wanted to eliminate. This would also be the sole
> reason for still having to resort to using firewall bridges.
>
> 2. Only allow DROP in the guest firewall for incoming traffic
>
> This would be quite awkward since, well, rejecting traffic would be quite nice
> for a firewall I'd say ;)
>
> I'm happy for all input regarding this matter.
>
>
> ## Useful Commands
>
> You can check if firewall rules got created by running
>
> ```
> nft list ruleset
> ```
>
> You can also check that `iptables` rules are not created via
> ```
> iptables-save
> ```
>
> Further info about the services:
> ```
> systemctl status proxmox-firewall.{service,timer}
> ```
>
> You can grab the debug output from the new firewall like so:
>
> ```
> RUST_LOG=trace proxmox-firewall
> ```
>
> ## Upcoming
>
> There are some (very minor) features missing:
> * automatically generating an ipfilter based on the link-local IPv6 address
> * complete list of ICMP codes
>
> I also have some improvements for the code base in mind, but I wanted to get the
> RFC out now, since I feel like the new firewall is already in a decent state and
> the architecture is relatively solid. Nevertheless there are still a few
> improvements that I will be working on:
> * move error handling in the library crates to custom error types / thiserror
> * integration tests for the firewall itself
>
> [1] https://git.kernel.org/pub/scm/linux/kernel/git/stable/linux.git/commit/net/bridge/netfilter/nft_reject_bridge.c?h=v6.8.2&id=127917c29a432c3b798e014a1714e9c1af0f87fe
> [2] https://bugzilla.proxmox.com/show_bug.cgi?id=4964
> [3] https://forum.proxmox.com/threads/proxmox-claiming-mac-address.52601/page-2#post-415493
>
>
>
> PS: Since the changestats are broken for patch series including the initial
> commit of a repo here is the cloc output for proxmox-firewall instead:
>
> -------------------------------------------------------------------------------
> Language                     files          blank        comment           code
> -------------------------------------------------------------------------------
> Rust                            37           1642             78           7749
> JSON                             2              0              0            948
> TOML                             3             10              0             59
> -------------------------------------------------------------------------------
> SUM:                            42           1652             78           8756
> -------------------------------------------------------------------------------
>
> proxmox-firewall:
>
> Stefan Hanreich (33):
>   config: add proxmox-ve-config crate
>   config: firewall: add types for ip addresses
>   config: firewall: add types for ports
>   config: firewall: add types for log level and rate limit
>   config: firewall: add types for aliases
>   config: host: add helpers for host network configuration
>   config: guest: add helpers for parsing guest network config
>   config: firewall: add types for ipsets
>   config: firewall: add types for rules
>   config: firewall: add types for security groups
>   config: firewall: add generic parser for firewall configs
>   config: firewall: add cluster-specific config + option types
>   config: firewall: add host specific config + option types
>   config: firewall: add guest-specific config + option types
>   config: firewall: add firewall macros
>   config: firewall: add conntrack helper types
>   nftables: add crate for libnftables bindings
>   nftables: add helpers
>   nftables: expression: add types
>   nftables: expression: implement conversion traits for firewall config
>   nftables: statement: add types
>   nftables: statement: add conversion traits for config types
>   nftables: commands: add types
>   nftables: types: add conversion traits
>   nftables: add libnftables bindings
>   firewall: add firewall crate
>   firewall: add base ruleset
>   firewall: add config loader
>   firewall: add rule generation logic
>   firewall: add object generation logic
>   firewall: add ruleset generation logic
>   firewall: add proxmox-firewall binary
>   firewall: add files for debian packaging
>
>
> qemu-server:
>
> Stefan Hanreich (1):
>   firewall: add handling for new nft firewall
>
>  vm-network-scripts/pve-bridge | 9 +++++++--
>  1 file changed, 7 insertions(+), 2 deletions(-)
>
>
> pve-container:
>
> Stefan Hanreich (1):
>   firewall: add handling for new nft firewall
>
>  src/PVE/LXC.pm | 5 +++++
>  1 file changed, 5 insertions(+)
>
>
> pve-firewall:
>
> Stefan Hanreich (1):
>   add configuration option for new nftables firewall
>
>  src/PVE/Firewall.pm | 20 ++++++++++++++++----
>  1 file changed, 16 insertions(+), 4 deletions(-)
>
>
> pve-manager:
>
> Stefan Hanreich (1):
>   firewall: expose configuration option for new nftables firewall
>
>  www/manager6/grid/FirewallOptions.js | 1 +
>  1 file changed, 1 insertion(+)
>
>
> Summary over all repositories:
>   4 files changed, 29 insertions(+), 6 deletions(-)





^ permalink raw reply	[flat|nested] 67+ messages in thread

* Re: [pve-devel] [PATCH proxmox-firewall 02/37] config: firewall: add types for ip addresses
  2024-04-02 17:15 ` [pve-devel] [PATCH proxmox-firewall 02/37] config: firewall: add types for ip addresses Stefan Hanreich
@ 2024-04-03 10:46   ` Max Carrara
  2024-04-09  8:26     ` Stefan Hanreich
  0 siblings, 1 reply; 67+ messages in thread
From: Max Carrara @ 2024-04-03 10:46 UTC (permalink / raw)
  To: Proxmox VE development discussion; +Cc: Wolfgang Bumiller

On Tue Apr 2, 2024 at 7:15 PM CEST, Stefan Hanreich wrote:
> Includes types for all kinds of IP values that can occur in the
> firewall config. Additionally, FromStr implementations are available
> for parsing from the config files.
>
> Co-authored-by: Wolfgang Bumiller <w.bumiller@proxmox.com>
> Signed-off-by: Stefan Hanreich <s.hanreich@proxmox.com>
> ---
>  proxmox-ve-config/src/firewall/mod.rs         |   1 +
>  .../src/firewall/types/address.rs             | 624 ++++++++++++++++++
>  proxmox-ve-config/src/firewall/types/mod.rs   |   3 +
>  proxmox-ve-config/src/lib.rs                  |   1 +
>  4 files changed, 629 insertions(+)
>  create mode 100644 proxmox-ve-config/src/firewall/mod.rs
>  create mode 100644 proxmox-ve-config/src/firewall/types/address.rs
>  create mode 100644 proxmox-ve-config/src/firewall/types/mod.rs
>
> diff --git a/proxmox-ve-config/src/firewall/mod.rs b/proxmox-ve-config/src/firewall/mod.rs
> new file mode 100644
> index 0000000..cd40856
> --- /dev/null
> +++ b/proxmox-ve-config/src/firewall/mod.rs
> @@ -0,0 +1 @@
> +pub mod types;
> diff --git a/proxmox-ve-config/src/firewall/types/address.rs b/proxmox-ve-config/src/firewall/types/address.rs
> new file mode 100644
> index 0000000..ce2f1cd
> --- /dev/null
> +++ b/proxmox-ve-config/src/firewall/types/address.rs
> @@ -0,0 +1,624 @@
> +use std::fmt;
> +use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
> +use std::ops::Deref;
> +
> +use anyhow::{bail, format_err, Error};
> +use serde_with::DeserializeFromStr;
> +
> +#[derive(Clone, Copy, Debug, Eq, PartialEq)]
> +pub enum Family {
> +    V4,
> +    V6,
> +}
> +
> +impl fmt::Display for Family {
> +    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
> +        match self {
> +            Family::V4 => f.write_str("Ipv4"),
> +            Family::V6 => f.write_str("Ipv6"),
> +        }
> +    }
> +}
> +
> +#[derive(Clone, Debug)]
> +#[cfg_attr(test, derive(Eq, PartialEq))]
> +pub enum Cidr {
> +    Ipv4(Ipv4Cidr),
> +    Ipv6(Ipv6Cidr),
> +}
> +
> +impl Cidr {
> +    pub fn new_v4(addr: impl Into<Ipv4Addr>, mask: u8) -> Result<Self, Error> {
> +        Ok(Cidr::Ipv4(Ipv4Cidr::new(addr, mask)?))
> +    }
> +
> +    pub fn new_v6(addr: impl Into<Ipv6Addr>, mask: u8) -> Result<Self, Error> {
> +        Ok(Cidr::Ipv6(Ipv6Cidr::new(addr, mask)?))
> +    }
> +
> +    pub const fn family(&self) -> Family {
> +        match self {
> +            Cidr::Ipv4(_) => Family::V4,
> +            Cidr::Ipv6(_) => Family::V6,
> +        }
> +    }
> +
> +    pub fn is_ipv4(&self) -> bool {
> +        matches!(self, Cidr::Ipv4(_))
> +    }
> +
> +    pub fn is_ipv6(&self) -> bool {
> +        matches!(self, Cidr::Ipv6(_))
> +    }
> +}
> +
> +impl fmt::Display for Cidr {
> +    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
> +        match self {
> +            Self::Ipv4(ip) => f.write_str(ip.to_string().as_str()),
> +            Self::Ipv6(ip) => f.write_str(ip.to_string().as_str()),
> +        }
> +    }
> +}
> +
> +impl std::str::FromStr for Cidr {
> +    type Err = Error;
> +
> +    fn from_str(s: &str) -> Result<Self, Error> {
> +        if let Ok(ip) = s.parse::<Ipv4Cidr>() {
> +            return Ok(Cidr::Ipv4(ip));
> +        }
> +
> +        if let Ok(ip) = s.parse::<Ipv6Cidr>() {
> +            return Ok(Cidr::Ipv6(ip));
> +        }
> +
> +        bail!("invalid ip address or CIDR: {s:?}");
> +    }
> +}
> +
> +impl From<Ipv4Cidr> for Cidr {
> +    fn from(cidr: Ipv4Cidr) -> Self {
> +        Cidr::Ipv4(cidr)
> +    }
> +}
> +
> +impl From<Ipv6Cidr> for Cidr {
> +    fn from(cidr: Ipv6Cidr) -> Self {
> +        Cidr::Ipv6(cidr)
> +    }
> +}
> +
> +const IPV4_LENGTH: u8 = 32;
> +
> +#[derive(Clone, Debug)]
> +#[cfg_attr(test, derive(Eq, PartialEq))]
> +pub struct Ipv4Cidr {
> +    addr: Ipv4Addr,
> +    mask: u8,
> +}
> +
> +impl Ipv4Cidr {
> +    pub fn new(addr: impl Into<Ipv4Addr>, mask: u8) -> Result<Self, Error> {
> +        if mask > 32 {
> +            bail!("mask out of range for ipv4 cidr ({mask})");
> +        }
> +
> +        Ok(Self {
> +            addr: addr.into(),
> +            mask,
> +        })
> +    }
> +
> +    pub fn contains_address(&self, other: &Ipv4Addr) -> bool {
> +        let bits = u32::from_be_bytes(self.addr.octets());
> +        let other_bits = u32::from_be_bytes(other.octets());
> +
> +        let shift_amount: u32 = IPV4_LENGTH.saturating_sub(self.mask).into();
> +
> +        bits.checked_shr(shift_amount).unwrap_or(0)
> +            == other_bits.checked_shr(shift_amount).unwrap_or(0)
> +    }
> +
> +    pub fn address(&self) -> &Ipv4Addr {
> +        &self.addr
> +    }
> +
> +    pub fn mask(&self) -> u8 {
> +        self.mask
> +    }
> +}
> +
> +impl<T: Into<Ipv4Addr>> From<T> for Ipv4Cidr {
> +    fn from(value: T) -> Self {
> +        Self {
> +            addr: value.into(),
> +            mask: 32,
> +        }
> +    }
> +}
> +
> +impl std::str::FromStr for Ipv4Cidr {
> +    type Err = Error;
> +
> +    fn from_str(s: &str) -> Result<Self, Error> {
> +        Ok(match s.find('/') {
> +            None => Self {
> +                addr: s.parse()?,
> +                mask: 32,
> +            },
> +            Some(pos) => {
> +                let mask: u8 = s[(pos + 1)..]
> +                    .parse()
> +                    .map_err(|_| format_err!("invalid mask in ipv4 cidr: {s:?}"))?;
> +
> +                Self::new(s[..pos].parse::<Ipv4Addr>()?, mask)?
> +            }
> +        })
> +    }
> +}
> +
> +impl fmt::Display for Ipv4Cidr {
> +    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
> +        write!(f, "{}/{}", &self.addr, self.mask)
> +    }
> +}
> +
> +const IPV6_LENGTH: u8 = 128;
> +
> +#[derive(Clone, Debug)]
> +#[cfg_attr(test, derive(Eq, PartialEq))]
> +pub struct Ipv6Cidr {
> +    addr: Ipv6Addr,
> +    mask: u8,
> +}
> +
> +impl Ipv6Cidr {
> +    pub fn new(addr: impl Into<Ipv6Addr>, mask: u8) -> Result<Self, Error> {
> +        if mask > IPV6_LENGTH {
> +            bail!("mask out of range for ipv6 cidr");
> +        }
> +
> +        Ok(Self {
> +            addr: addr.into(),
> +            mask,
> +        })
> +    }
> +
> +    pub fn contains_address(&self, other: &Ipv6Addr) -> bool {
> +        let bits = u128::from_be_bytes(self.addr.octets());
> +        let other_bits = u128::from_be_bytes(other.octets());
> +
> +        let shift_amount: u32 = IPV6_LENGTH.saturating_sub(self.mask).into();
> +
> +        bits.checked_shr(shift_amount).unwrap_or(0)
> +            == other_bits.checked_shr(shift_amount).unwrap_or(0)
> +    }
> +
> +    pub fn address(&self) -> &Ipv6Addr {
> +        &self.addr
> +    }
> +
> +    pub fn mask(&self) -> u8 {
> +        self.mask
> +    }
> +}
> +
> +impl std::str::FromStr for Ipv6Cidr {
> +    type Err = Error;
> +
> +    fn from_str(s: &str) -> Result<Self, Error> {
> +        Ok(match s.find('/') {
> +            None => Self {
> +                addr: s.parse()?,
> +                mask: 128,
> +            },
> +            Some(pos) => {
> +                let mask: u8 = s[(pos + 1)..]
> +                    .parse()
> +                    .map_err(|_| format_err!("invalid mask in ipv6 cidr: {s:?}"))?;
> +
> +                Self::new(s[..pos].parse::<Ipv6Addr>()?, mask)?
> +            }
> +        })
> +    }
> +}
> +
> +impl fmt::Display for Ipv6Cidr {
> +    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
> +        write!(f, "{}/{}", &self.addr, self.mask)
> +    }
> +}
> +
> +impl<T: Into<Ipv6Addr>> From<T> for Ipv6Cidr {
> +    fn from(addr: T) -> Self {
> +        Self {
> +            addr: addr.into(),
> +            mask: 128,
> +        }
> +    }
> +}
> +
> +#[derive(Clone, Debug)]
> +#[cfg_attr(test, derive(Eq, PartialEq))]
> +pub enum IpEntry {
> +    Cidr(Cidr),
> +    Range(IpAddr, IpAddr),
> +}
> +
> +impl std::str::FromStr for IpEntry {
> +    type Err = Error;
> +
> +    fn from_str(s: &str) -> Result<Self, Error> {
> +        if s.is_empty() {
> +            bail!("Empty IP specification!")
> +        }
> +
> +        let entries: Vec<&str> = s
> +            .split('-')
> +            .take(3) // so we can check whether there are too many
> +            .collect();
> +
> +        match entries.len() {

You could just `match` on a slice of `entries` here and then have ...

> +            1 => {

               [cidr] => { 
	       as pattern here ...

> +                let cidr = entries.first().expect("Vec contains an element");
> +
> +                Ok(IpEntry::Cidr(cidr.parse()?))
> +            }
> +            2 => {
               ... and
	       [beg, end] => {
	       as pattern here.
> +                let (beg, end) = (
> +                    entries.first().expect("Vec contains two elements"),
> +                    entries.get(1).expect("Vec contains two elements"),
> +                );
> +
> +                if let Ok(beg) = beg.parse::<Ipv4Addr>() {
> +                    if let Ok(end) = end.parse::<Ipv4Addr>() {
> +                        if beg < end {
> +                            return Ok(IpEntry::Range(beg.into(), end.into()));
> +                        }
> +
> +                        bail!("start address is greater than end address!");
> +                    }
> +                }
> +
> +                if let Ok(beg) = beg.parse::<Ipv6Addr>() {
> +                    if let Ok(end) = end.parse::<Ipv6Addr>() {
> +                        if beg < end {
> +                            return Ok(IpEntry::Range(beg.into(), end.into()));
> +                        }
> +
> +                        bail!("start address is greater than end address!");
> +                    }
> +                }
> +
> +                bail!("start and end are not valid IP addresses of the same type!")
> +            }
> +            _ => bail!("Invalid amount of elements in IpEntry!"),
> +        }
> +    }
> +}
> +
> +impl fmt::Display for IpEntry {
> +    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
> +        match self {
> +            Self::Cidr(ip) => write!(f, "{ip}"),
> +            Self::Range(beg, end) => write!(f, "{beg}-{end}"),
> +        }
> +    }
> +}
> +
> +impl IpEntry {
> +    fn family(&self) -> Family {
> +        match self {
> +            Self::Cidr(cidr) => cidr.family(),
> +            Self::Range(start, end) => {
> +                if start.is_ipv4() && end.is_ipv4() {
> +                    return Family::V4;
> +                }
> +
> +                if start.is_ipv6() && end.is_ipv6() {
> +                    return Family::V6;
> +                }
> +
> +                // should never be reached due to constructors validating that
> +                // start type == end type
> +                unreachable!("invalid IP entry")
> +            }
> +        }
> +    }
> +}
> +
> +impl From<Cidr> for IpEntry {
> +    fn from(value: Cidr) -> Self {
> +        IpEntry::Cidr(value)
> +    }
> +}
> +
> +#[derive(Clone, Debug, DeserializeFromStr)]
> +#[cfg_attr(test, derive(Eq, PartialEq))]
> +pub struct IpList {
> +    // guaranteed to have the same family
> +    entries: Vec<IpEntry>,
> +    family: Family,
> +}
> +
> +impl Deref for IpList {
> +    type Target = Vec<IpEntry>;
> +
> +    fn deref(&self) -> &Self::Target {
> +        &self.entries
> +    }
> +}
> +
> +impl<T: Into<IpEntry>> From<T> for IpList {
> +    fn from(value: T) -> Self {
> +        let entry = value.into();
> +
> +        Self {
> +            family: entry.family(),
> +            entries: vec![entry],
> +        }
> +    }
> +}
> +
> +impl std::str::FromStr for IpList {
> +    type Err = Error;
> +
> +    fn from_str(s: &str) -> Result<Self, Error> {
> +        if s.is_empty() {
> +            bail!("Empty IP specification!")
> +        }
> +
> +        let mut entries = Vec::new();
> +        let mut current_family = None;
> +
> +        for element in s.split(',') {
> +            let entry: IpEntry = element.parse()?;
> +
> +            if let Some(family) = current_family {
> +                if family != entry.family() {
> +                    bail!("Incompatible families in IPList!")
> +                }
> +            } else {
> +                current_family = Some(entry.family());
> +            }
> +
> +            entries.push(entry);
> +        }
> +
> +        if entries.is_empty() {
> +            bail!("empty ip list")
> +        }
> +
> +        Ok(IpList {
> +            entries,
> +            family: current_family.unwrap(), // must be set due to length check above
> +        })
> +    }
> +}
> +
> +impl IpList {
> +    pub fn new(entries: Vec<IpEntry>) -> Result<Self, Error> {
> +        let family = entries.iter().try_fold(None, |result, entry| {
> +            if let Some(family) = result {
> +                if entry.family() != family {
> +                    bail!("non-matching families in entries list");
> +                }
> +
> +                Ok(Some(family))
> +            } else {
> +                Ok(Some(entry.family()))
> +            }
> +        })?;
> +
> +        if let Some(family) = family {
> +            return Ok(Self { entries, family });
> +        }
> +
> +        bail!("no elements in ip list entries");
> +    }
> +
> +    pub fn family(&self) -> Family {
> +        self.family
> +    }
> +}
> +
> +#[cfg(test)]
> +mod tests {
> +    use super::*;
> +    use std::net::{Ipv4Addr, Ipv6Addr};
> +
> +    #[test]
> +    fn test_v4_cidr() {
> +        let mut cidr: Ipv4Cidr = "0.0.0.0/0".parse().expect("valid IPv4 CIDR");
> +
> +        assert_eq!(cidr.addr, Ipv4Addr::new(0, 0, 0, 0));
> +        assert_eq!(cidr.mask, 0);
> +
> +        assert!(cidr.contains_address(&Ipv4Addr::new(0, 0, 0, 0)));
> +        assert!(cidr.contains_address(&Ipv4Addr::new(255, 255, 255, 255)));
> +
> +        cidr = "192.168.100.1".parse().expect("valid IPv4 CIDR");
> +
> +        assert_eq!(cidr.addr, Ipv4Addr::new(192, 168, 100, 1));
> +        assert_eq!(cidr.mask, 32);
> +
> +        assert!(cidr.contains_address(&Ipv4Addr::new(192, 168, 100, 1)));
> +        assert!(!cidr.contains_address(&Ipv4Addr::new(192, 168, 100, 2)));
> +        assert!(!cidr.contains_address(&Ipv4Addr::new(192, 168, 100, 0)));
> +
> +        cidr = "10.100.5.0/24".parse().expect("valid IPv4 CIDR");
> +
> +        assert_eq!(cidr.mask, 24);
> +
> +        assert!(cidr.contains_address(&Ipv4Addr::new(10, 100, 5, 0)));
> +        assert!(cidr.contains_address(&Ipv4Addr::new(10, 100, 5, 1)));
> +        assert!(cidr.contains_address(&Ipv4Addr::new(10, 100, 5, 100)));
> +        assert!(cidr.contains_address(&Ipv4Addr::new(10, 100, 5, 255)));
> +        assert!(!cidr.contains_address(&Ipv4Addr::new(10, 100, 4, 255)));
> +        assert!(!cidr.contains_address(&Ipv4Addr::new(10, 100, 6, 0)));
> +
> +        "0.0.0.0/-1".parse::<Ipv4Cidr>().unwrap_err();
> +        "0.0.0.0/33".parse::<Ipv4Cidr>().unwrap_err();
> +        "256.256.256.256/10".parse::<Ipv4Cidr>().unwrap_err();
> +
> +        "fe80::1/64".parse::<Ipv4Cidr>().unwrap_err();
> +        "qweasd".parse::<Ipv4Cidr>().unwrap_err();
> +        "".parse::<Ipv4Cidr>().unwrap_err();
> +    }
> +
> +    #[test]
> +    fn test_v6_cidr() {
> +        let mut cidr: Ipv6Cidr = "abab::1/64".parse().expect("valid IPv6 CIDR");
> +
> +        assert_eq!(cidr.addr, Ipv6Addr::new(0xABAB, 0, 0, 0, 0, 0, 0, 1));
> +        assert_eq!(cidr.mask, 64);
> +
> +        assert!(cidr.contains_address(&Ipv6Addr::new(0xABAB, 0, 0, 0, 0, 0, 0, 0)));
> +        assert!(cidr.contains_address(&Ipv6Addr::new(
> +            0xABAB, 0, 0, 0, 0xAAAA, 0xAAAA, 0xAAAA, 0xAAAA
> +        )));
> +        assert!(cidr.contains_address(&Ipv6Addr::new(
> +            0xABAB, 0, 0, 0, 0xFFFF, 0xFFFF, 0xFFFF, 0xFFFF
> +        )));
> +        assert!(!cidr.contains_address(&Ipv6Addr::new(0xABAB, 0, 0, 1, 0, 0, 0, 0)));
> +        assert!(!cidr.contains_address(&Ipv6Addr::new(
> +            0xABAA, 0, 0, 0, 0xFFFF, 0xFFFF, 0xFFFF, 0xFFFF
> +        )));
> +
> +        cidr = "eeee::1".parse().expect("valid IPv6 CIDR");
> +
> +        assert_eq!(cidr.mask, 128);
> +
> +        assert!(cidr.contains_address(&Ipv6Addr::new(0xEEEE, 0, 0, 0, 0, 0, 0, 1)));
> +        assert!(!cidr.contains_address(&Ipv6Addr::new(
> +            0xEEED, 0xFFFF, 0xFFFF, 0xFFFF, 0xFFFF, 0xFFFF, 0xFFFF, 0xFFFF
> +        )));
> +        assert!(!cidr.contains_address(&Ipv6Addr::new(0xEEEE, 0, 0, 0, 0, 0, 0, 0)));
> +
> +        "eeee::1/-1".parse::<Ipv6Cidr>().unwrap_err();
> +        "eeee::1/129".parse::<Ipv6Cidr>().unwrap_err();
> +        "gggg::1/64".parse::<Ipv6Cidr>().unwrap_err();
> +
> +        "192.168.0.1".parse::<Ipv6Cidr>().unwrap_err();
> +        "qweasd".parse::<Ipv6Cidr>().unwrap_err();
> +        "".parse::<Ipv6Cidr>().unwrap_err();
> +    }
> +
> +    #[test]
> +    fn test_parse_ip_entry() {
> +        let mut entry: IpEntry = "10.0.0.1".parse().expect("valid IP entry");
> +
> +        assert_eq!(entry, Cidr::new_v4([10, 0, 0, 1], 32).unwrap().into());
> +
> +        entry = "10.0.0.0/16".parse().expect("valid IP entry");
> +
> +        assert_eq!(entry, Cidr::new_v4([10, 0, 0, 0], 16).unwrap().into());
> +
> +        entry = "192.168.0.1-192.168.99.255"
> +            .parse()
> +            .expect("valid IP entry");
> +
> +        assert_eq!(
> +            entry,
> +            IpEntry::Range([192, 168, 0, 1].into(), [192, 168, 99, 255].into())
> +        );
> +
> +        entry = "fe80::1".parse().expect("valid IP entry");
> +
> +        assert_eq!(
> +            entry,
> +            Cidr::new_v6([0xFE80, 0, 0, 0, 0, 0, 0, 1], 128)
> +                .unwrap()
> +                .into()
> +        );
> +
> +        entry = "fe80::1/48".parse().expect("valid IP entry");
> +
> +        assert_eq!(
> +            entry,
> +            Cidr::new_v6([0xFE80, 0, 0, 0, 0, 0, 0, 1], 48)
> +                .unwrap()
> +                .into()
> +        );
> +
> +        entry = "fd80::1-fd80::ffff".parse().expect("valid IP entry");
> +
> +        assert_eq!(
> +            entry,
> +            IpEntry::Range(
> +                [0xFD80, 0, 0, 0, 0, 0, 0, 1].into(),
> +                [0xFD80, 0, 0, 0, 0, 0, 0, 0xFFFF].into(),
> +            )
> +        );
> +
> +        "192.168.100.0-192.168.99.255"
> +            .parse::<IpEntry>()
> +            .unwrap_err();
> +        "192.168.100.0-fe80::1".parse::<IpEntry>().unwrap_err();
> +        "192.168.100.0-192.168.200.0/16"
> +            .parse::<IpEntry>()
> +            .unwrap_err();
> +        "192.168.100.0-192.168.200.0-192.168.250.0"
> +            .parse::<IpEntry>()
> +            .unwrap_err();
> +        "qweasd".parse::<IpEntry>().unwrap_err();
> +    }
> +
> +    #[test]
> +    fn test_parse_ip_list() {
> +        let mut ip_list: IpList = "192.168.0.1,192.168.100.0/24,172.16.0.0-172.32.255.255"
> +            .parse()
> +            .expect("valid IP list");
> +
> +        assert_eq!(
> +            ip_list,
> +            IpList {
> +                entries: vec![
> +                    IpEntry::Cidr(Cidr::new_v4([192, 168, 0, 1], 32).unwrap()),
> +                    IpEntry::Cidr(Cidr::new_v4([192, 168, 100, 0], 24).unwrap()),
> +                    IpEntry::Range([172, 16, 0, 0].into(), [172, 32, 255, 255].into()),
> +                ],
> +                family: Family::V4,
> +            }
> +        );
> +
> +        ip_list = "fe80::1/64".parse().expect("valid IP list");
> +
> +        assert_eq!(
> +            ip_list,
> +            IpList {
> +                entries: vec![IpEntry::Cidr(
> +                    Cidr::new_v6([0xFE80, 0, 0, 0, 0, 0, 0, 1], 64).unwrap()
> +                ),],
> +                family: Family::V6,
> +            }
> +        );
> +
> +        "192.168.0.1,fe80::1".parse::<IpList>().unwrap_err();
> +
> +        "".parse::<IpList>().unwrap_err();
> +        "proxmox".parse::<IpList>().unwrap_err();
> +    }
> +
> +    #[test]
> +    fn test_construct_ip_list() {
> +        let mut ip_list = IpList::new(vec![Cidr::new_v4([10, 0, 0, 0], 8).unwrap().into()])
> +            .expect("valid ip list");
> +
> +        assert_eq!(ip_list.family(), Family::V4);
> +
> +        ip_list =
> +            IpList::new(vec![Cidr::new_v6([0x000; 8], 8).unwrap().into()]).expect("valid ip list");
> +
> +        assert_eq!(ip_list.family(), Family::V6);
> +
> +        IpList::new(vec![]).expect_err("empty ip list is invalid");
> +
> +        IpList::new(vec![
> +            Cidr::new_v4([10, 0, 0, 0], 8).unwrap().into(),
> +            Cidr::new_v6([0x0000; 8], 8).unwrap().into(),
> +        ])
> +        .expect_err("cannot mix ip families in ip list");
> +    }
> +}
> diff --git a/proxmox-ve-config/src/firewall/types/mod.rs b/proxmox-ve-config/src/firewall/types/mod.rs
> new file mode 100644
> index 0000000..de534b4
> --- /dev/null
> +++ b/proxmox-ve-config/src/firewall/types/mod.rs
> @@ -0,0 +1,3 @@
> +pub mod address;
> +
> +pub use address::Cidr;
> diff --git a/proxmox-ve-config/src/lib.rs b/proxmox-ve-config/src/lib.rs
> index e69de29..a0734b8 100644
> --- a/proxmox-ve-config/src/lib.rs
> +++ b/proxmox-ve-config/src/lib.rs
> @@ -0,0 +1 @@
> +pub mod firewall;





^ permalink raw reply	[flat|nested] 67+ messages in thread

* Re: [pve-devel] [PATCH proxmox-firewall 06/37] config: host: add helpers for host network configuration
  2024-04-02 17:15 ` [pve-devel] [PATCH proxmox-firewall 06/37] config: host: add helpers for host network configuration Stefan Hanreich
@ 2024-04-03 10:46   ` Max Carrara
  2024-04-09  8:32     ` Stefan Hanreich
  2024-04-09 14:20   ` Lukas Wagner
  1 sibling, 1 reply; 67+ messages in thread
From: Max Carrara @ 2024-04-03 10:46 UTC (permalink / raw)
  To: Proxmox VE development discussion; +Cc: Wolfgang Bumiller

On Tue Apr 2, 2024 at 7:15 PM CEST, Stefan Hanreich wrote:
> Currently the helpers for obtaining the host network configuration
> panic on error, which could be avoided by the use of
> OnceLock::get_or_init, but this method is currently only available in
> nightly versions.
>
> Generally, if there is a problem with obtaining a hostname for the
> current node then something else is probably already quite broken, so
> I would deem it acceptable for now, same goes for obtaining the
> current network configuration.
>
> Co-authored-by: Wolfgang Bumiller <w.bumiller@proxmox.com>
> Signed-off-by: Stefan Hanreich <s.hanreich@proxmox.com>
> ---
>  proxmox-ve-config/Cargo.toml        |  1 +
>  proxmox-ve-config/src/host/mod.rs   |  1 +
>  proxmox-ve-config/src/host/utils.rs | 97 +++++++++++++++++++++++++++++
>  proxmox-ve-config/src/lib.rs        |  1 +
>  4 files changed, 100 insertions(+)
>  create mode 100644 proxmox-ve-config/src/host/mod.rs
>  create mode 100644 proxmox-ve-config/src/host/utils.rs
>
> diff --git a/proxmox-ve-config/Cargo.toml b/proxmox-ve-config/Cargo.toml
> index 7bb391e..480eb58 100644
> --- a/proxmox-ve-config/Cargo.toml
> +++ b/proxmox-ve-config/Cargo.toml
> @@ -13,6 +13,7 @@ license = "AGPL-3"
>  [dependencies]
>  log = "0.4"
>  anyhow = "1"
> +nix = "0.26"
>  
>  serde = { version = "1", features = [ "derive" ] }
>  serde_json = "1"
> diff --git a/proxmox-ve-config/src/host/mod.rs b/proxmox-ve-config/src/host/mod.rs
> new file mode 100644
> index 0000000..b5614dd
> --- /dev/null
> +++ b/proxmox-ve-config/src/host/mod.rs
> @@ -0,0 +1 @@
> +pub mod utils;
> diff --git a/proxmox-ve-config/src/host/utils.rs b/proxmox-ve-config/src/host/utils.rs
> new file mode 100644
> index 0000000..1636f95
> --- /dev/null
> +++ b/proxmox-ve-config/src/host/utils.rs
> @@ -0,0 +1,97 @@
> +use std::net::{IpAddr, ToSocketAddrs};
> +use std::sync::OnceLock;
> +
> +use crate::firewall::types::Cidr;
> +
> +use nix::sys::socket::{AddressFamily, SockaddrLike};
> +
> +pub fn hostname() -> &'static str {
> +    static HOSTNAME: OnceLock<String> = OnceLock::new();
> +
> +    // We should rather use get_or_try_init to avoid needing to panic
> +    // but it is currently experimental
> +    HOSTNAME.get_or_init(|| {
> +        use nix::libc::{c_char, gethostname, sysconf, _SC_HOST_NAME_MAX};
> +        use std::ffi::CStr;
> +
> +        let max_len = unsafe { sysconf(_SC_HOST_NAME_MAX) } as usize + 1;
> +        let mut buffer = vec![0; max_len];
> +
> +        let ret = unsafe { gethostname(buffer.as_mut_ptr() as *mut c_char, buffer.len()) };
> +
> +        if ret != 0 {
> +            // failing to get the hostname means something is *really* off
> +            panic!("gethostname failed with returncode {ret}");
> +        }
> +
> +        let c_str = CStr::from_bytes_until_nul(&buffer).expect("buffer contains a NUL byte");
> +
> +        String::from_utf8_lossy(c_str.to_bytes()).to_string()
> +    })

IMO the closures here and below could later be put into something like
proxmox-sys (or similar) as freestanding functions without static data
and then called here - but this is fine as it is; just an idea!

> +}
> +
> +pub fn host_ips() -> &'static [IpAddr] {
> +    static IP_ADDRESSES: OnceLock<Vec<IpAddr>> = OnceLock::new();
> +
> +    // We should rather use get_or_try_init to avoid needing to panic
> +    // but it is currently experimental
> +    IP_ADDRESSES.get_or_init(|| {
> +        let hostname = hostname();
> +
> +        format!("{hostname}:0")
> +            .to_socket_addrs()
> +            .expect("local hostname is resolvable")
> +            .map(|addr| addr.ip())
> +            .collect()
> +    })

^ Here as well.

> +}
> +
> +pub fn network_interface_cidrs() -> &'static [Cidr] {
> +    static INTERFACES: OnceLock<Vec<Cidr>> = OnceLock::new();
> +
> +    // We should rather use get_or_try_init to avoid needing to panic
> +    // but it is currently experimental
> +    INTERFACES.get_or_init(|| {
> +        use nix::ifaddrs::getifaddrs;
> +
> +        let mut cidrs = Vec::new();
> +
> +        let interfaces = getifaddrs().expect("should be able to query network interfaces");
> +
> +        for interface in interfaces {
> +            if let (Some(address), Some(netmask)) = (interface.address, interface.netmask) {
> +                match (address.family(), netmask.family()) {
> +                    (Some(AddressFamily::Inet), Some(AddressFamily::Inet)) => {
> +                        let address = address.as_sockaddr_in().expect("is an IPv4 address").ip();
> +
> +                        let netmask = netmask
> +                            .as_sockaddr_in()
> +                            .expect("is an IPv4 address")
> +                            .ip()
> +                            .count_ones()
> +                            .try_into()
> +                            .expect("count_ones of u32 is < u8_max");
> +
> +                        cidrs.push(Cidr::new_v4(address, netmask).expect("netmask is valid"));
> +                    }
> +                    (Some(AddressFamily::Inet6), Some(AddressFamily::Inet6)) => {
> +                        let address = address.as_sockaddr_in6().expect("is an IPv6 address").ip();
> +
> +                        let netmask_address =
> +                            netmask.as_sockaddr_in6().expect("is an IPv6 address").ip();
> +
> +                        let netmask = u128::from_be_bytes(netmask_address.octets())
> +                            .count_ones()
> +                            .try_into()
> +                            .expect("count_ones of u128 is < u8_max");
> +
> +                        cidrs.push(Cidr::new_v6(address, netmask).expect("netmask is valid"));
> +                    }
> +                    _ => continue,
> +                }
> +            }
> +        }
> +
> +        cidrs
> +    })

^ And this chonker too.

> +}
> diff --git a/proxmox-ve-config/src/lib.rs b/proxmox-ve-config/src/lib.rs
> index a0734b8..2bf9352 100644
> --- a/proxmox-ve-config/src/lib.rs
> +++ b/proxmox-ve-config/src/lib.rs
> @@ -1 +1,2 @@
>  pub mod firewall;
> +pub mod host;





^ permalink raw reply	[flat|nested] 67+ messages in thread

* Re: [pve-devel] [PATCH proxmox-firewall 09/37] config: firewall: add types for rules
  2024-04-02 17:16 ` [pve-devel] [PATCH proxmox-firewall 09/37] config: firewall: add types for rules Stefan Hanreich
@ 2024-04-03 10:46   ` Max Carrara
  2024-04-09  8:36     ` Stefan Hanreich
  2024-04-09 14:55     ` Lukas Wagner
  0 siblings, 2 replies; 67+ messages in thread
From: Max Carrara @ 2024-04-03 10:46 UTC (permalink / raw)
  To: Proxmox VE development discussion; +Cc: Wolfgang Bumiller

On Tue Apr 2, 2024 at 7:16 PM CEST, Stefan Hanreich wrote:
> Additionally we implement FromStr for all rule types and parts, which
> can be used for parsing firewall config rules. Initial rule parsing
> works by parsing the different options into a HashMap and only then
> de-serializing a struct from the parsed options.
>
> This intermediate step makes rule parsing a lot easier, since we can
> reuse the deserialization logic from serde. Also, we can split the
> parsing/deserialization logic from the validation logic.
>
> Co-authored-by: Wolfgang Bumiller <w.bumiller@proxmox.com>
> Signed-off-by: Stefan Hanreich <s.hanreich@proxmox.com>
> ---
>  proxmox-ve-config/src/firewall/parse.rs       | 185 ++++
>  proxmox-ve-config/src/firewall/types/mod.rs   |   3 +
>  proxmox-ve-config/src/firewall/types/rule.rs  | 412 ++++++++
>  .../src/firewall/types/rule_match.rs          | 953 ++++++++++++++++++
>  4 files changed, 1553 insertions(+)
>  create mode 100644 proxmox-ve-config/src/firewall/types/rule.rs
>  create mode 100644 proxmox-ve-config/src/firewall/types/rule_match.rs
>
> diff --git a/proxmox-ve-config/src/firewall/parse.rs b/proxmox-ve-config/src/firewall/parse.rs
> index 669623b..227e045 100644
> --- a/proxmox-ve-config/src/firewall/parse.rs
> +++ b/proxmox-ve-config/src/firewall/parse.rs
> @@ -1,3 +1,5 @@
> +use std::fmt;
> +
>  use anyhow::{bail, format_err, Error};
>  
>  /// Parses out a "name" which can be alphanumeric and include dashes.
> @@ -78,3 +80,186 @@ pub fn parse_bool(value: &str) -> Result<bool, Error> {
>          },
>      )
>  }
> +
> +/// `&str` deserializer which also accepts an `Option`.
> +///
> +/// Serde's `StringDeserializer` does not.
> +#[derive(Clone, Copy, Debug)]
> +pub struct SomeStrDeserializer<'a, E>(serde::de::value::StrDeserializer<'a, E>);
> +
> +impl<'de, 'a, E> serde::de::Deserializer<'de> for SomeStrDeserializer<'a, E>
> +where
> +    E: serde::de::Error,
> +{
> +    type Error = E;
> +
> +    fn deserialize_any<V>(self, visitor: V) -> Result<V::Value, Self::Error>
> +    where
> +        V: serde::de::Visitor<'de>,
> +    {
> +        self.0.deserialize_any(visitor)
> +    }
> +
> +    fn deserialize_option<V>(self, visitor: V) -> Result<V::Value, Self::Error>
> +    where
> +        V: serde::de::Visitor<'de>,
> +    {
> +        visitor.visit_some(self.0)
> +    }
> +
> +    fn deserialize_str<V>(self, visitor: V) -> Result<V::Value, Self::Error>
> +    where
> +        V: serde::de::Visitor<'de>,
> +    {
> +        self.0.deserialize_str(visitor)
> +    }
> +
> +    fn deserialize_string<V>(self, visitor: V) -> Result<V::Value, Self::Error>
> +    where
> +        V: serde::de::Visitor<'de>,
> +    {
> +        self.0.deserialize_string(visitor)
> +    }
> +
> +    fn deserialize_enum<V>(
> +        self,
> +        _name: &str,
> +        _variants: &'static [&'static str],
> +        visitor: V,
> +    ) -> Result<V::Value, Self::Error>
> +    where
> +        V: serde::de::Visitor<'de>,
> +    {
> +        visitor.visit_enum(self.0)
> +    }
> +
> +    serde::forward_to_deserialize_any! {
> +        bool i8 i16 i32 i64 i128 u8 u16 u32 u64 u128 f32 f64 char
> +        bytes byte_buf unit unit_struct newtype_struct seq tuple
> +        tuple_struct map struct identifier ignored_any
> +    }
> +}
> +
> +/// `&str` wrapper which implements `IntoDeserializer` via `SomeStrDeserializer`.
> +#[derive(Clone, Debug)]
> +pub struct SomeStr<'a>(pub &'a str);
> +
> +impl<'a> From<&'a str> for SomeStr<'a> {
> +    fn from(s: &'a str) -> Self {
> +        Self(s)
> +    }
> +}
> +
> +impl<'de, 'a, E> serde::de::IntoDeserializer<'de, E> for SomeStr<'a>
> +where
> +    E: serde::de::Error,
> +{
> +    type Deserializer = SomeStrDeserializer<'a, E>;
> +
> +    fn into_deserializer(self) -> Self::Deserializer {
> +        SomeStrDeserializer(self.0.into_deserializer())
> +    }
> +}
> +
> +/// `String` deserializer which also accepts an `Option`.
> +///
> +/// Serde's `StringDeserializer` does not.
> +#[derive(Clone, Debug)]
> +pub struct SomeStringDeserializer<E>(serde::de::value::StringDeserializer<E>);
> +
> +impl<'de, E> serde::de::Deserializer<'de> for SomeStringDeserializer<E>
> +where
> +    E: serde::de::Error,
> +{
> +    type Error = E;
> +
> +    fn deserialize_any<V>(self, visitor: V) -> Result<V::Value, Self::Error>
> +    where
> +        V: serde::de::Visitor<'de>,
> +    {
> +        self.0.deserialize_any(visitor)
> +    }
> +
> +    fn deserialize_option<V>(self, visitor: V) -> Result<V::Value, Self::Error>
> +    where
> +        V: serde::de::Visitor<'de>,
> +    {
> +        visitor.visit_some(self.0)
> +    }
> +
> +    fn deserialize_str<V>(self, visitor: V) -> Result<V::Value, Self::Error>
> +    where
> +        V: serde::de::Visitor<'de>,
> +    {
> +        self.0.deserialize_str(visitor)
> +    }
> +
> +    fn deserialize_string<V>(self, visitor: V) -> Result<V::Value, Self::Error>
> +    where
> +        V: serde::de::Visitor<'de>,
> +    {
> +        self.0.deserialize_string(visitor)
> +    }
> +
> +    fn deserialize_enum<V>(
> +        self,
> +        _name: &str,
> +        _variants: &'static [&'static str],
> +        visitor: V,
> +    ) -> Result<V::Value, Self::Error>
> +    where
> +        V: serde::de::Visitor<'de>,
> +    {
> +        visitor.visit_enum(self.0)
> +    }
> +
> +    serde::forward_to_deserialize_any! {
> +        bool i8 i16 i32 i64 i128 u8 u16 u32 u64 u128 f32 f64 char
> +        bytes byte_buf unit unit_struct newtype_struct seq tuple
> +        tuple_struct map struct identifier ignored_any
> +    }
> +}
> +
> +/// `&str` wrapper which implements `IntoDeserializer` via `SomeStringDeserializer`.
> +#[derive(Clone, Debug)]
> +pub struct SomeString(pub String);
> +
> +impl From<&str> for SomeString {
> +    fn from(s: &str) -> Self {
> +        Self::from(s.to_string())
> +    }
> +}
> +
> +impl From<String> for SomeString {
> +    fn from(s: String) -> Self {
> +        Self(s)
> +    }
> +}
> +
> +impl<'de, E> serde::de::IntoDeserializer<'de, E> for SomeString
> +where
> +    E: serde::de::Error,
> +{
> +    type Deserializer = SomeStringDeserializer<E>;
> +
> +    fn into_deserializer(self) -> Self::Deserializer {
> +        SomeStringDeserializer(self.0.into_deserializer())
> +    }
> +}
> +
> +#[derive(Debug)]
> +pub struct SerdeStringError(String);
> +
> +impl std::error::Error for SerdeStringError {}
> +
> +impl fmt::Display for SerdeStringError {
> +    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
> +        f.write_str(&self.0)
> +    }
> +}
> +
> +impl serde::de::Error for SerdeStringError {
> +    fn custom<T: fmt::Display>(msg: T) -> Self {
> +        Self(msg.to_string())
> +    }
> +}
> diff --git a/proxmox-ve-config/src/firewall/types/mod.rs b/proxmox-ve-config/src/firewall/types/mod.rs
> index 5833787..b4a6b12 100644
> --- a/proxmox-ve-config/src/firewall/types/mod.rs
> +++ b/proxmox-ve-config/src/firewall/types/mod.rs
> @@ -3,7 +3,10 @@ pub mod alias;
>  pub mod ipset;
>  pub mod log;
>  pub mod port;
> +pub mod rule;
> +pub mod rule_match;
>  
>  pub use address::Cidr;
>  pub use alias::Alias;
>  pub use ipset::Ipset;
> +pub use rule::Rule;
> diff --git a/proxmox-ve-config/src/firewall/types/rule.rs b/proxmox-ve-config/src/firewall/types/rule.rs
> new file mode 100644
> index 0000000..20deb3a
> --- /dev/null
> +++ b/proxmox-ve-config/src/firewall/types/rule.rs
> @@ -0,0 +1,412 @@
> +use core::fmt::Display;
> +use std::fmt;
> +use std::str::FromStr;
> +
> +use anyhow::{bail, ensure, format_err, Error};
> +
> +use crate::firewall::parse::match_name;
> +use crate::firewall::types::rule_match::RuleMatch;
> +use crate::firewall::types::rule_match::RuleOptions;
> +
> +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
> +pub enum Direction {
> +    #[default]
> +    In,
> +    Out,
> +}
> +
> +impl std::str::FromStr for Direction {
> +    type Err = Error;
> +
> +    fn from_str(s: &str) -> Result<Self, Error> {
> +        for (name, dir) in [("IN", Direction::In), ("OUT", Direction::Out)] {
> +            if s.eq_ignore_ascii_case(name) {
> +                return Ok(dir);
> +            }
> +        }
> +
> +        bail!("invalid direction: {s:?}, expect 'IN' or 'OUT'");
> +    }
> +}
> +
> +serde_plain::derive_deserialize_from_fromstr!(Direction, "valid packet direction");
> +
> +impl fmt::Display for Direction {
> +    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
> +        match self {
> +            Direction::In => f.write_str("in"),
> +            Direction::Out => f.write_str("out"),
> +        }
> +    }
> +}
> +
> +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
> +pub enum Verdict {
> +    Accept,
> +    Reject,
> +    #[default]
> +    Drop,
> +}
> +
> +impl std::str::FromStr for Verdict {
> +    type Err = Error;
> +
> +    fn from_str(s: &str) -> Result<Self, Error> {
> +        for (name, verdict) in [
> +            ("ACCEPT", Verdict::Accept),
> +            ("REJECT", Verdict::Reject),
> +            ("DROP", Verdict::Drop),
> +        ] {
> +            if s.eq_ignore_ascii_case(name) {
> +                return Ok(verdict);
> +            }
> +        }
> +        bail!("invalid verdict {s:?}, expected one of 'ACCEPT', 'REJECT' or 'DROP'");
> +    }
> +}
> +
> +impl Display for Verdict {
> +    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
> +        let string = match self {
> +            Verdict::Accept => "ACCEPT",
> +            Verdict::Drop => "DROP",
> +            Verdict::Reject => "REJECT",
> +        };
> +
> +        write!(f, "{string}")
> +    }
> +}
> +
> +serde_plain::derive_deserialize_from_fromstr!(Verdict, "valid verdict");
> +
> +#[derive(Clone, Debug)]
> +#[cfg_attr(test, derive(Eq, PartialEq))]
> +pub struct Rule {
> +    pub(crate) disabled: bool,
> +    pub(crate) kind: Kind,
> +    pub(crate) comment: Option<String>,
> +}
> +
> +impl std::ops::Deref for Rule {
> +    type Target = Kind;
> +
> +    fn deref(&self) -> &Self::Target {
> +        &self.kind
> +    }
> +}
> +
> +impl std::ops::DerefMut for Rule {
> +    fn deref_mut(&mut self) -> &mut Self::Target {
> +        &mut self.kind
> +    }
> +}
> +
> +impl FromStr for Rule {
> +    type Err = Error;
> +
> +    fn from_str(input: &str) -> Result<Self, Self::Err> {
> +        if input.contains(['\n', '\r']) {
> +            bail!("rule must not contain any newlines!");
> +        }
> +
> +        let (line, comment) = match input.rsplit_once('#') {
> +            Some((line, comment)) if !comment.is_empty() => (line.trim(), Some(comment.trim())),
> +            _ => (input.trim(), None),
> +        };
> +
> +        let (disabled, line) = match line.strip_prefix('|') {
> +            Some(line) => (true, line.trim_start()),
> +            None => (false, line),
> +        };
> +
> +        // todo: case insensitive?
> +        let kind = if line.starts_with("GROUP") {
> +            Kind::from(line.parse::<RuleGroup>()?)
> +        } else {
> +            Kind::from(line.parse::<RuleMatch>()?)
> +        };
> +
> +        Ok(Self {
> +            disabled,
> +            comment: comment.map(str::to_string),
> +            kind,
> +        })
> +    }
> +}
> +
> +impl Rule {
> +    pub fn iface(&self) -> Option<&str> {
> +        match &self.kind {
> +            Kind::Group(group) => group.iface(),
> +            Kind::Match(rule) => rule.iface(),
> +        }
> +    }
> +
> +    pub fn disabled(&self) -> bool {
> +        self.disabled
> +    }
> +
> +    pub fn kind(&self) -> &Kind {
> +        &self.kind
> +    }
> +
> +    pub fn comment(&self) -> Option<&str> {
> +        self.comment.as_deref()
> +    }
> +}
> +
> +#[derive(Clone, Debug)]
> +#[cfg_attr(test, derive(Eq, PartialEq))]
> +pub enum Kind {
> +    Group(RuleGroup),
> +    Match(RuleMatch),
> +}
> +
> +impl Kind {
> +    pub fn is_group(&self) -> bool {
> +        matches!(self, Kind::Group(_))
> +    }
> +
> +    pub fn is_match(&self) -> bool {
> +        matches!(self, Kind::Match(_))
> +    }
> +}
> +
> +impl From<RuleGroup> for Kind {
> +    fn from(value: RuleGroup) -> Self {
> +        Kind::Group(value)
> +    }
> +}
> +
> +impl From<RuleMatch> for Kind {
> +    fn from(value: RuleMatch) -> Self {
> +        Kind::Match(value)
> +    }
> +}
> +
> +#[derive(Clone, Debug)]
> +#[cfg_attr(test, derive(Eq, PartialEq))]
> +pub struct RuleGroup {
> +    pub(crate) group: String,
> +    pub(crate) iface: Option<String>,
> +}
> +
> +impl RuleGroup {
> +    pub(crate) fn from_options(group: String, options: RuleOptions) -> Result<Self, Error> {
> +        ensure!(
> +            options.proto.is_none()
> +                && options.dport.is_none()
> +                && options.sport.is_none()
> +                && options.dest.is_none()
> +                && options.source.is_none()
> +                && options.log.is_none()
> +                && options.icmp_type.is_none(),
> +            "only interface parameter is permitted for group rules"
> +        );
> +
> +        Ok(Self {
> +            group,
> +            iface: options.iface,
> +        })
> +    }
> +
> +    pub fn group(&self) -> &str {
> +        &self.group
> +    }
> +
> +    pub fn iface(&self) -> Option<&str> {
> +        self.iface.as_deref()
> +    }
> +}
> +
> +impl FromStr for RuleGroup {
> +    type Err = Error;
> +
> +    fn from_str(input: &str) -> Result<Self, Self::Err> {
> +        let (keyword, rest) = match_name(input)
> +            .ok_or_else(|| format_err!("expected a leading keyword in rule group"))?;
> +
> +        if !keyword.eq_ignore_ascii_case("group") {
> +            bail!("Expected keyword GROUP")
> +        }
> +
> +        let (name, rest) =
> +            match_name(rest.trim()).ok_or_else(|| format_err!("expected a name for rule group"))?;
> +
> +        let options = rest.trim_start().parse()?;
> +
> +        Self::from_options(name.to_string(), options)
> +    }
> +}
> +
> +#[cfg(test)]
> +mod tests {
> +    use crate::firewall::types::{
> +        address::{IpEntry, IpList},
> +        alias::{AliasName, AliasScope},
> +        ipset::{IpsetName, IpsetScope},
> +        log::LogLevel,
> +        rule_match::{Icmp, IcmpCode, IpAddrMatch, IpMatch, Ports, Protocol, Udp},
> +        Cidr,
> +    };
> +
> +    use super::*;
> +
> +    #[test]
> +    fn test_parse_rule() {
> +        let mut rule: Rule = "|GROUP tgr -i eth0 # acomm".parse().expect("valid rule");
> +
> +        assert_eq!(
> +            rule,
> +            Rule {
> +                disabled: true,
> +                comment: Some("acomm".to_string()),
> +                kind: Kind::Group(RuleGroup {
> +                    group: "tgr".to_string(),
> +                    iface: Some("eth0".to_string()),
> +                }),
> +            },
> +        );
> +
> +        rule = "IN ACCEPT -p udp -dport 33 -sport 22 -log warning"
> +            .parse()
> +            .expect("valid rule");
> +
> +        assert_eq!(
> +            rule,
> +            Rule {
> +                disabled: false,
> +                comment: None,
> +                kind: Kind::Match(RuleMatch {
> +                    dir: Direction::In,
> +                    verdict: Verdict::Accept,
> +                    proto: Some(Udp::new(Ports::from_u16(22, 33)).into()),
> +                    log: Some(LogLevel::Warning),
> +                    ..Default::default()
> +                }),
> +            }
> +        );
> +
> +        rule = "IN ACCEPT --proto udp -i eth0".parse().expect("valid rule");
> +
> +        assert_eq!(
> +            rule,
> +            Rule {
> +                disabled: false,
> +                comment: None,
> +                kind: Kind::Match(RuleMatch {
> +                    dir: Direction::In,
> +                    verdict: Verdict::Accept,
> +                    proto: Some(Udp::new(Ports::new(None, None)).into()),
> +                    iface: Some("eth0".to_string()),
> +                    ..Default::default()
> +                }),
> +            }
> +        );
> +
> +        rule = " OUT DROP \
> +          -source 10.0.0.0/24 -dest 20.0.0.0-20.255.255.255,192.168.0.0/16 \
> +          -p icmp -log nolog -icmp-type port-unreachable "
> +            .parse()
> +            .expect("valid rule");
> +
> +        assert_eq!(
> +            rule,
> +            Rule {
> +                disabled: false,
> +                comment: None,
> +                kind: Kind::Match(RuleMatch {
> +                    dir: Direction::Out,
> +                    verdict: Verdict::Drop,
> +                    ip: IpMatch::new(
> +                        IpAddrMatch::Ip(IpList::from(Cidr::new_v4([10, 0, 0, 0], 24).unwrap())),
> +                        IpAddrMatch::Ip(
> +                            IpList::new(vec![
> +                                IpEntry::Range([20, 0, 0, 0].into(), [20, 255, 255, 255].into()),
> +                                IpEntry::Cidr(Cidr::new_v4([192, 168, 0, 0], 16).unwrap()),
> +                            ])
> +                            .unwrap()
> +                        ),
> +                    )
> +                    .ok(),
> +                    proto: Some(Protocol::Icmp(Icmp::new_code(IcmpCode::Named(
> +                        "port-unreachable"
> +                    )))),
> +                    log: Some(LogLevel::Nolog),
> +                    ..Default::default()
> +                }),
> +            }
> +        );
> +
> +        rule = "IN BGP(ACCEPT) --log crit --iface eth0"
> +            .parse()
> +            .expect("valid rule");
> +
> +        assert_eq!(
> +            rule,
> +            Rule {
> +                disabled: false,
> +                comment: None,
> +                kind: Kind::Match(RuleMatch {
> +                    dir: Direction::In,
> +                    verdict: Verdict::Accept,
> +                    log: Some(LogLevel::Critical),
> +                    fw_macro: Some("BGP".to_string()),
> +                    iface: Some("eth0".to_string()),
> +                    ..Default::default()
> +                }),
> +            }
> +        );
> +
> +        rule = "IN ACCEPT --source dc/test --dest +dc/test"
> +            .parse()
> +            .expect("valid rule");
> +
> +        assert_eq!(
> +            rule,
> +            Rule {
> +                disabled: false,
> +                comment: None,
> +                kind: Kind::Match(RuleMatch {
> +                    dir: Direction::In,
> +                    verdict: Verdict::Accept,
> +                    ip: Some(
> +                        IpMatch::new(
> +                            IpAddrMatch::Alias(AliasName::new(AliasScope::Datacenter, "test")),
> +                            IpAddrMatch::Set(IpsetName::new(IpsetScope::Datacenter, "test"),),
> +                        )
> +                        .unwrap()
> +                    ),
> +                    ..Default::default()
> +                }),
> +            }
> +        );
> +
> +        rule = "IN REJECT".parse().expect("valid rule");
> +
> +        assert_eq!(
> +            rule,
> +            Rule {
> +                disabled: false,
> +                comment: None,
> +                kind: Kind::Match(RuleMatch {
> +                    dir: Direction::In,
> +                    verdict: Verdict::Reject,
> +                    ..Default::default()
> +                }),
> +            }
> +        );
> +
> +        "IN DROP ---log crit"
> +            .parse::<Rule>()
> +            .expect_err("too many dashes in option");
> +
> +        "IN DROP --log --iface eth0"
> +            .parse::<Rule>()
> +            .expect_err("no value for option");
> +
> +        "IN DROP --log crit --iface"
> +            .parse::<Rule>()
> +            .expect_err("no value for option");
> +    }
> +}
> diff --git a/proxmox-ve-config/src/firewall/types/rule_match.rs b/proxmox-ve-config/src/firewall/types/rule_match.rs
> new file mode 100644
> index 0000000..ae5345c
> --- /dev/null
> +++ b/proxmox-ve-config/src/firewall/types/rule_match.rs
> @@ -0,0 +1,953 @@
> +use std::collections::HashMap;
> +use std::fmt;
> +use std::str::FromStr;
> +
> +use serde::Deserialize;
> +
> +use anyhow::{bail, format_err, Error};
> +use serde::de::IntoDeserializer;
> +
> +use crate::firewall::parse::{match_name, match_non_whitespace, SomeStr};
> +use crate::firewall::types::address::{Family, IpList};
> +use crate::firewall::types::alias::AliasName;
> +use crate::firewall::types::ipset::IpsetName;
> +use crate::firewall::types::log::LogLevel;
> +use crate::firewall::types::port::PortList;
> +use crate::firewall::types::rule::{Direction, Verdict};
> +
> +#[derive(Clone, Debug, Default, Deserialize)]
> +#[cfg_attr(test, derive(Eq, PartialEq))]
> +#[serde(deny_unknown_fields, rename_all = "kebab-case")]
> +pub(crate) struct RuleOptions {
> +    #[serde(alias = "p")]
> +    pub(crate) proto: Option<String>,
> +
> +    pub(crate) dport: Option<String>,
> +    pub(crate) sport: Option<String>,
> +
> +    pub(crate) dest: Option<String>,
> +    pub(crate) source: Option<String>,
> +
> +    #[serde(alias = "i")]
> +    pub(crate) iface: Option<String>,
> +
> +    pub(crate) log: Option<LogLevel>,
> +    pub(crate) icmp_type: Option<String>,
> +}
> +
> +impl FromStr for RuleOptions {
> +    type Err = Error;
> +
> +    fn from_str(mut line: &str) -> Result<Self, Self::Err> {
> +        let mut options = HashMap::new();
> +
> +        loop {
> +            line = line.trim_start();
> +
> +            if line.is_empty() {
> +                break;
> +            }
> +
> +            line = line
> +                .strip_prefix('-')
> +                .ok_or_else(|| format_err!("expected an option starting with '-'"))?;
> +
> +            // second dash is optional
> +            line = line.strip_prefix('-').unwrap_or(line);
> +
> +            let param;
> +            (param, line) = match_name(line)
> +                .ok_or_else(|| format_err!("expected a parameter name after '-'"))?;
> +
> +            let value;
> +            (value, line) = match_non_whitespace(line.trim_start())
> +                .ok_or_else(|| format_err!("expected a value for {param:?}"))?;
> +
> +            if options.insert(param, SomeStr(value)).is_some() {
> +                bail!("duplicate option in rule: {param}")
> +            }
> +        }
> +
> +        Ok(RuleOptions::deserialize(IntoDeserializer::<
> +            '_,
> +            crate::firewall::parse::SerdeStringError,
> +        >::into_deserializer(
> +            options
> +        ))?)
> +    }
> +}
> +
> +#[derive(Clone, Debug, Default)]
> +#[cfg_attr(test, derive(Eq, PartialEq))]
> +pub struct RuleMatch {
> +    pub(crate) dir: Direction,
> +    pub(crate) verdict: Verdict,
> +    pub(crate) fw_macro: Option<String>,
> +
> +    pub(crate) iface: Option<String>,
> +    pub(crate) log: Option<LogLevel>,
> +    pub(crate) ip: Option<IpMatch>,
> +    pub(crate) proto: Option<Protocol>,
> +}
> +
> +impl RuleMatch {
> +    pub(crate) fn from_options(
> +        dir: Direction,
> +        verdict: Verdict,
> +        fw_macro: impl Into<Option<String>>,
> +        options: RuleOptions,
> +    ) -> Result<Self, Error> {
> +        if options.dport.is_some() && options.icmp_type.is_some() {
> +            bail!("dport and icmp-type are mutually exclusive");
> +        }
> +
> +        let ip = IpMatch::from_options(&options)?;
> +        let proto = Protocol::from_options(&options)?;
> +
> +        // todo: check protocol & IP Version compatibility
> +
> +        Ok(Self {
> +            dir,
> +            verdict,
> +            fw_macro: fw_macro.into(),
> +            iface: options.iface,
> +            log: options.log,
> +            ip,
> +            proto,
> +        })
> +    }
> +
> +    pub fn direction(&self) -> Direction {
> +        self.dir
> +    }
> +
> +    pub fn iface(&self) -> Option<&str> {
> +        self.iface.as_deref()
> +    }
> +
> +    pub fn verdict(&self) -> Verdict {
> +        self.verdict
> +    }
> +
> +    pub fn fw_macro(&self) -> Option<&str> {
> +        self.fw_macro.as_deref()
> +    }
> +
> +    pub fn log(&self) -> Option<LogLevel> {
> +        self.log
> +    }
> +
> +    pub fn ip(&self) -> Option<&IpMatch> {
> +        self.ip.as_ref()
> +    }
> +
> +    pub fn proto(&self) -> Option<&Protocol> {
> +        self.proto.as_ref()
> +    }
> +}
> +
> +/// Returns `(Macro name, Verdict, RestOfTheLine)`.
> +fn parse_action(line: &str) -> Result<(Option<&str>, Verdict, &str), Error> {

Hmm, since this is only used below, IMO it's fine that this returns a
tuple like that on `Ok` - but should functions like that be used in
multiple places, it might be beneficial to use a type alias or even a
tuple struct for readability's sake.

> +    let (verdict, line) =
> +        match_name(line).ok_or_else(|| format_err!("expected a verdict or macro name"))?;
> +
> +    Ok(if let Some(line) = line.strip_prefix('(') {
> +        // <macro>(<verdict>)
> +
> +        let macro_name = verdict;
> +        let (verdict, line) = match_name(line).ok_or_else(|| format_err!("expected a verdict"))?;
> +        let line = line
> +            .strip_prefix(')')
> +            .ok_or_else(|| format_err!("expected closing ')' after verdict"))?;
> +
> +        let verdict: Verdict = verdict.parse()?;
> +
> +        (Some(macro_name), verdict, line.trim_start())
> +    } else {
> +        (None, verdict.parse()?, line.trim_start())
> +    })
> +}
> +
> +impl FromStr for RuleMatch {
> +    type Err = Error;
> +
> +    fn from_str(line: &str) -> Result<Self, Self::Err> {
> +        let (dir, rest) = match_name(line).ok_or_else(|| format_err!("expected a direction"))?;
> +
> +        let direction: Direction = dir.parse()?;
> +
> +        let (fw_macro, verdict, rest) = parse_action(rest.trim_start())?;
> +
> +        let options: RuleOptions = rest.trim_start().parse()?;
> +
> +        Self::from_options(direction, verdict, fw_macro.map(str::to_string), options)
> +    }
> +}
> +
> +#[derive(Clone, Debug)]
> +#[cfg_attr(test, derive(Eq, PartialEq))]
> +pub struct IpMatch {
> +    pub(crate) src: Option<IpAddrMatch>,
> +    pub(crate) dst: Option<IpAddrMatch>,
> +}
> +
> +impl IpMatch {
> +    pub fn new(
> +        src: impl Into<Option<IpAddrMatch>>,
> +        dst: impl Into<Option<IpAddrMatch>>,
> +    ) -> Result<Self, Error> {
> +        let source = src.into();
> +        let dest = dst.into();
> +
> +        if source.is_none() && dest.is_none() {
> +            bail!("either src or dst must be set")
> +        }
> +
> +        if let (Some(src), Some(dst)) = (&source, &dest) {
> +            if src.family() != dst.family() {
> +                bail!("src and dst family must be equal")
> +            }
> +        }
> +
> +        let ip_match = Self {
> +            src: source,
> +            dst: dest,
> +        };
> +
> +        Ok(ip_match)
> +    }
> +
> +    fn from_options(options: &RuleOptions) -> Result<Option<Self>, Error> {
> +        let src = options
> +            .source
> +            .as_ref()
> +            .map(|elem| elem.parse::<IpAddrMatch>())
> +            .transpose()?;
> +
> +        let dst = options
> +            .dest
> +            .as_ref()
> +            .map(|elem| elem.parse::<IpAddrMatch>())
> +            .transpose()?;
> +
> +        Ok(IpMatch::new(src, dst).ok())
> +    }
> +
> +    pub fn src(&self) -> Option<&IpAddrMatch> {
> +        self.src.as_ref()
> +    }
> +
> +    pub fn dst(&self) -> Option<&IpAddrMatch> {
> +        self.dst.as_ref()
> +    }
> +}
> +
> +#[derive(Clone, Debug, Deserialize)]
> +#[cfg_attr(test, derive(Eq, PartialEq))]
> +pub enum IpAddrMatch {
> +    Ip(IpList),
> +    Set(IpsetName),
> +    Alias(AliasName),
> +}
> +
> +impl IpAddrMatch {
> +    pub fn family(&self) -> Option<Family> {
> +        if let IpAddrMatch::Ip(list) = self {
> +            return Some(list.family());
> +        }
> +
> +        None
> +    }
> +}
> +
> +impl FromStr for IpAddrMatch {
> +    type Err = Error;
> +
> +    fn from_str(value: &str) -> Result<Self, Error> {
> +        if value.is_empty() {
> +            bail!("empty IP specification");
> +        }
> +
> +        if let Ok(ip_list) = value.parse() {
> +            return Ok(IpAddrMatch::Ip(ip_list));
> +        }
> +
> +        if let Ok(ipset) = value.parse() {
> +            return Ok(IpAddrMatch::Set(ipset));
> +        }
> +
> +        if let Ok(name) = value.parse() {
> +            return Ok(IpAddrMatch::Alias(name));
> +        }
> +
> +        bail!("invalid IP specification: {value}")
> +    }
> +}
> +
> +#[derive(Clone, Debug)]
> +#[cfg_attr(test, derive(Eq, PartialEq))]
> +pub enum Protocol {
> +    Dccp(Ports),
> +    Sctp(Sctp),
> +    Tcp(Tcp),
> +    Udp(Udp),
> +    UdpLite(Ports),
> +    Icmp(Icmp),
> +    Icmpv6(Icmpv6),
> +    Named(String),
> +    Numeric(u8),
> +}
> +
> +impl Protocol {
> +    pub(crate) fn from_options(options: &RuleOptions) -> Result<Option<Self>, Error> {
> +        let proto = match options.proto.as_deref() {
> +            Some(p) => p,
> +            None => return Ok(None),
> +        };
> +
> +        Ok(Some(match proto {
> +            "dccp" | "33" => Protocol::Dccp(Ports::from_options(options)?),
> +            "sctp" | "132" => Protocol::Sctp(Sctp::from_options(options)?),
> +            "tcp" | "6" => Protocol::Tcp(Tcp::from_options(options)?),
> +            "udp" | "17" => Protocol::Udp(Udp::from_options(options)?),
> +            "udplite" | "136" => Protocol::UdpLite(Ports::from_options(options)?),
> +            "icmp" | "1" => Protocol::Icmp(Icmp::from_options(options)?),
> +            "ipv6-icmp" | "icmpv6" | "58" => Protocol::Icmpv6(Icmpv6::from_options(options)?),
> +            other => match other.parse::<u8>() {
> +                Ok(num) => Protocol::Numeric(num),
> +                Err(_) => Protocol::Named(other.to_string()),
> +            },
> +        }))
> +    }
> +
> +    pub fn family(&self) -> Option<Family> {
> +        match self {
> +            Self::Icmp(_) => Some(Family::V4),
> +            Self::Icmpv6(_) => Some(Family::V6),
> +            _ => None,
> +        }
> +    }
> +}
> +
> +#[derive(Clone, Debug, Default)]
> +#[cfg_attr(test, derive(Eq, PartialEq))]
> +pub struct Udp {
> +    ports: Ports,
> +}
> +
> +impl Udp {
> +    fn from_options(options: &RuleOptions) -> Result<Self, Error> {
> +        Ok(Self {
> +            ports: Ports::from_options(options)?,
> +        })
> +    }
> +
> +    pub fn new(ports: Ports) -> Self {
> +        Self { ports }
> +    }
> +
> +    pub fn ports(&self) -> &Ports {
> +        &self.ports
> +    }
> +}
> +
> +impl From<Udp> for Protocol {
> +    fn from(value: Udp) -> Self {
> +        Protocol::Udp(value)
> +    }
> +}
> +
> +#[derive(Clone, Debug, Default)]
> +#[cfg_attr(test, derive(Eq, PartialEq))]
> +pub struct Ports {
> +    sport: Option<PortList>,
> +    dport: Option<PortList>,
> +}
> +
> +impl Ports {
> +    pub fn new(sport: impl Into<Option<PortList>>, dport: impl Into<Option<PortList>>) -> Self {
> +        Self {
> +            sport: sport.into(),
> +            dport: dport.into(),
> +        }
> +    }
> +
> +    fn from_options(options: &RuleOptions) -> Result<Self, Error> {
> +        Ok(Self {
> +            sport: options.sport.as_deref().map(|s| s.parse()).transpose()?,
> +            dport: options.dport.as_deref().map(|s| s.parse()).transpose()?,
> +        })
> +    }
> +
> +    pub fn from_u16(sport: impl Into<Option<u16>>, dport: impl Into<Option<u16>>) -> Self {
> +        Self::new(
> +            sport.into().map(PortList::from),
> +            dport.into().map(PortList::from),
> +        )
> +    }
> +
> +    pub fn sport(&self) -> Option<&PortList> {
> +        self.sport.as_ref()
> +    }
> +
> +    pub fn dport(&self) -> Option<&PortList> {
> +        self.dport.as_ref()
> +    }
> +}
> +
> +#[derive(Clone, Debug, Default)]
> +#[cfg_attr(test, derive(Eq, PartialEq))]
> +pub struct Tcp {
> +    ports: Ports,
> +}
> +
> +impl Tcp {
> +    pub fn new(ports: Ports) -> Self {
> +        Self { ports }
> +    }
> +
> +    fn from_options(options: &RuleOptions) -> Result<Self, Error> {
> +        Ok(Self {
> +            ports: Ports::from_options(options)?,
> +        })
> +    }
> +
> +    pub fn ports(&self) -> &Ports {
> +        &self.ports
> +    }
> +}
> +
> +impl From<Tcp> for Protocol {
> +    fn from(value: Tcp) -> Self {
> +        Protocol::Tcp(value)
> +    }
> +}
> +
> +#[derive(Clone, Debug, Default)]
> +#[cfg_attr(test, derive(Eq, PartialEq))]
> +pub struct Sctp {
> +    ports: Ports,
> +}
> +
> +impl Sctp {
> +    fn from_options(options: &RuleOptions) -> Result<Self, Error> {
> +        Ok(Self {
> +            ports: Ports::from_options(options)?,
> +        })
> +    }
> +
> +    pub fn ports(&self) -> &Ports {
> +        &self.ports
> +    }
> +}
> +
> +#[derive(Clone, Debug, Default)]
> +#[cfg_attr(test, derive(Eq, PartialEq))]
> +pub struct Icmp {
> +    ty: Option<IcmpType>,
> +    code: Option<IcmpCode>,
> +}
> +
> +impl Icmp {
> +    pub fn new_ty(ty: IcmpType) -> Self {
> +        Self {
> +            ty: Some(ty),
> +            ..Default::default()
> +        }
> +    }
> +
> +    pub fn new_code(code: IcmpCode) -> Self {
> +        Self {
> +            code: Some(code),
> +            ..Default::default()
> +        }
> +    }
> +
> +    fn from_options(options: &RuleOptions) -> Result<Self, Error> {
> +        if let Some(ty) = &options.icmp_type {
> +            return ty.parse();
> +        }
> +
> +        Ok(Self::default())
> +    }
> +
> +    pub fn ty(&self) -> Option<&IcmpType> {
> +        self.ty.as_ref()
> +    }
> +
> +    pub fn code(&self) -> Option<&IcmpCode> {
> +        self.code.as_ref()
> +    }
> +}
> +
> +impl FromStr for Icmp {
> +    type Err = Error;
> +
> +    fn from_str(s: &str) -> Result<Self, Self::Err> {
> +        let mut this = Self::default();
> +
> +        if let Ok(ty) = s.parse() {
> +            this.ty = Some(ty);
> +            return Ok(this);
> +        }
> +
> +        if let Ok(code) = s.parse() {
> +            this.code = Some(code);
> +            return Ok(this);
> +        }
> +
> +        bail!("supplied string is neither a valid icmp type nor code");
> +    }
> +}
> +
> +#[derive(Clone, Debug)]
> +#[cfg_attr(test, derive(Eq, PartialEq))]
> +pub enum IcmpType {
> +    Numeric(u8),
> +    Named(&'static str),
> +}
> +
> +// MUST BE SORTED!

Should maaaybe note that it must be sorted for binary search, not just
for any reason. :P

> +const ICMP_TYPES: &[(&str, u8)] = &[
> +    ("address-mask-reply", 18),
> +    ("address-mask-request", 17),
> +    ("destination-unreachable", 3),
> +    ("echo-reply", 0),
> +    ("echo-request", 8),
> +    ("info-reply", 16),
> +    ("info-request", 15),
> +    ("parameter-problem", 12),
> +    ("redirect", 5),
> +    ("router-advertisement", 9),
> +    ("router-solicitation", 10),
> +    ("source-quench", 4),
> +    ("time-exceeded", 11),
> +    ("timestamp-reply", 14),
> +    ("timestamp-request", 13),
> +];
> +
> +impl std::str::FromStr for IcmpType {
> +    type Err = Error;
> +
> +    fn from_str(s: &str) -> Result<Self, Error> {
> +        if let Ok(ty) = s.trim().parse::<u8>() {
> +            return Ok(Self::Numeric(ty));
> +        }
> +
> +        if let Ok(index) = ICMP_TYPES.binary_search_by(|v| v.0.cmp(s)) {
> +            return Ok(Self::Named(ICMP_TYPES[index].0));
> +        }
> +
> +        bail!("{s:?} is not a valid icmp type");
> +    }
> +}
> +
> +impl fmt::Display for IcmpType {
> +    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
> +        match self {
> +            IcmpType::Numeric(ty) => write!(f, "{ty}"),
> +            IcmpType::Named(ty) => write!(f, "{ty}"),
> +        }
> +    }
> +}
> +
> +#[derive(Clone, Debug)]
> +#[cfg_attr(test, derive(Eq, PartialEq))]
> +pub enum IcmpCode {
> +    Numeric(u8),
> +    Named(&'static str),
> +}
> +
> +// MUST BE SORTED!

Same here.

> +const ICMP_CODES: &[(&str, u8)] = &[
> +    ("admin-prohibited", 13),
> +    ("host-prohibited", 10),
> +    ("host-unreachable", 1),
> +    ("net-prohibited", 9),
> +    ("net-unreachable", 0),
> +    ("port-unreachable", 3),
> +    ("prot-unreachable", 2),
> +];
> +
> +impl std::str::FromStr for IcmpCode {
> +    type Err = Error;
> +
> +    fn from_str(s: &str) -> Result<Self, Error> {
> +        if let Ok(code) = s.trim().parse::<u8>() {
> +            return Ok(Self::Numeric(code));
> +        }
> +
> +        if let Ok(index) = ICMP_CODES.binary_search_by(|v| v.0.cmp(s)) {
> +            return Ok(Self::Named(ICMP_CODES[index].0));
> +        }
> +
> +        bail!("{s:?} is not a valid icmp code");
> +    }
> +}
> +
> +impl fmt::Display for IcmpCode {
> +    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
> +        match self {
> +            IcmpCode::Numeric(code) => write!(f, "{code}"),
> +            IcmpCode::Named(code) => write!(f, "{code}"),
> +        }
> +    }
> +}
> +
> +#[derive(Clone, Debug, Default)]
> +#[cfg_attr(test, derive(Eq, PartialEq))]
> +pub struct Icmpv6 {
> +    pub ty: Option<Icmpv6Type>,
> +    pub code: Option<Icmpv6Code>,
> +}
> +
> +impl Icmpv6 {
> +    pub fn new_ty(ty: Icmpv6Type) -> Self {
> +        Self {
> +            ty: Some(ty),
> +            ..Default::default()
> +        }
> +    }
> +
> +    pub fn new_code(code: Icmpv6Code) -> Self {
> +        Self {
> +            code: Some(code),
> +            ..Default::default()
> +        }
> +    }
> +
> +    fn from_options(options: &RuleOptions) -> Result<Self, Error> {
> +        if let Some(ty) = &options.icmp_type {
> +            return ty.parse();
> +        }
> +
> +        Ok(Self::default())
> +    }
> +
> +    pub fn ty(&self) -> Option<&Icmpv6Type> {
> +        self.ty.as_ref()
> +    }
> +
> +    pub fn code(&self) -> Option<&Icmpv6Code> {
> +        self.code.as_ref()
> +    }
> +}
> +
> +impl FromStr for Icmpv6 {
> +    type Err = Error;
> +
> +    fn from_str(s: &str) -> Result<Self, Self::Err> {
> +        let mut this = Self::default();
> +
> +        if let Ok(ty) = s.parse() {
> +            this.ty = Some(ty);
> +            return Ok(this);
> +        }
> +
> +        if let Ok(code) = s.parse() {
> +            this.code = Some(code);
> +            return Ok(this);
> +        }
> +
> +        bail!("supplied string is neither a valid icmpv6 type nor code");
> +    }
> +}
> +
> +#[derive(Clone, Debug)]
> +#[cfg_attr(test, derive(Eq, PartialEq))]
> +pub enum Icmpv6Type {
> +    Numeric(u8),
> +    Named(&'static str),
> +}
> +
> +// MUST BE SORTED!

And here too.

> +const ICMPV6_TYPES: &[(&str, u8)] = &[
> +    ("destination-unreachable", 1),
> +    ("echo-reply", 129),
> +    ("echo-request", 128),
> +    ("ind-neighbor-advert", 142),
> +    ("ind-neighbor-solicit", 141),
> +    ("mld-listener-done", 132),
> +    ("mld-listener-query", 130),
> +    ("mld-listener-reduction", 132),
> +    ("mld-listener-report", 131),
> +    ("mld2-listener-report", 143),
> +    ("nd-neighbor-advert", 136),
> +    ("nd-neighbor-solicit", 135),
> +    ("nd-redirect", 137),
> +    ("nd-router-advert", 134),
> +    ("nd-router-solicit", 133),
> +    ("packet-too-big", 2),
> +    ("parameter-problem", 4),
> +    ("router-renumbering", 138),
> +    ("time-exceeded", 3),
> +];
> +
> +impl std::str::FromStr for Icmpv6Type {
> +    type Err = Error;
> +
> +    fn from_str(s: &str) -> Result<Self, Error> {
> +        if let Ok(ty) = s.trim().parse::<u8>() {
> +            return Ok(Self::Numeric(ty));
> +        }
> +
> +        if let Ok(index) = ICMPV6_TYPES.binary_search_by(|v| v.0.cmp(s)) {
> +            return Ok(Self::Named(ICMPV6_TYPES[index].0));
> +        }
> +
> +        bail!("{s:?} is not a valid icmpv6 type");
> +    }
> +}
> +
> +impl fmt::Display for Icmpv6Type {
> +    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
> +        match self {
> +            Icmpv6Type::Numeric(ty) => write!(f, "{ty}"),
> +            Icmpv6Type::Named(ty) => write!(f, "{ty}"),
> +        }
> +    }
> +}
> +
> +#[derive(Clone, Debug)]
> +#[cfg_attr(test, derive(Eq, PartialEq))]
> +pub enum Icmpv6Code {
> +    Numeric(u8),
> +    Named(&'static str),
> +}
> +
> +// MUST BE SORTED!

As well as here.

> +const ICMPV6_CODES: &[(&str, u8)] = &[
> +    ("addr-unreachable", 3),
> +    ("admin-prohibited", 1),
> +    ("no-route", 0),
> +    ("policy-fail", 5),
> +    ("port-unreachable", 4),
> +    ("reject-route", 6),
> +];
> +
> +impl std::str::FromStr for Icmpv6Code {
> +    type Err = Error;
> +
> +    fn from_str(s: &str) -> Result<Self, Error> {
> +        if let Ok(code) = s.trim().parse::<u8>() {
> +            return Ok(Self::Numeric(code));
> +        }
> +
> +        if let Ok(index) = ICMPV6_CODES.binary_search_by(|v| v.0.cmp(s)) {
> +            return Ok(Self::Named(ICMPV6_CODES[index].0));
> +        }
> +
> +        bail!("{s:?} is not a valid icmpv6 code");
> +    }
> +}
> +
> +impl fmt::Display for Icmpv6Code {
> +    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
> +        match self {
> +            Icmpv6Code::Numeric(code) => write!(f, "{code}"),
> +            Icmpv6Code::Named(code) => write!(f, "{code}"),
> +        }
> +    }
> +}
> +
> +#[cfg(test)]
> +mod tests {
> +    use crate::firewall::types::Cidr;
> +
> +    use super::*;
> +
> +    #[test]
> +    fn test_parse_action() {
> +        assert_eq!(parse_action("REJECT").unwrap(), (None, Verdict::Reject, ""));
> +
> +        assert_eq!(
> +            parse_action("SSH(ACCEPT) qweasd").unwrap(),
> +            (Some("SSH"), Verdict::Accept, "qweasd")
> +        );
> +    }
> +
> +    #[test]
> +    fn test_parse_ip_addr_match() {
> +        for input in [
> +            "10.0.0.0/8",
> +            "10.0.0.0/8,192.168.0.0-192.168.255.255,172.16.0.1",
> +            "dc/test",
> +            "+guest/proxmox",
> +        ] {
> +            input.parse::<IpAddrMatch>().expect("valid ip match");
> +        }
> +
> +        for input in [
> +            "10.0.0.0/",
> +            "10.0.0.0/8,192.168.256.0-192.168.255.255,172.16.0.1",
> +            "dcc/test",
> +            "+guest/",
> +            "",
> +        ] {
> +            input.parse::<IpAddrMatch>().expect_err("invalid ip match");
> +        }
> +    }
> +
> +    #[test]
> +    fn test_parse_options() {
> +        let mut options: RuleOptions =
> +            "-p udp --sport 123 --dport 234 -source 127.0.0.1 --dest 127.0.0.1 -i ens1 --log crit"
> +                .parse()
> +                .expect("valid option string");
> +
> +        assert_eq!(
> +            options,
> +            RuleOptions {
> +                proto: Some("udp".to_string()),
> +                sport: Some("123".to_string()),
> +                dport: Some("234".to_string()),
> +                source: Some("127.0.0.1".to_string()),
> +                dest: Some("127.0.0.1".to_string()),
> +                iface: Some("ens1".to_string()),
> +                log: Some(LogLevel::Critical),
> +                icmp_type: None,
> +            }
> +        );
> +
> +        options = "".parse().expect("valid option string");
> +
> +        assert_eq!(options, RuleOptions::default(),);
> +    }
> +
> +    #[test]
> +    fn test_construct_ip_match() {
> +        IpMatch::new(
> +            IpAddrMatch::Ip(IpList::from(Cidr::new_v4([10, 0, 0, 0], 8).unwrap())),
> +            IpAddrMatch::Ip(IpList::from(Cidr::new_v4([10, 0, 0, 0], 8).unwrap())),
> +        )
> +        .expect("valid ip match");
> +
> +        IpMatch::new(
> +            IpAddrMatch::Ip(IpList::from(Cidr::new_v4([10, 0, 0, 0], 8).unwrap())),
> +            IpAddrMatch::Ip(IpList::from(Cidr::new_v6([0x0000; 8], 8).unwrap())),
> +        )
> +        .expect_err("cannot mix ip families");
> +
> +        IpMatch::new(None, None).expect_err("at least one ip must be set");
> +    }
> +
> +    #[test]
> +    fn test_from_options() {
> +        let mut options = RuleOptions {
> +            proto: Some("tcp".to_string()),
> +            sport: Some("123".to_string()),
> +            dport: Some("234".to_string()),
> +            source: Some("192.168.0.1".to_string()),
> +            dest: Some("10.0.0.1".to_string()),
> +            iface: Some("eth123".to_string()),
> +            log: Some(LogLevel::Error),
> +            ..Default::default()
> +        };
> +
> +        assert_eq!(
> +            Protocol::from_options(&options).unwrap().unwrap(),
> +            Protocol::Tcp(Tcp::new(Ports::from_u16(123, 234))),
> +        );
> +
> +        assert_eq!(
> +            IpMatch::from_options(&options).unwrap().unwrap(),
> +            IpMatch::new(
> +                IpAddrMatch::Ip(IpList::from(Cidr::new_v4([192, 168, 0, 1], 32).unwrap()),),
> +                IpAddrMatch::Ip(IpList::from(Cidr::new_v4([10, 0, 0, 1], 32).unwrap()),)
> +            )
> +            .unwrap(),
> +        );
> +
> +        options = RuleOptions::default();
> +
> +        assert_eq!(Protocol::from_options(&options).unwrap(), None,);
> +
> +        assert_eq!(IpMatch::from_options(&options).unwrap(), None,);
> +
> +        options = RuleOptions {
> +            proto: Some("tcp".to_string()),
> +            sport: Some("qwe".to_string()),
> +            source: Some("qwe".to_string()),
> +            ..Default::default()
> +        };
> +
> +        Protocol::from_options(&options).expect_err("invalid source port");
> +
> +        IpMatch::from_options(&options).expect_err("invalid source address");
> +
> +        options = RuleOptions {
> +            icmp_type: Some("port-unreachable".to_string()),
> +            dport: Some("123".to_string()),
> +            ..Default::default()
> +        };
> +
> +        RuleMatch::from_options(Direction::In, Verdict::Drop, None, options)
> +            .expect_err("cannot mix dport and icmp-type");
> +    }
> +
> +    #[test]
> +    fn test_parse_icmp() {
> +        let mut icmp: Icmp = "info-request".parse().expect("valid icmp type");
> +
> +        assert_eq!(
> +            icmp,
> +            Icmp {
> +                ty: Some(IcmpType::Named("info-request")),
> +                code: None
> +            }
> +        );
> +
> +        icmp = "12".parse().expect("valid icmp type");
> +
> +        assert_eq!(
> +            icmp,
> +            Icmp {
> +                ty: Some(IcmpType::Numeric(12)),
> +                code: None
> +            }
> +        );
> +
> +        icmp = "port-unreachable".parse().expect("valid icmp code");
> +
> +        assert_eq!(
> +            icmp,
> +            Icmp {
> +                ty: None,
> +                code: Some(IcmpCode::Named("port-unreachable"))
> +            }
> +        );
> +    }
> +
> +    #[test]
> +    fn test_parse_icmp6() {
> +        let mut icmp: Icmpv6 = "echo-reply".parse().expect("valid icmpv6 type");
> +
> +        assert_eq!(
> +            icmp,
> +            Icmpv6 {
> +                ty: Some(Icmpv6Type::Named("echo-reply")),
> +                code: None
> +            }
> +        );
> +
> +        icmp = "12".parse().expect("valid icmpv6 type");
> +
> +        assert_eq!(
> +            icmp,
> +            Icmpv6 {
> +                ty: Some(Icmpv6Type::Numeric(12)),
> +                code: None
> +            }
> +        );
> +
> +        icmp = "admin-prohibited".parse().expect("valid icmpv6 code");
> +
> +        assert_eq!(
> +            icmp,
> +            Icmpv6 {
> +                ty: None,
> +                code: Some(Icmpv6Code::Named("admin-prohibited"))
> +            }
> +        );
> +    }
> +}





^ permalink raw reply	[flat|nested] 67+ messages in thread

* Re: [pve-devel] [PATCH proxmox-firewall 11/37] config: firewall: add generic parser for firewall configs
  2024-04-02 17:16 ` [pve-devel] [PATCH proxmox-firewall 11/37] config: firewall: add generic parser for firewall configs Stefan Hanreich
@ 2024-04-03 10:47   ` Max Carrara
  2024-04-09  8:38     ` Stefan Hanreich
  0 siblings, 1 reply; 67+ messages in thread
From: Max Carrara @ 2024-04-03 10:47 UTC (permalink / raw)
  To: Proxmox VE development discussion; +Cc: Wolfgang Bumiller

On Tue Apr 2, 2024 at 7:16 PM CEST, Stefan Hanreich wrote:
> Since the basic format of cluster, host and guest firewall
> configurations is the same, we create a generic parser that can handle
> the common config format. The main difference is in the available
> options, which can be passed via a generic parameter.
>
> Co-authored-by: Wolfgang Bumiller <w.bumiller@proxmox.com>
> Signed-off-by: Stefan Hanreich <s.hanreich@proxmox.com>
> ---
>  proxmox-ve-config/src/firewall/common.rs | 182 +++++++++++++++++++++
>  proxmox-ve-config/src/firewall/mod.rs    |   1 +
>  proxmox-ve-config/src/firewall/parse.rs  | 200 +++++++++++++++++++++++
>  3 files changed, 383 insertions(+)
>  create mode 100644 proxmox-ve-config/src/firewall/common.rs
>
> diff --git a/proxmox-ve-config/src/firewall/common.rs b/proxmox-ve-config/src/firewall/common.rs
> new file mode 100644
> index 0000000..887339b
> --- /dev/null
> +++ b/proxmox-ve-config/src/firewall/common.rs
> @@ -0,0 +1,182 @@
> +use std::collections::HashMap;
> +use std::io;
> +
> +use anyhow::{bail, format_err, Error};
> +use serde::de::IntoDeserializer;
> +
> +use crate::firewall::parse::{parse_named_section_tail, split_key_value, SomeString};
> +use crate::firewall::types::ipset::{IpsetName, IpsetScope};
> +use crate::firewall::types::{Alias, Group, Ipset, Rule};
> +
> +#[derive(Debug, Default)]
> +pub struct Config<O>
> +where
> +    O: Default + std::fmt::Debug + serde::de::DeserializeOwned,
> +{
> +    pub(crate) options: O,
> +    pub(crate) rules: Vec<Rule>,
> +    pub(crate) aliases: HashMap<String, Alias>,
> +    pub(crate) ipsets: HashMap<String, Ipset>,
> +    pub(crate) groups: HashMap<String, Group>,
> +}
> +
> +enum Sec {
> +    None,
> +    Options,
> +    Aliases,
> +    Rules,
> +    Ipset(String, Ipset),
> +    Group(String, Group),
> +}
> +
> +#[derive(Default)]
> +pub struct ParserConfig {
> +    /// Network interfaces must be of the form `netX`.
> +    pub guest_iface_names: bool,
> +    pub ipset_scope: Option<IpsetScope>,
> +}
> +
> +impl<O> Config<O>
> +where
> +    O: Default + std::fmt::Debug + serde::de::DeserializeOwned,
> +{
> +    pub fn new() -> Self {
> +        Self::default()
> +    }
> +
> +    pub fn parse<R: io::BufRead>(input: R, parser_cfg: &ParserConfig) -> Result<Self, Error> {
> +        let mut section = Sec::None;
> +
> +        let mut this = Self::new();
> +        let mut options = HashMap::new();
> +
> +        for line in input.lines() {
> +            let line = line?;
> +            let line = line.trim();
> +
> +            if line.is_empty() || line.starts_with('#') {
> +                continue;
> +            }
> +
> +            if line.eq_ignore_ascii_case("[OPTIONS]") {
> +                this.set_section(&mut section, Sec::Options)?;
> +            } else if line.eq_ignore_ascii_case("[ALIASES]") {
> +                this.set_section(&mut section, Sec::Aliases)?;
> +            } else if line.eq_ignore_ascii_case("[RULES]") {
> +                this.set_section(&mut section, Sec::Rules)?;
> +            } else if let Some(line) = line.strip_prefix("[IPSET") {
> +                let (name, comment) = parse_named_section_tail("ipset", line)?;
> +
> +                let scope = parser_cfg.ipset_scope.ok_or_else(|| {
> +                    format_err!("IPSET in config, but no scope set in parser config")
> +                })?;
> +
> +                let ipset_name = IpsetName::new(scope, name.to_string());
> +                let mut ipset = Ipset::new(ipset_name);
> +                ipset.comment = comment.map(str::to_owned);
> +
> +                this.set_section(&mut section, Sec::Ipset(name.to_string(), ipset))?;
> +            } else if let Some(line) = line.strip_prefix("[group") {
> +                let (name, comment) = parse_named_section_tail("group", line)?;
> +                let mut group = Group::new();
> +
> +                group.set_comment(comment.map(str::to_owned));
> +
> +                this.set_section(&mut section, Sec::Group(name.to_owned(), group))?;
> +            } else if line.starts_with('[') {
> +                bail!("invalid section {line:?}");
> +            } else {
> +                match &mut section {
> +                    Sec::None => bail!("config line with no section: {line:?}"),
> +                    Sec::Options => Self::parse_option(line, &mut options)?,
> +                    Sec::Aliases => this.parse_alias(line)?,
> +                    Sec::Rules => this.parse_rule(line, parser_cfg)?,
> +                    Sec::Ipset(_name, ipset) => ipset.parse_entry(line)?,
> +                    Sec::Group(_name, group) => group.parse_entry(line)?,
> +                }
> +            }
> +        }
> +        this.set_section(&mut section, Sec::None)?;
> +
> +        this.options = O::deserialize(IntoDeserializer::<
> +            '_,
> +            crate::firewall::parse::SerdeStringError,
> +        >::into_deserializer(options))?;
> +
> +        Ok(this)
> +    }
> +
> +    fn parse_option(line: &str, options: &mut HashMap<String, SomeString>) -> Result<(), Error> {
> +        let (key, value) = split_key_value(line)
> +            .ok_or_else(|| format_err!("expected colon separated key and value, found {line:?}"))?;
> +
> +        if options.insert(key.to_string(), value.into()).is_some() {
> +            bail!("duplicate option {key:?}");
> +        }
> +
> +        Ok(())
> +    }
> +
> +    fn parse_alias(&mut self, line: &str) -> Result<(), Error> {
> +        let alias: Alias = line.parse()?;
> +
> +        if self
> +            .aliases
> +            .insert(alias.name().to_string(), alias)
> +            .is_some()
> +        {
> +            bail!("duplicate alias: {line}");
> +        }
> +
> +        Ok(())
> +    }
> +
> +    fn parse_rule(&mut self, line: &str, parser_cfg: &ParserConfig) -> Result<(), Error> {
> +        let rule: Rule = line.parse()?;
> +
> +        if parser_cfg.guest_iface_names {
> +            if let Some(iface) = rule.iface() {
> +                let _ = iface
> +                    .strip_prefix("net")
> +                    .ok_or_else(|| {
> +                        format_err!("interface name must be of the form \"net<number>\"")
> +                    })?
> +                    .parse::<u16>()
> +                    .map_err(|_| {
> +                        format_err!("interface name must be of the form \"net<number>\"")
> +                    })?;
> +            }
> +        }
> +
> +        self.rules.push(rule);
> +        Ok(())
> +    }
> +
> +    fn set_section(&mut self, sec: &mut Sec, to: Sec) -> Result<(), Error> {
> +        let prev = std::mem::replace(sec, to);
> +
> +        match prev {
> +            Sec::Ipset(name, ipset) => {
> +                if self.ipsets.insert(name.clone(), ipset).is_some() {
> +                    bail!("duplicate ipset: {name:?}");
> +                }
> +            }
> +            Sec::Group(name, group) => {
> +                if self.groups.insert(name.clone(), group).is_some() {
> +                    bail!("duplicate group: {name:?}");
> +                }
> +            }
> +            _ => (),
> +        }
> +
> +        Ok(())
> +    }
> +
> +    pub fn ipsets(&self) -> &HashMap<String, Ipset> {
> +        &self.ipsets
> +    }
> +
> +    pub fn alias(&self, name: &str) -> Option<&Alias> {
> +        self.aliases.get(name)
> +    }
> +}
> diff --git a/proxmox-ve-config/src/firewall/mod.rs b/proxmox-ve-config/src/firewall/mod.rs
> index 2e0f31e..591ee52 100644
> --- a/proxmox-ve-config/src/firewall/mod.rs
> +++ b/proxmox-ve-config/src/firewall/mod.rs
> @@ -1,3 +1,4 @@
> +pub mod common;
>  pub mod ports;
>  pub mod types;
>  
> diff --git a/proxmox-ve-config/src/firewall/parse.rs b/proxmox-ve-config/src/firewall/parse.rs
> index 227e045..9cc2b8a 100644
> --- a/proxmox-ve-config/src/firewall/parse.rs
> +++ b/proxmox-ve-config/src/firewall/parse.rs
> @@ -61,6 +61,16 @@ pub fn match_digits(line: &str) -> Option<(&str, &str)> {
>  
>      None
>  }
> +
> +/// Separate a `key: value` line, trimming whitespace.
> +///
> +/// Returns `None` if the `key` would be empty.
> +pub fn split_key_value(line: &str) -> Option<(&str, &str)> {
> +    line.split_once(':')
> +        .map(|(key, value)| (key.trim(), value.trim()))
> +}
> +
> +/// Parse a boolean.
>  pub fn parse_bool(value: &str) -> Result<bool, Error> {
>      Ok(
>          if value == "0"
> @@ -81,6 +91,196 @@ pub fn parse_bool(value: &str) -> Result<bool, Error> {
>      )
>  }
>  
> +/// Parse the *remainder* of a section line, that is `<whitespace>NAME] #optional comment`.
> +/// The `kind` parameter is used for error messages and should be the section type.
> +///
> +/// Return the name and the optional comment.
> +pub fn parse_named_section_tail<'a>(
> +    kind: &'static str,
> +    line: &'a str,
> +) -> Result<(&'a str, Option<&'a str>), Error> {
> +    if line.is_empty() || !line.as_bytes()[0].is_ascii_whitespace() {
> +        bail!("incomplete {kind} section");
> +    }
> +
> +    let line = line.trim_start();
> +    let (name, line) = match_name(line)
> +        .ok_or_else(|| format_err!("expected a name for the {kind} at {line:?}"))?;
> +
> +    let line = line
> +        .strip_prefix(']')
> +        .ok_or_else(|| format_err!("expected closing ']' in {kind} section header"))?
> +        .trim_start();
> +
> +    Ok(match line.strip_prefix('#') {
> +        Some(comment) => (name, Some(comment.trim())),
> +        None if !line.is_empty() => bail!("trailing characters after {kind} section: {line:?}"),
> +        None => (name, None),
> +    })
> +}
> +
> +// parses a number from a string OR number
> +pub mod serde_option_number {

Since this is `pub`, I think a more complete docstring here would be
better instead of a comment. Though I haven't generated the docs for all
of this (yet) I have to admit, so I'm not sure if this actually shows
up.

> +    use std::fmt;
> +
> +    use serde::de::{Deserializer, Error, Visitor};
> +
> +    pub fn deserialize<'de, D: Deserializer<'de>>(
> +        deserializer: D,
> +    ) -> Result<Option<i64>, D::Error> {
> +        struct V;
> +
> +        impl<'de> Visitor<'de> for V {
> +            type Value = Option<i64>;
> +
> +            fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result {
> +                f.write_str("a numerical value")
> +            }
> +
> +            fn visit_str<E: Error>(self, v: &str) -> Result<Self::Value, E> {
> +                v.parse().map_err(E::custom).map(Some)
> +            }
> +
> +            fn visit_none<E: Error>(self) -> Result<Self::Value, E> {
> +                Ok(None)
> +            }
> +
> +            fn visit_some<D>(self, deserializer: D) -> Result<Self::Value, D::Error>
> +            where
> +                D: Deserializer<'de>,
> +            {
> +                deserializer.deserialize_any(self)
> +            }
> +        }
> +
> +        deserializer.deserialize_any(V)
> +    }
> +}
> +
> +// parses a bool from a string OR bool
> +pub mod serde_option_bool {

^ Same as above.

> +    use std::fmt;
> +
> +    use serde::de::{Deserializer, Error, Visitor};
> +
> +    pub fn deserialize<'de, D: Deserializer<'de>>(
> +        deserializer: D,
> +    ) -> Result<Option<bool>, D::Error> {
> +        struct V;
> +
> +        impl<'de> Visitor<'de> for V {
> +            type Value = Option<bool>;
> +
> +            fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result {
> +                f.write_str("a boolean-like value")
> +            }
> +
> +            fn visit_bool<E: Error>(self, v: bool) -> Result<Self::Value, E> {
> +                Ok(Some(v))
> +            }
> +
> +            fn visit_str<E: Error>(self, v: &str) -> Result<Self::Value, E> {
> +                super::parse_bool(v).map_err(E::custom).map(Some)
> +            }
> +
> +            fn visit_none<E: Error>(self) -> Result<Self::Value, E> {
> +                Ok(None)
> +            }
> +
> +            fn visit_some<D>(self, deserializer: D) -> Result<Self::Value, D::Error>
> +            where
> +                D: Deserializer<'de>,
> +            {
> +                deserializer.deserialize_any(self)
> +            }
> +        }
> +
> +        deserializer.deserialize_any(V)
> +    }
> +}
> +
> +// parses a comma_separated list of strings
> +pub mod serde_option_conntrack_helpers {

^ Same as above here as well.

> +    use std::fmt;
> +
> +    use serde::de::{Deserializer, Error, Visitor};
> +
> +    pub fn deserialize<'de, D: Deserializer<'de>>(
> +        deserializer: D,
> +    ) -> Result<Option<Vec<String>>, D::Error> {
> +        struct V;
> +
> +        impl<'de> Visitor<'de> for V {
> +            type Value = Option<Vec<String>>;
> +
> +            fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result {
> +                f.write_str("A list of conntrack helpers")
> +            }
> +
> +            fn visit_str<E: Error>(self, v: &str) -> Result<Self::Value, E> {
> +                if v.is_empty() {
> +                    return Ok(None);
> +                }
> +
> +                Ok(Some(v.split(',').map(String::from).collect()))
> +            }
> +
> +            fn visit_none<E: Error>(self) -> Result<Self::Value, E> {
> +                Ok(None)
> +            }
> +
> +            fn visit_some<D>(self, deserializer: D) -> Result<Self::Value, D::Error>
> +            where
> +                D: Deserializer<'de>,
> +            {
> +                deserializer.deserialize_any(self)
> +            }
> +        }
> +
> +        deserializer.deserialize_any(V)
> +    }
> +}
> +
> +// parses a log_ratelimit string: '[enable=]<1|0> [,burst=<integer>] [,rate=<rate>]'
> +pub mod serde_option_log_ratelimit {

^ And here.

> +    use std::fmt;
> +
> +    use serde::de::{Deserializer, Error, Visitor};
> +
> +    use crate::firewall::types::log::LogRateLimit;
> +
> +    pub fn deserialize<'de, D: Deserializer<'de>>(
> +        deserializer: D,
> +    ) -> Result<Option<LogRateLimit>, D::Error> {
> +        struct V;
> +
> +        impl<'de> Visitor<'de> for V {
> +            type Value = Option<LogRateLimit>;
> +
> +            fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result {
> +                f.write_str("a boolean-like value")
> +            }
> +
> +            fn visit_str<E: Error>(self, v: &str) -> Result<Self::Value, E> {
> +                v.parse().map_err(E::custom).map(Some)
> +            }
> +
> +            fn visit_none<E: Error>(self) -> Result<Self::Value, E> {
> +                Ok(None)
> +            }
> +
> +            fn visit_some<D>(self, deserializer: D) -> Result<Self::Value, D::Error>
> +            where
> +                D: Deserializer<'de>,
> +            {
> +                deserializer.deserialize_any(self)
> +            }
> +        }
> +
> +        deserializer.deserialize_any(V)
> +    }
> +}
> +
>  /// `&str` deserializer which also accepts an `Option`.
>  ///
>  /// Serde's `StringDeserializer` does not.





^ permalink raw reply	[flat|nested] 67+ messages in thread

* Re: [pve-devel] [PATCH proxmox-firewall 13/37] config: firewall: add host specific config + option types
  2024-04-02 17:16 ` [pve-devel] [PATCH proxmox-firewall 13/37] config: firewall: add host specific " Stefan Hanreich
@ 2024-04-03 10:47   ` Max Carrara
  2024-04-09  8:55     ` Stefan Hanreich
  0 siblings, 1 reply; 67+ messages in thread
From: Max Carrara @ 2024-04-03 10:47 UTC (permalink / raw)
  To: Proxmox VE development discussion; +Cc: Wolfgang Bumiller

On Tue Apr 2, 2024 at 7:16 PM CEST, Stefan Hanreich wrote:
> Co-authored-by: Wolfgang Bumiller <w.bumiller@proxmox.com>
> Signed-off-by: Stefan Hanreich <s.hanreich@proxmox.com>
> ---
>  proxmox-ve-config/src/firewall/host.rs | 309 +++++++++++++++++++++++++
>  proxmox-ve-config/src/firewall/mod.rs  |   1 +
>  2 files changed, 310 insertions(+)
>  create mode 100644 proxmox-ve-config/src/firewall/host.rs
>
> diff --git a/proxmox-ve-config/src/firewall/host.rs b/proxmox-ve-config/src/firewall/host.rs
> new file mode 100644
> index 0000000..3e47bfa
> --- /dev/null
> +++ b/proxmox-ve-config/src/firewall/host.rs
> @@ -0,0 +1,309 @@
> +use std::io;
> +use std::net::IpAddr;
> +
> +use anyhow::{bail, Error};
> +use serde::Deserialize;
> +
> +use crate::host::utils::{host_ips, hostname, network_interface_cidrs};
> +
> +use crate::firewall::parse;
> +use crate::firewall::types::log::LogLevel;
> +use crate::firewall::types::rule::Direction;
> +use crate::firewall::types::{Alias, Cidr, Rule};
> +
> +#[derive(Debug, Default, Deserialize)]
> +#[cfg_attr(test, derive(Eq, PartialEq))]
> +pub struct Options {
> +    #[serde(default, with = "parse::serde_option_bool")]
> +    enable: Option<bool>,
> +
> +    #[serde(default, with = "parse::serde_option_bool")]
> +    nftables: Option<bool>,
> +
> +    log_level_in: Option<LogLevel>,
> +    log_level_out: Option<LogLevel>,
> +
> +    #[serde(default, with = "parse::serde_option_bool")]
> +    log_nf_conntrack: Option<bool>,
> +    #[serde(default, with = "parse::serde_option_bool")]
> +    ndp: Option<bool>,
> +
> +    #[serde(default, with = "parse::serde_option_bool")]
> +    nf_conntrack_allow_invalid: Option<bool>,
> +
> +    #[serde(default, with = "parse::serde_option_conntrack_helpers")]
> +    nf_conntrack_helpers: Option<Vec<String>>,
> +
> +    #[serde(default, with = "parse::serde_option_number")]
> +    nf_conntrack_max: Option<i64>,
> +    #[serde(default, with = "parse::serde_option_number")]
> +    nf_conntrack_tcp_timeout_established: Option<i64>,
> +    #[serde(default, with = "parse::serde_option_number")]
> +    nf_conntrack_tcp_timeout_syn_recv: Option<i64>,
> +
> +    #[serde(default, with = "parse::serde_option_bool")]
> +    nosmurfs: Option<bool>,
> +
> +    #[serde(default, with = "parse::serde_option_bool")]
> +    protection_synflood: Option<bool>,
> +    #[serde(default, with = "parse::serde_option_number")]
> +    protection_synflood_burst: Option<i64>,
> +    #[serde(default, with = "parse::serde_option_number")]
> +    protection_synflood_rate: Option<i64>,
> +
> +    smurf_log_level: Option<LogLevel>,
> +    tcp_flags_log_level: Option<LogLevel>,
> +
> +    #[serde(default, with = "parse::serde_option_bool")]
> +    tcpflags: Option<bool>,
> +}
> +
> +#[derive(Debug, Default)]
> +pub struct Config {
> +    pub(crate) config: super::common::Config<Options>,
> +}
> +
> +impl Config {
> +    pub fn new() -> Self {
> +        Self {
> +            config: Default::default(),
> +        }
> +    }
> +
> +    pub fn parse<R: io::BufRead>(input: R) -> Result<Self, Error> {
> +        let config = super::common::Config::parse(input, &Default::default())?;
> +
> +        if !config.groups.is_empty() {
> +            bail!("host firewall config cannot declare groups");
> +        }
> +
> +        if !config.aliases.is_empty() {
> +            bail!("host firewall config cannot declare aliases");
> +        }
> +
> +        if !config.ipsets.is_empty() {
> +            bail!("host firewall config cannot declare ipsets");
> +        }
> +
> +        Ok(Self { config })
> +    }
> +
> +    pub fn rules(&self) -> &[Rule] {
> +        &self.config.rules
> +    }
> +
> +    pub fn management_ips() -> Result<Vec<Cidr>, Error> {
> +        let mut management_cidrs = Vec::new();
> +
> +        for host_ip in host_ips() {
> +            for network_interface_cidr in network_interface_cidrs() {
> +                match (host_ip, network_interface_cidr) {
> +                    (IpAddr::V4(ip), Cidr::Ipv4(cidr)) => {
> +                        if cidr.contains_address(ip) {
> +                            management_cidrs.push(network_interface_cidr.clone());
> +                        }
> +                    }
> +                    (IpAddr::V6(ip), Cidr::Ipv6(cidr)) => {
> +                        if cidr.contains_address(ip) {
> +                            management_cidrs.push(network_interface_cidr.clone());
> +                        }
> +                    }
> +                    _ => continue,
> +                };
> +            }
> +        }
> +
> +        Ok(management_cidrs)
> +    }
> +
> +    pub fn hostname() -> &'static str {
> +        hostname()
> +    }
> +
> +    pub fn get_alias(&self, name: &str) -> Option<&Alias> {
> +        self.config.alias(name)
> +    }
> +
> +    pub fn is_enabled(&self) -> bool {
> +        self.config.options.enable.unwrap_or(true)
> +    }
> +
> +    pub fn nftables(&self) -> bool {
> +        self.config.options.nftables.unwrap_or(false)
> +    }
> +
> +    pub fn allow_ndp(&self) -> bool {
> +        self.config.options.ndp.unwrap_or(true)
> +    }
> +
> +    pub fn block_smurfs(&self) -> bool {
> +        self.config.options.nosmurfs.unwrap_or(true)
> +    }
> +
> +    pub fn block_smurfs_log_level(&self) -> LogLevel {
> +        self.config.options.smurf_log_level.unwrap_or_default()
> +    }
> +
> +    pub fn block_synflood(&self) -> bool {
> +        self.config.options.protection_synflood.unwrap_or(false)
> +    }
> +
> +    pub fn synflood_rate(&self) -> i64 {
> +        self.config.options.protection_synflood_rate.unwrap_or(200)
> +    }

Should maybe document such defaults in the docstring of the `pub`
function above?

> +
> +    pub fn synflood_burst(&self) -> i64 {
> +        self.config
> +            .options
> +            .protection_synflood_burst
> +            .unwrap_or(1000)
> +    }

Same here.

Also, numeric defaults like those could maaaaaybe be declared as a
`const` upfront (and documented). Technically, doing this for the
boolean defaults here in this patch wouldn't hurt either - I realize
that it's clear from the context of the code what's meant, but in this
case it would be solely for documentation purposes.

E.g. if the question "Does the firewall enable NDP by default?" arises,
one could just check the (docstrings of the) constants declared at the
top of the file, or even better, browse the docs generated by cargo if
they're not a developer.

This might seem a little pedantic, but e.g. altering or removing default
values can lead to a new major version in semver, so IMO it's best if
they're defined more explicitly somewhere.

> +
> +    pub fn block_invalid_tcp(&self) -> bool {
> +        self.config.options.tcpflags.unwrap_or(false)
> +    }
> +
> +    pub fn block_invalid_tcp_log_level(&self) -> LogLevel {
> +        self.config.options.tcp_flags_log_level.unwrap_or_default()
> +    }
> +
> +    pub fn block_invalid_conntrack(&self) -> bool {
> +        !self
> +            .config
> +            .options
> +            .nf_conntrack_allow_invalid
> +            .unwrap_or(false)
> +    }
> +
> +    pub fn nf_conntrack_max(&self) -> Option<i64> {
> +        self.config.options.nf_conntrack_max
> +    }
> +
> +    pub fn nf_conntrack_tcp_timeout_established(&self) -> Option<i64> {
> +        self.config.options.nf_conntrack_tcp_timeout_established
> +    }
> +
> +    pub fn nf_conntrack_tcp_timeout_syn_recv(&self) -> Option<i64> {
> +        self.config.options.nf_conntrack_tcp_timeout_syn_recv
> +    }
> +
> +    pub fn log_nf_conntrack(&self) -> bool {
> +        self.config.options.log_nf_conntrack.unwrap_or(false)
> +    }
> +
> +    pub fn conntrack_helpers(&self) -> Option<&Vec<String>> {
> +        self.config.options.nf_conntrack_helpers.as_ref()
> +    }
> +
> +    pub fn log_level(&self, dir: Direction) -> LogLevel {
> +        match dir {
> +            Direction::In => self.config.options.log_level_in.unwrap_or_default(),
> +            Direction::Out => self.config.options.log_level_out.unwrap_or_default(),
> +        }
> +    }
> +}
> +
> +#[cfg(test)]
> +mod tests {
> +    use crate::firewall::types::{
> +        log::LogLevel,
> +        rule::{Kind, RuleGroup, Verdict},
> +        rule_match::{Ports, Protocol, RuleMatch, Udp},
> +    };
> +
> +    use super::*;
> +
> +    #[test]
> +    fn test_parse_config() {
> +        const CONFIG: &str = r#"
> +[OPTIONS]
> +enable: 1
> +nftables: 1
> +log_level_in: debug
> +log_level_out: emerg
> +log_nf_conntrack: 0
> +ndp: 1
> +nf_conntrack_allow_invalid: yes
> +nf_conntrack_helpers: ftp
> +nf_conntrack_max: 44000
> +nf_conntrack_tcp_timeout_established: 500000
> +nf_conntrack_tcp_timeout_syn_recv: 44
> +nosmurfs: no
> +protection_synflood: 1
> +protection_synflood_burst: 2500
> +protection_synflood_rate: 300
> +smurf_log_level: notice
> +tcp_flags_log_level: nolog
> +tcpflags: yes
> +
> +[RULES]
> +
> +GROUP tgr -i eth0 # acomm
> +IN ACCEPT -p udp -dport 33 -sport 22 -log warning
> +
> +"#;
> +
> +        let mut config = CONFIG.as_bytes();
> +        let config = Config::parse(&mut config).unwrap();
> +
> +        assert_eq!(
> +            config.config.options,
> +            Options {
> +                enable: Some(true),
> +                nftables: Some(true),
> +                log_level_in: Some(LogLevel::Debug),
> +                log_level_out: Some(LogLevel::Emergency),
> +                log_nf_conntrack: Some(false),
> +                ndp: Some(true),
> +                nf_conntrack_allow_invalid: Some(true),
> +                nf_conntrack_helpers: Some(vec!["ftp".to_string()]),
> +                nf_conntrack_max: Some(44000),
> +                nf_conntrack_tcp_timeout_established: Some(500000),
> +                nf_conntrack_tcp_timeout_syn_recv: Some(44),
> +                nosmurfs: Some(false),
> +                protection_synflood: Some(true),
> +                protection_synflood_burst: Some(2500),
> +                protection_synflood_rate: Some(300),
> +                smurf_log_level: Some(LogLevel::Notice),
> +                tcp_flags_log_level: Some(LogLevel::Nolog),
> +                tcpflags: Some(true),
> +            }
> +        );
> +
> +        assert_eq!(config.config.rules.len(), 2);
> +
> +        assert_eq!(
> +            config.config.rules[0],
> +            Rule {
> +                disabled: false,
> +                comment: Some("acomm".to_string()),
> +                kind: Kind::Group(RuleGroup {
> +                    group: "tgr".to_string(),
> +                    iface: Some("eth0".to_string()),
> +                }),
> +            },
> +        );
> +
> +        assert_eq!(
> +            config.config.rules[1],
> +            Rule {
> +                disabled: false,
> +                comment: None,
> +                kind: Kind::Match(RuleMatch {
> +                    dir: Direction::In,
> +                    verdict: Verdict::Accept,
> +                    proto: Some(Protocol::Udp(Udp::new(Ports::from_u16(22, 33)))),
> +                    log: Some(LogLevel::Warning),
> +                    ..Default::default()
> +                }),
> +            },
> +        );
> +
> +        Config::parse("[ALIASES]\ntest 127.0.0.1".as_bytes())
> +            .expect_err("host config cannot contain aliases");
> +
> +        Config::parse("[GROUP test]".as_bytes()).expect_err("host config cannot contain groups");
> +
> +        Config::parse("[IPSET test]".as_bytes()).expect_err("host config cannot contain ipsets");
> +    }
> +}
> diff --git a/proxmox-ve-config/src/firewall/mod.rs b/proxmox-ve-config/src/firewall/mod.rs
> index 82689c3..85fe6c4 100644
> --- a/proxmox-ve-config/src/firewall/mod.rs
> +++ b/proxmox-ve-config/src/firewall/mod.rs
> @@ -1,5 +1,6 @@
>  pub mod cluster;
>  pub mod common;
> +pub mod host;
>  pub mod ports;
>  pub mod types;
>  





^ permalink raw reply	[flat|nested] 67+ messages in thread

* Re: [pve-devel] [PATCH proxmox-firewall 21/37] nftables: statement: add types
  2024-04-02 17:16 ` [pve-devel] [PATCH proxmox-firewall 21/37] nftables: statement: add types Stefan Hanreich
@ 2024-04-03 10:47   ` Max Carrara
  2024-04-09  8:58     ` Stefan Hanreich
  0 siblings, 1 reply; 67+ messages in thread
From: Max Carrara @ 2024-04-03 10:47 UTC (permalink / raw)
  To: Proxmox VE development discussion; +Cc: Wolfgang Bumiller

On Tue Apr 2, 2024 at 7:16 PM CEST, Stefan Hanreich wrote:
> Adds an enum containing most of the statements defined in the
> nftables-json schema [1].
>
> [1] https://manpages.debian.org/bookworm/libnftables1/libnftables-json.5.en.html#STATEMENTS
>
> Co-authored-by: Wolfgang Bumiller <w.bumiller@proxmox.com>
> Signed-off-by: Stefan Hanreich <s.hanreich@proxmox.com>
> ---
>  proxmox-nftables/Cargo.toml       |   1 +
>  proxmox-nftables/src/lib.rs       |   2 +
>  proxmox-nftables/src/statement.rs | 321 ++++++++++++++++++++++++++++++
>  proxmox-nftables/src/types.rs     |  17 ++
>  4 files changed, 341 insertions(+)
>  create mode 100644 proxmox-nftables/src/statement.rs
>
> diff --git a/proxmox-nftables/Cargo.toml b/proxmox-nftables/Cargo.toml
> index 7e607e8..153716d 100644
> --- a/proxmox-nftables/Cargo.toml
> +++ b/proxmox-nftables/Cargo.toml
> @@ -15,6 +15,7 @@ config-ext = ["dep:proxmox-ve-config"]
>  
>  [dependencies]
>  log = "0.4"
> +anyhow = "1"
>  
>  serde = { version = "1", features = [ "derive" ] }
>  serde_json = "1"
> diff --git a/proxmox-nftables/src/lib.rs b/proxmox-nftables/src/lib.rs
> index 712858b..40f6bab 100644
> --- a/proxmox-nftables/src/lib.rs
> +++ b/proxmox-nftables/src/lib.rs
> @@ -1,5 +1,7 @@
>  pub mod expression;
>  pub mod helper;
> +pub mod statement;
>  pub mod types;
>  
>  pub use expression::Expression;
> +pub use statement::Statement;
> diff --git a/proxmox-nftables/src/statement.rs b/proxmox-nftables/src/statement.rs
> new file mode 100644
> index 0000000..e569f33
> --- /dev/null
> +++ b/proxmox-nftables/src/statement.rs
> @@ -0,0 +1,321 @@
> +use anyhow::{bail, Error};

Hmm, you don't use either here - you sure you didn't mean to introduce
`anyhow` later?

> +use serde::{Deserialize, Serialize};
> +
> +use crate::expression::Meta;
> +use crate::helper::{NfVec, Null};
> +use crate::types::{RateTimescale, RateUnit, Verdict};
> +use crate::Expression;
> +
> +#[derive(Clone, Debug, Deserialize, Serialize)]
> +#[serde(rename_all = "lowercase")]
> +pub enum Statement {
> +    Match(Match),
> +    Mangle(Mangle),
> +    Limit(Limit),
> +    Notrack(Null),
> +    Reject(Reject),
> +    Set(Set),
> +    Log(Log),
> +    #[serde(rename = "ct helper")]
> +    CtHelper(String),
> +    Vmap(Vmap),
> +    Comment(String),
> +
> +    #[serde(untagged)]
> +    Verdict(Verdict),
> +}
> +
> +impl Statement {
> +    pub const fn make_accept() -> Self {
> +        Statement::Verdict(Verdict::Accept(Null))
> +    }
> +
> +    pub const fn make_drop() -> Self {
> +        Statement::Verdict(Verdict::Drop(Null))
> +    }
> +
> +    pub const fn make_return() -> Self {
> +        Statement::Verdict(Verdict::Return(Null))
> +    }
> +
> +    pub const fn make_continue() -> Self {
> +        Statement::Verdict(Verdict::Continue(Null))
> +    }
> +
> +    pub fn jump(target: impl Into<String>) -> Self {
> +        Statement::Verdict(Verdict::Jump {
> +            target: target.into(),
> +        })
> +    }
> +
> +    pub fn goto(target: impl Into<String>) -> Self {
> +        Statement::Verdict(Verdict::Goto {
> +            target: target.into(),
> +        })
> +    }
> +}
> +
> +impl From<Match> for Statement {
> +    #[inline]
> +    fn from(m: Match) -> Statement {
> +        Statement::Match(m)
> +    }
> +}
> +
> +impl From<Mangle> for Statement {
> +    #[inline]
> +    fn from(m: Mangle) -> Statement {
> +        Statement::Mangle(m)
> +    }
> +}
> +
> +impl From<Reject> for Statement {
> +    #[inline]
> +    fn from(m: Reject) -> Statement {
> +        Statement::Reject(m)
> +    }
> +}
> +
> +impl From<Set> for Statement {
> +    #[inline]
> +    fn from(m: Set) -> Statement {
> +        Statement::Set(m)
> +    }
> +}
> +
> +impl From<Vmap> for Statement {
> +    #[inline]
> +    fn from(m: Vmap) -> Statement {
> +        Statement::Vmap(m)
> +    }
> +}
> +
> +impl From<Log> for Statement {
> +    #[inline]
> +    fn from(log: Log) -> Statement {
> +        Statement::Log(log)
> +    }
> +}
> +
> +impl<T: Into<Limit>> From<T> for Statement {
> +    #[inline]
> +    fn from(limit: T) -> Statement {
> +        Statement::Limit(limit.into())
> +    }
> +}
> +
> +#[derive(Clone, Debug, Deserialize, Serialize)]
> +#[serde(rename_all = "lowercase")]
> +pub enum RejectType {
> +    #[serde(rename = "tcp reset")]
> +    TcpRst,
> +    IcmpX,
> +    Icmp,
> +    IcmpV6,
> +}
> +
> +#[derive(Clone, Debug, Default, Deserialize, Serialize)]
> +pub struct Reject {
> +    #[serde(rename = "type", skip_serializing_if = "Option::is_none")]
> +    ty: Option<RejectType>,
> +    #[serde(skip_serializing_if = "Option::is_none")]
> +    expr: Option<Expression>,
> +}
> +
> +#[derive(Clone, Debug, Default, Deserialize, Serialize)]
> +#[serde(rename_all = "kebab-case")]
> +pub struct Log {
> +    #[serde(skip_serializing_if = "Option::is_none")]
> +    prefix: Option<String>,
> +
> +    #[serde(skip_serializing_if = "Option::is_none")]
> +    group: Option<i64>,
> +
> +    #[serde(skip_serializing_if = "Option::is_none")]
> +    snaplen: Option<i64>,
> +
> +    #[serde(skip_serializing_if = "Option::is_none")]
> +    queue_threshold: Option<i64>,
> +
> +    #[serde(skip_serializing_if = "Option::is_none")]
> +    level: Option<LogLevel>,
> +
> +    #[serde(default, skip_serializing_if = "Vec::is_empty")]
> +    flags: NfVec<LogFlag>,
> +}
> +
> +impl Log {
> +    pub fn new_nflog(prefix: String, group: i64) -> Self {
> +        Self {
> +            prefix: Some(prefix),
> +            group: Some(group),
> +            ..Default::default()
> +        }
> +    }
> +}
> +
> +#[derive(Clone, Debug, Deserialize, Serialize)]
> +#[serde(rename_all = "lowercase")]
> +pub enum LogLevel {
> +    Emerg,
> +    Alert,
> +    Crit,
> +    Err,
> +    Warn,
> +    Notice,
> +    Info,
> +    Debug,
> +    Audit,
> +}
> +
> +impl LogLevel {
> +    pub fn nflog_level(&self) -> u8 {
> +        match self {
> +            LogLevel::Emerg => 0,
> +            LogLevel::Alert => 1,
> +            LogLevel::Crit => 2,
> +            LogLevel::Err => 3,
> +            LogLevel::Warn => 4,
> +            LogLevel::Notice => 5,
> +            LogLevel::Info => 6,
> +            LogLevel::Debug => 7,
> +            LogLevel::Audit => 7,
> +        }
> +    }
> +}
> +
> +#[derive(Clone, Debug, Deserialize, Serialize)]
> +#[serde(rename_all = "lowercase")]
> +pub enum LogFlag {
> +    #[serde(rename = "tcp sequence")]
> +    TcpSequence,
> +    #[serde(rename = "tcp options")]
> +    TcpOptions,
> +    #[serde(rename = "ip options")]
> +    IpOptions,
> +
> +    Skuid,
> +    Ether,
> +    All,
> +}
> +
> +#[derive(Clone, Debug, Deserialize, Serialize)]
> +#[serde(untagged)]
> +pub enum Limit {
> +    Named(String),
> +    Anonymous(AnonymousLimit),
> +}
> +
> +impl<T: Into<AnonymousLimit>> From<T> for Limit {
> +    fn from(value: T) -> Self {
> +        Limit::Anonymous(value.into())
> +    }
> +}
> +
> +#[derive(Clone, Debug, Deserialize, Serialize, Default)]
> +pub struct AnonymousLimit {
> +    pub rate: i64,
> +
> +    #[serde(skip_serializing_if = "Option::is_none")]
> +    pub rate_unit: Option<RateUnit>,
> +
> +    pub per: RateTimescale,
> +
> +    #[serde(skip_serializing_if = "Option::is_none")]
> +    pub burst: Option<i64>,
> +
> +    #[serde(skip_serializing_if = "Option::is_none")]
> +    pub burst_unit: Option<RateUnit>,
> +
> +    #[serde(skip_serializing_if = "Option::is_none")]
> +    pub inv: Option<bool>,
> +}
> +
> +#[derive(Clone, Debug, Deserialize, Serialize)]
> +pub struct Vmap {
> +    key: Expression,
> +    data: Expression,
> +}
> +
> +#[derive(Clone, Debug, Deserialize, Serialize)]
> +pub struct Match {
> +    op: Operator,
> +    left: Expression,
> +    right: Expression,
> +}
> +
> +impl Match {
> +    pub fn new(op: Operator, left: impl Into<Expression>, right: impl Into<Expression>) -> Self {
> +        Self {
> +            op,
> +            left: left.into(),
> +            right: right.into(),
> +        }
> +    }
> +
> +    pub fn new_eq(left: impl Into<Expression>, right: impl Into<Expression>) -> Self {
> +        Self::new(Operator::Eq, left, right)
> +    }
> +
> +    pub fn new_ne(left: impl Into<Expression>, right: impl Into<Expression>) -> Self {
> +        Self::new(Operator::Ne, left, right)
> +    }
> +}
> +
> +#[derive(Clone, Debug, Deserialize, Serialize)]
> +pub enum Operator {
> +    #[serde(rename = "&")]
> +    And,
> +    #[serde(rename = "|")]
> +    Or,
> +    #[serde(rename = "^")]
> +    Xor,
> +    #[serde(rename = "<<")]
> +    ShiftLeft,
> +    #[serde(rename = ">>")]
> +    ShiftRight,
> +    #[serde(rename = "==")]
> +    Eq,
> +    #[serde(rename = "!=")]
> +    Ne,
> +    #[serde(rename = "<")]
> +    Lt,
> +    #[serde(rename = ">")]
> +    Gt,
> +    #[serde(rename = "<=")]
> +    Le,
> +    #[serde(rename = ">=")]
> +    Ge,
> +    #[serde(rename = "in")]
> +    In,
> +}
> +
> +#[derive(Clone, Debug, Deserialize, Serialize)]
> +pub struct Mangle {
> +    pub key: Expression,
> +    pub value: Expression,
> +}
> +
> +impl Mangle {
> +    pub fn set_mark(value: impl Into<Expression>) -> Self {
> +        Self {
> +            key: Meta::new("mark").into(),
> +            value: value.into(),
> +        }
> +    }
> +}
> +
> +#[derive(Clone, Debug, Deserialize, Serialize)]
> +#[serde(rename_all = "lowercase")]
> +pub enum SetOperation {
> +    Add,
> +    Update,
> +}
> +
> +#[derive(Clone, Debug, Deserialize, Serialize)]
> +pub struct Set {
> +    pub op: SetOperation,
> +    pub elem: Expression,
> +    pub set: String,
> +    pub stmt: Option<NfVec<Statement>>,
> +}
> diff --git a/proxmox-nftables/src/types.rs b/proxmox-nftables/src/types.rs
> index 942c866..b99747b 100644
> --- a/proxmox-nftables/src/types.rs
> +++ b/proxmox-nftables/src/types.rs
> @@ -30,6 +30,23 @@ impl Display for Verdict {
>      }
>  }
>  
> +#[derive(Clone, Debug, Deserialize, Serialize)]
> +pub enum RateUnit {
> +    Packets,
> +    Bytes,
> +}
> +
> +#[derive(Clone, Debug, Deserialize, Serialize, Default)]
> +#[cfg_attr(test, derive(Eq, PartialEq))]
> +#[serde(rename_all = "lowercase")]
> +pub enum RateTimescale {
> +    #[default]
> +    Second,
> +    Minute,
> +    Hour,
> +    Day,
> +}
> +
>  #[derive(Clone, Debug, Deserialize, Serialize)]
>  pub struct ElemConfig {
>      timeout: Option<i64>,





^ permalink raw reply	[flat|nested] 67+ messages in thread

* Re: [pve-devel] [RFC container/firewall/manager/proxmox-firewall/qemu-server 00/37] proxmox firewall nftables implementation
       [not found]     ` <mailman.77.1712145853.450.pve-devel@lists.proxmox.com>
@ 2024-04-03 12:25       ` Stefan Hanreich
       [not found]         ` <mailman.78.1712149473.450.pve-devel@lists.proxmox.com>
  0 siblings, 1 reply; 67+ messages in thread
From: Stefan Hanreich @ 2024-04-03 12:25 UTC (permalink / raw)
  To: pve-devel

On 4/3/24 14:03, DERUMIER, Alexandre via pve-devel wrote:
> maybe revert the kernel patch ? ^_^
> https://git.kernel.org/pub/scm/linux/kernel/git/stable/linux.git/commit/net/bridge/netfilter/nft_reject_bridge.c?h=v6.8.2&id=127917c29a432c3b798e014a1714e9c1af0f87fe

I also thought about it shortly. If we can ensure that certain
conditions are met that might be an option. We would have to think about
broadcast/multicast traffic like ARP / DHCP I would assume. It seems a
bit drastic from my POV, which is why dropped that thought.

> Or Improve it for upstream, something like:
> 
> if !bridge_unicast_flooding && !bridge_mac_learning && proto = tcp|udp
>     allow_use_of_reject

that might be a possibility, although I'm not sure that information
about the bridge is available in the netfilter modules.




^ permalink raw reply	[flat|nested] 67+ messages in thread

* Re: [pve-devel] [RFC container/firewall/manager/proxmox-firewall/qemu-server 00/37] proxmox firewall nftables implementation
       [not found] ` <mailman.54.1712122640.450.pve-devel@lists.proxmox.com>
  2024-04-03  7:52   ` Stefan Hanreich
@ 2024-04-03 12:26   ` Stefan Hanreich
  1 sibling, 0 replies; 67+ messages in thread
From: Stefan Hanreich @ 2024-04-03 12:26 UTC (permalink / raw)
  To: pve-devel

also as a short fyi, since I forgot to mention it in my cover letter:

I've refrained from adding stuff like flowtables and broute for now -
but it is certainly something I want to add in future revisions. For the
initial POC I wanted to stay as basic as possible and create a 1:1
replacement without any fancy bells and whistles.




^ permalink raw reply	[flat|nested] 67+ messages in thread

* Re: [pve-devel] [RFC container/firewall/manager/proxmox-firewall/qemu-server 00/37] proxmox firewall nftables implementation
       [not found]         ` <mailman.78.1712149473.450.pve-devel@lists.proxmox.com>
@ 2024-04-03 13:08           ` Stefan Hanreich
  0 siblings, 0 replies; 67+ messages in thread
From: Stefan Hanreich @ 2024-04-03 13:08 UTC (permalink / raw)
  To: pve-devel



On 4/3/24 15:04, DERUMIER, Alexandre via pve-devel wrote:
> I think you can just use DROP for this kind of traffic, as anyway, you
> don't expect to receive a response like tcp-reset or icmp port
> unreachable.

Yes, of course, replied too quickly without thinking twice...




^ permalink raw reply	[flat|nested] 67+ messages in thread

* Re: [pve-devel] [PATCH proxmox-firewall 33/37] firewall: add files for debian packaging
  2024-04-02 17:16 ` [pve-devel] [PATCH proxmox-firewall 33/37] firewall: add files for debian packaging Stefan Hanreich
@ 2024-04-03 13:14   ` Fabian Grünbichler
  2024-04-09  8:56     ` Stefan Hanreich
  0 siblings, 1 reply; 67+ messages in thread
From: Fabian Grünbichler @ 2024-04-03 13:14 UTC (permalink / raw)
  To: Proxmox VE development discussion

just looked at the packaging, mostly related to clean building, but not
only.

On April 2, 2024 7:16 pm, Stefan Hanreich wrote:
> Signed-off-by: Stefan Hanreich <s.hanreich@proxmox.com>
> ---
>  Makefile                        | 93 +++++++++++++++++++++++++++++++++
>  debian/changelog                |  5 ++
>  debian/control                  | 31 +++++++++++
>  debian/copyright                | 16 ++++++
>  debian/proxmox-firewall.service | 16 ++++++
>  debian/proxmox-firewall.timer   | 11 ++++
>  debian/rules                    | 14 +++++
>  debian/source/format            |  1 +
>  defines.mk                      | 13 +++++
>  9 files changed, 200 insertions(+)
>  create mode 100644 Makefile
>  create mode 100644 debian/changelog
>  create mode 100644 debian/control
>  create mode 100644 debian/copyright
>  create mode 100644 debian/proxmox-firewall.service
>  create mode 100644 debian/proxmox-firewall.timer
>  create mode 100644 debian/rules
>  create mode 100644 debian/source/format
>  create mode 100644 defines.mk
> 
> diff --git a/Makefile b/Makefile
> new file mode 100644
> index 0000000..984c318
> --- /dev/null
> +++ b/Makefile
> @@ -0,0 +1,93 @@
> +include /usr/share/dpkg/pkg-info.mk
> +include /usr/share/dpkg/architecture.mk
> +include defines.mk
> +
> +PACKAGE=proxmox-firewall
> +BUILDDIR ?= $(PACKAGE)-$(DEB_VERSION_UPSTREAM)
> +
> +
> +DEB=$(PACKAGE)_$(DEB_VERSION_UPSTREAM_REVISION)_$(DEB_HOST_ARCH).deb
> +DBG_DEB=$(PACKAGE)-dbgsym_$(DEB_VERSION_UPSTREAM_REVISION)_$(DEB_HOST_ARCH).deb
> +DSC=rust-$(PACKAGE)_$(DEB_VERSION_UPSTREAM_REVISION).dsc

this doesn't match d/control ;)

> +
> +DEBS = $(DEB) $(DBG_DEB)
> +
> +ifeq ($(BUILD_MODE), release)

you need to set/export this in d/rules, else the package will contain a
debug build..

> +CARGO_BUILD_ARGS += --release
> +COMPILEDIR := target/release
> +else
> +COMPILEDIR := target/debug
> +endif
> +
> +USR_BIN := \
> +	proxmox-firewall
> +
> +COMPILED_BINS := \
> +	$(addprefix $(COMPILEDIR)/,$(USR_BIN))
> +
> +all: cargo-build
> +
> +.PHONY: cargo-build
> +cargo-build:
> +	cargo build $(CARGO_BUILD_ARGS)
> +
> +$(COMPILED_BINS): cargo-build
> +
> +install: $(COMPILED_BINS)
> +	install -dm755 $(DESTDIR)$(SBINDIR)
> +	$(foreach i,$(USR_BIN), \
> +	    install -m755 $(COMPILEDIR)/$(i) $(DESTDIR)$(SBINDIR)/ ;)

I am not sure this (and all the helper stuff above that only exists for
it) make much sense. maybe we could simply get `$(CARGO) install` to
work here, and then let the packging put it into /usr/sbin ?

> +
> +update-dcontrol: #$(BUILDDIR)
> +	debcargo package \
> +	  --config debian/debcargo.toml \
> +	  --changelog-ready \
> +	  --no-overlay-write-back \
> +	  --directory $(BUILDDIR) \
> +	  $(PACKAGE) \
> +	  $(shell dpkg-parsechangelog -l debian/changelog -SVersion | sed -e 's/-.*//')
> +	cat $(BUILDDIR)/debian/control debian/control.extra > debian/control
> +	wrap-and-sort -t -k -f debian/control

this doesn't work because there is no debian/debcargo.toml and also no
debian/control.extra ;) since debcargo doesn't work with workspaces
(yet), it probably doesn't make sense to leave this in.. one just has to
remember to update d/control when updating any of the Cargo.toml files
w.r.t. dependencies or features.

> +
> +.PHONY: build
> +build: $(BUILDDIR)
> +$(BUILDDIR):
> +	rm -rf $@ $@.tmp; mkdir $@.tmp
> +	cp -a proxmox-firewall proxmox-nftables proxmox-ve-config debian Cargo.toml Makefile defines.mk $@.tmp/

this is missing the .cargo dir, which doesn't matter (much) for building
directly via `make deb` (since cargo will look in parent dirs), but
completely breaks for `make dsc` or `make sbuild`. but I suggest
handling that in d/rules (see comment there)

> +	mv $@.tmp $@
> +
> +.PHONY: deb
> +deb: $(DEB)
> +$(HELPER_DEB) $(DBG_DEB) $(HELPER_DBG_DEB) $(DOC_DEB): $(DEB)
> +$(DEB): $(BUILDDIR)
> +	cd $(BUILDDIR); dpkg-buildpackage -b -us -uc --no-pre-clean
> +	lintian $(DEB) $(DOC_DEB) $(HELPER_DEB)
> +
> +.PHONY: test
> +test:
> +	cargo test
> +
> +.PHONY: dsc
> +dsc:
> +	rm -rf $(BUILDDIR) $(DSC)
> +	$(MAKE) $(DSC)
> +	lintian $(DSC)
> +$(DSC): $(BUILDDIR)
> +	cd $(BUILDDIR); dpkg-buildpackage -S -us -uc -d -nc
> +
> +sbuild: $(DSC)
> +	sbuild $<
> +
> +.PHONY: dinstall
> +dinstall: $(DEB)
> +	dpkg -i $(DEB) $(DBG_DEB) $(DOC_DEB)
> +
> +.PHONY: distclean
> +distclean: clean
> +
> +.PHONY: clean
> +clean:
> +	cargo clean
> +	rm -f *.deb *.build *.buildinfo *.changes *.dsc rust-$(PACKAGE)*.tar*
> +	rm -rf $(PACKAGE)-[0-9]*/
> +	find . -name '*~' -exec rm {} ';'
> diff --git a/debian/changelog b/debian/changelog
> new file mode 100644
> index 0000000..7918ec9
> --- /dev/null
> +++ b/debian/changelog
> @@ -0,0 +1,5 @@
> +proxmox-firewall (0.1-1) UNRELEASED; urgency=medium

the `-1` and `debian/source/format` disagree (again, only a problem when
attempting building via sbuild)

> +
> +  * Initial release.
> +
> + -- Stefan Hanreich <s.hanreich@proxmox.com>  Thu, 07 Mar 2024 10:15:10 +0100
> diff --git a/debian/control b/debian/control
> new file mode 100644
> index 0000000..e04ce68
> --- /dev/null
> +++ b/debian/control
> @@ -0,0 +1,31 @@
> +Source: proxmox-firewall
> +Section: admin
> +Priority: optional
> +Maintainer: Proxmox Support Team <support@proxmox.com>
> +Build-Depends: cargo:native,
> +	       debhelper-compat (= 13),
> +	       dh-cargo (>= 25),

you aren't actually using that though? (and probably shouldn't, it's
mainly for "simple" crate <-> package situations)

> +	       librust-anyhow-1+default-dev,
> +	       librust-env-logger-0.10+default-dev,
> +	       librust-log-0.4+default-dev (>= 0.4.17-~~),
> +	       librust-nix-0.26+default-dev (>= 0.26.1-~~),
> +	       librust-serde-1+default-dev,
> +	       librust-serde-1+derive-dev,
> +	       librust-serde-json-1+default-dev,
> +	       librust-serde-plain-1+default-dev,
> +	       librust-serde-plain-1+default-dev,
> +	       librust-serde-with+default-dev,
> +	       librust-libc-0.2+default-dev,
> +	       librust-proxmox-schema-3+default-dev,

this is missing at least libnftables-dev and netbase

> +Standards-Version: 4.6.2
> +Homepage: https://www.proxmox.com
> +
> +Package: proxmox-firewall
> +Architecture: any
> +Conflicts: ulogd,
> +Depends: ${misc:Depends}, ${shlibs:Depends},
> +	 pve-firewall,
> +	 nftables,

this is missing at least netbase ;)

> +Description: Proxmox VE nft Firewall
> + This package contains a nftables-based implementation of the Proxmox VE
> + Firewall
> diff --git a/debian/copyright b/debian/copyright
> new file mode 100644
> index 0000000..fe09a1b
> --- /dev/null
> +++ b/debian/copyright
> @@ -0,0 +1,16 @@
> +Copyright (C) 2018-2024 Proxmox Server Solutions GmbH
> +
> +This software is written by Proxmox Server Solutions GmbH <support@proxmox.com>
> +
> +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 <http://www.gnu.org/licenses/>.
> diff --git a/debian/proxmox-firewall.service b/debian/proxmox-firewall.service
> new file mode 100644
> index 0000000..5f9bf4b
> --- /dev/null
> +++ b/debian/proxmox-firewall.service
> @@ -0,0 +1,16 @@
> +[Unit]
> +Description=Proxmox VE nftables firewall
> +ConditionPathExists=/usr/sbin/proxmox-firewall

when would this path not exist?

> +Wants=pve-cluster.service pvefw-logger.service
> +After=pvefw-logger.service pve-cluster.service network.target systemd-modules-load.service

should the timer also get this to avoid premature starting? or am I
misremembering how timers and their services interact :)

> +DefaultDependencies=no
> +Before=shutdown.target
> +Conflicts=shutdown.target
> +
> +[Service]
> +ExecStart=/usr/sbin/proxmox-firewall
> +Type=oneshot
> +
> +[Install]
> +WantedBy=multi-user.target
> +

so this is started by the timer below (on boot and then every 5s) but
also enabled and thus started once on boot? that seems confusing ;)

> diff --git a/debian/proxmox-firewall.timer b/debian/proxmox-firewall.timer
> new file mode 100644
> index 0000000..d051102
> --- /dev/null
> +++ b/debian/proxmox-firewall.timer
> @@ -0,0 +1,11 @@
> +[Unit]
> +Description=Proxmox VE nft Firewall timer

capitalisation compared to the service above (and also, nft vs nftables)

> +
> +[Timer]
> +OnBootSec=1s
> +OnUnitInactiveSec=5s
> +Unit=proxmox-firewall.service
> +
> +[Install]
> +WantedBy=timers.target
> +
> diff --git a/debian/rules b/debian/rules
> new file mode 100644

d/rules should be executable :)

> index 0000000..5539a00
> --- /dev/null
> +++ b/debian/rules
> @@ -0,0 +1,14 @@
> +#!/usr/bin/make -f
> +
> +# Uncomment this to turn on verbose mode.
> +#export DH_VERBOSE=1
> +
> +%:
> +	dh $@
> +

I would suggest calling the cargo wrapper here to create a proper
.cargo/config file for a clean build. you can look at proxmox-backup for
inspiration ;)

here's a starting point:

diff --git a/debian/rules b/debian/rules
index 5539a00..bbb4d0a 100644
--- a/debian/rules
+++ b/debian/rules
@@ -1,14 +1,31 @@
 #!/usr/bin/make -f
 
 # Uncomment this to turn on verbose mode.
-#export DH_VERBOSE=1
+export DH_VERBOSE=1
+
+include /usr/share/dpkg/pkg-info.mk
+include /usr/share/rustc/architecture.mk
+
+export BUILD_MODE=release
+
+CARGO=/usr/share/cargo/bin/cargo
+
+export CFLAGS CXXFLAGS CPPFLAGS LDFLAGS
+export DEB_HOST_RUST_TYPE DEB_HOST_GNU_TYPE
+export CARGO_HOME = $(CURDIR)/debian/cargo_home
+
+export DEB_CARGO_CRATE=proxmox-firewall_$(DEB_VERSION_UPSTREAM)
+export DEB_CARGO_PACKAGE=proxmox-firewall
 
 %:
 	dh $@
 
+override_dh_auto_configure:
+	@perl -ne 'if (/^version\s*=\s*"(\d+(?:\.\d+)+)"/) { my $$v_cargo = $$1; my $$v_deb = "$(DEB_VERSION_UPSTREAM)"; \
+	    die "ERROR: d/changelog <-> Cargo.toml version mismatch: $$v_cargo != $$v_deb\n" if $$v_cargo ne $$v_deb; exit(0); }' Cargo.toml
+	$(CARGO) prepare-debian $(CURDIR)/debian/cargo_registry --link-from-system
+	dh_auto_configure
+
 override_dh_installsystemd:
 	dh_installsystemd --no-start proxmox-firewall.service
 	dh_installsystemd proxmox-firewall.timer


we probably also want `cargo` in the Makefile to be $(CARGO)
(defaulting to `cargo` there if not set). that way, `cargo build` and
`cargo test` also go through the wrapper and pick up any special sauce

> +override_dh_installsystemd:
> +	dh_installsystemd --no-start proxmox-firewall.service
> +	dh_installsystemd proxmox-firewall.timer
> +
> +override_dh_installinit:
> +

don't think this dh_installinit override is needed?

> diff --git a/debian/source/format b/debian/source/format
> new file mode 100644
> index 0000000..89ae9db
> --- /dev/null
> +++ b/debian/source/format
> @@ -0,0 +1 @@
> +3.0 (native)

native means no Debian revision in the version, see above w.r.t.
d/changelog

> diff --git a/defines.mk b/defines.mk
> new file mode 100644
> index 0000000..e01164d
> --- /dev/null
> +++ b/defines.mk
> @@ -0,0 +1,13 @@
> +PREFIX = /usr
> +BINDIR = $(PREFIX)/bin
> +SBINDIR = $(PREFIX)/sbin
> +LIBDIR = $(PREFIX)/lib
> +LIBEXECDIR = $(LIBDIR)
> +DATAROOTDIR = $(PREFIX)/share
> +MAN1DIR = $(PREFIX)/share/man/man1
> +MAN5DIR = $(PREFIX)/share/man/man5
> +SYSCONFDIR = /etc
> +
> +# For local overrides
> +-include local.mak
> +
> -- 
> 2.39.2
> 
> 
> _______________________________________________
> pve-devel mailing list
> pve-devel@lists.proxmox.com
> https://lists.proxmox.com/cgi-bin/mailman/listinfo/pve-devel
> 
> 
> 




^ permalink raw reply	[flat|nested] 67+ messages in thread

* Re: [pve-devel] [PATCH proxmox-firewall 02/37] config: firewall: add types for ip addresses
  2024-04-03 10:46   ` Max Carrara
@ 2024-04-09  8:26     ` Stefan Hanreich
  0 siblings, 0 replies; 67+ messages in thread
From: Stefan Hanreich @ 2024-04-09  8:26 UTC (permalink / raw)
  To: pve-devel

On 4/3/24 12:46, Max Carrara wrote:
> You could just `match` on a slice of `entries` here and then have ...

Yes, that is much nicer! Will incorporate.




^ permalink raw reply	[flat|nested] 67+ messages in thread

* Re: [pve-devel] [PATCH proxmox-firewall 06/37] config: host: add helpers for host network configuration
  2024-04-03 10:46   ` Max Carrara
@ 2024-04-09  8:32     ` Stefan Hanreich
  0 siblings, 0 replies; 67+ messages in thread
From: Stefan Hanreich @ 2024-04-09  8:32 UTC (permalink / raw)
  To: pve-devel

On 4/3/24 12:46, Max Carrara wrote:
> IMO the closures here and below could later be put into something like
> proxmox-sys (or similar) as freestanding functions without static data
> and then called here - but this is fine as it is; just an idea!

Yes, we are lacking networking-related helpers there afaict and it would
be a good fit imo.

I wanted to check proxmox-backup if there are any similar methods there
already when I daemonize the whole firewall since that would require
touching those functions anyway (removing the OnceLock's). Surely we
have something related to getting network configuration there already...




^ permalink raw reply	[flat|nested] 67+ messages in thread

* Re: [pve-devel] [PATCH proxmox-firewall 09/37] config: firewall: add types for rules
  2024-04-03 10:46   ` Max Carrara
@ 2024-04-09  8:36     ` Stefan Hanreich
  2024-04-09 14:55     ` Lukas Wagner
  1 sibling, 0 replies; 67+ messages in thread
From: Stefan Hanreich @ 2024-04-09  8:36 UTC (permalink / raw)
  To: pve-devel

On 4/3/24 12:46, Max Carrara wrote:
> Hmm, since this is only used below, IMO it's fine that this returns a
> tuple like that on `Ok` - but should functions like that be used in
> multiple places, it might be beneficial to use a type alias or even a
> tuple struct for readability's sake.

Yes, I figured it was alright in this case, since it's mostly its own
function so the FromStr implementation is less noisy

> Should maaaybe note that it must be sorted for binary search, not just
> for any reason. :P

Will do!




^ permalink raw reply	[flat|nested] 67+ messages in thread

* Re: [pve-devel] [PATCH proxmox-firewall 11/37] config: firewall: add generic parser for firewall configs
  2024-04-03 10:47   ` Max Carrara
@ 2024-04-09  8:38     ` Stefan Hanreich
  0 siblings, 0 replies; 67+ messages in thread
From: Stefan Hanreich @ 2024-04-09  8:38 UTC (permalink / raw)
  To: pve-devel

On 4/3/24 12:47, Max Carrara wrote:
> Since this is `pub`, I think a more complete docstring here would be
> better instead of a comment. Though I haven't generated the docs for all
> of this (yet) I have to admit, so I'm not sure if this actually shows
> up.

It's generally something I've been a bit sloppy with in this RFC
admittedly due to trying to get this out for an initial review. I am
currently trying to improve code comments in general for a v2.




^ permalink raw reply	[flat|nested] 67+ messages in thread

* Re: [pve-devel] [PATCH proxmox-firewall 13/37] config: firewall: add host specific config + option types
  2024-04-03 10:47   ` Max Carrara
@ 2024-04-09  8:55     ` Stefan Hanreich
  0 siblings, 0 replies; 67+ messages in thread
From: Stefan Hanreich @ 2024-04-09  8:55 UTC (permalink / raw)
  To: pve-devel

On 4/3/24 12:47, Max Carrara wrote:
> Should maybe document such defaults in the docstring of the `pub`
> function above?
> 
>> +
>> +    pub fn synflood_burst(&self) -> i64 {
>> +        self.config
>> +            .options
>> +            .protection_synflood_burst
>> +            .unwrap_or(1000)
>> +    }
> 
> Same here.
> 
> Also, numeric defaults like those could maaaaaybe be declared as a
> `const` upfront (and documented). Technically, doing this for the
> boolean defaults here in this patch wouldn't hurt either - I realize
> that it's clear from the context of the code what's meant, but in this
> case it would be solely for documentation purposes.
>
> E.g. if the question "Does the firewall enable NDP by default?" arises,
> one could just check the (docstrings of the) constants declared at the
> top of the file, or even better, browse the docs generated by cargo if
> they're not a developer.

Those defaults are already documented quite well in the Firewall
documentation [1], but having it explicitly in the source code as well
wouldn't hurt in any case I'd say. Certainly something for a v2.

Generally I wasn't sure how to best implement this, since another
possibility would be implementing Default for the Options and then just
overwriting the default values if they occur in the configuration.
Thinking about it more, this might be the better way to go about this,
but I think there was a reason why I opted against it, I just cannot
remember it atm. I'll definitely look into it!

[1] https://pve.proxmox.com/wiki/Firewall




^ permalink raw reply	[flat|nested] 67+ messages in thread

* Re: [pve-devel] [PATCH proxmox-firewall 33/37] firewall: add files for debian packaging
  2024-04-03 13:14   ` Fabian Grünbichler
@ 2024-04-09  8:56     ` Stefan Hanreich
  0 siblings, 0 replies; 67+ messages in thread
From: Stefan Hanreich @ 2024-04-09  8:56 UTC (permalink / raw)
  To: pve-devel

Thank you very much for the comprehensive comments and remarks, I've
already incorporated all of them and we should now have proper clean
builds for the firewall!




^ permalink raw reply	[flat|nested] 67+ messages in thread

* Re: [pve-devel] [PATCH proxmox-firewall 21/37] nftables: statement: add types
  2024-04-03 10:47   ` Max Carrara
@ 2024-04-09  8:58     ` Stefan Hanreich
  0 siblings, 0 replies; 67+ messages in thread
From: Stefan Hanreich @ 2024-04-09  8:58 UTC (permalink / raw)
  To: pve-devel

On 4/3/24 12:47, Max Carrara wrote:
> Hmm, you don't use either here - you sure you didn't mean to introduce
> `anyhow` later?

Very possible that is a mishap of mine while trying to prettify git history.




^ permalink raw reply	[flat|nested] 67+ messages in thread

* Re: [pve-devel] [RFC container/firewall/manager/proxmox-firewall/qemu-server 00/37] proxmox firewall nftables implementation
  2024-04-03 10:46 ` Max Carrara
@ 2024-04-09  9:21   ` Stefan Hanreich
  0 siblings, 0 replies; 67+ messages in thread
From: Stefan Hanreich @ 2024-04-09  9:21 UTC (permalink / raw)
  To: pve-devel

On 4/3/24 12:46, Max Carrara wrote:
> Four overall things I want to mention:
> 
>   1. IMO a lot of the `pub` items should eventually be documented,
>      preferably once the actual series is out. I don't think we need to
>      be as thorough as e.g. the Rust STL's documentation, but I don't
>      think it would hurt if the overall functionality of things was
>      documented. (Of course, e.g. saying that `pub fn hostname()`
>      "gets the hostname" isn't necessary; but you get what I mean :P )

As already mentioned in-line I am currently working on this.

>   2. Constants and defaults should also be documented, simply because
>      it makes it easier to refer to those defaults if necessary. On top
>      of that, it's also more obvious if those constants / defaults ever
>      have to be changed for some reason. That way we would avoid
>      accidental semver-breakage. There's a more specific example inline.

see my in-line comments in the specific patch.

>   3. Would it perhaps actually make sense to use `thiserror` instead of
>      `anyhow`? I know we've speculated a little off list about this
>      already - I still am not 100% convinced that `thiserror` is
>      necessary, but then again, it would be quite nice in the library
>      crates, as you don't really need to propagate any `anyhow::Context`
>      anyways ...
> 
>      There's already `NftError` in proxmox-nftables that *could perhaps*
>      just be implemented via `thiserror`, I think.

Yes, error handling is probably the one big thing that needs some
overhauling. Since this was a monolithic crate that I've then extracted
into 3 different crates, anyhow was used throughout the whole codebase.

Not sure if thiserror is really necessary here, just like you, probably
just custom error types would suffice imo.

>   4. Some of the types (in particular in `proxmox-ve-config` and
>      `proxmox-nftables`) could use some more trait-deriving - a lot of
>      the structs and enums could benefit from deriving `PartialOrd`,
>      `Ord` and `Hash` for interoperability's sake [0]. While it's
>      probably unlikely that some types will ever be used as keys in a
>      hashmap, deriving the trait IMO doesn't hurt.
> 
>      A lot of types also implement `PartialEq` and `Eq` only for tests,
>      but IMO those traits could theoretically just always be implemented
>      for most of them.
> 
>      As this affects a lot of types I've decided to just sum this up
>      here by the way; if you need more concrete examples, please let me
>      know and I'll add respective comments inline.

Good point, I will review the structs/enums and add additional
derivations where applicable.




^ permalink raw reply	[flat|nested] 67+ messages in thread

* Re: [pve-devel] [PATCH proxmox-firewall 06/37] config: host: add helpers for host network configuration
  2024-04-02 17:15 ` [pve-devel] [PATCH proxmox-firewall 06/37] config: host: add helpers for host network configuration Stefan Hanreich
  2024-04-03 10:46   ` Max Carrara
@ 2024-04-09 14:20   ` Lukas Wagner
  1 sibling, 0 replies; 67+ messages in thread
From: Lukas Wagner @ 2024-04-09 14:20 UTC (permalink / raw)
  To: Proxmox VE development discussion, Stefan Hanreich; +Cc: Wolfgang Bumiller



On  2024-04-02 19:15, Stefan Hanreich wrote:
> Currently the helpers for obtaining the host network configuration
> panic on error, which could be avoided by the use of
> OnceLock::get_or_init, but this method is currently only available in
> nightly versions.
> 
> Generally, if there is a problem with obtaining a hostname for the
> current node then something else is probably already quite broken, so
> I would deem it acceptable for now, same goes for obtaining the
> current network configuration.
> 
> Co-authored-by: Wolfgang Bumiller <w.bumiller@proxmox.com>
> Signed-off-by: Stefan Hanreich <s.hanreich@proxmox.com>
> ---
>  proxmox-ve-config/Cargo.toml        |  1 +
>  proxmox-ve-config/src/host/mod.rs   |  1 +
>  proxmox-ve-config/src/host/utils.rs | 97 +++++++++++++++++++++++++++++
>  proxmox-ve-config/src/lib.rs        |  1 +
>  4 files changed, 100 insertions(+)
>  create mode 100644 proxmox-ve-config/src/host/mod.rs
>  create mode 100644 proxmox-ve-config/src/host/utils.rs
> 
> diff --git a/proxmox-ve-config/Cargo.toml b/proxmox-ve-config/Cargo.toml
> index 7bb391e..480eb58 100644
> --- a/proxmox-ve-config/Cargo.toml
> +++ b/proxmox-ve-config/Cargo.toml
> @@ -13,6 +13,7 @@ license = "AGPL-3"
>  [dependencies]
>  log = "0.4"
>  anyhow = "1"
> +nix = "0.26"
>  
>  serde = { version = "1", features = [ "derive" ] }
>  serde_json = "1"
> diff --git a/proxmox-ve-config/src/host/mod.rs b/proxmox-ve-config/src/host/mod.rs
> new file mode 100644
> index 0000000..b5614dd
> --- /dev/null
> +++ b/proxmox-ve-config/src/host/mod.rs
> @@ -0,0 +1 @@
> +pub mod utils;
> diff --git a/proxmox-ve-config/src/host/utils.rs b/proxmox-ve-config/src/host/utils.rs
> new file mode 100644
> index 0000000..1636f95
> --- /dev/null
> +++ b/proxmox-ve-config/src/host/utils.rs
> @@ -0,0 +1,97 @@
> +use std::net::{IpAddr, ToSocketAddrs};
> +use std::sync::OnceLock;
> +
> +use crate::firewall::types::Cidr;
> +
> +use nix::sys::socket::{AddressFamily, SockaddrLike};
> +
> +pub fn hostname() -> &'static str {
> +    static HOSTNAME: OnceLock<String> = OnceLock::new();
> +
> +    // We should rather use get_or_try_init to avoid needing to panic
> +    // but it is currently experimental
> +    HOSTNAME.get_or_init(|| {
> +        use nix::libc::{c_char, gethostname, sysconf, _SC_HOST_NAME_MAX};
> +        use std::ffi::CStr;
> +
> +        let max_len = unsafe { sysconf(_SC_HOST_NAME_MAX) } as usize + 1;
> +        let mut buffer = vec![0; max_len];
> +
> +        let ret = unsafe { gethostname(buffer.as_mut_ptr() as *mut c_char, buffer.len()) };
> +
> +        if ret != 0 {
> +            // failing to get the hostname means something is *really* off
> +            panic!("gethostname failed with returncode {ret}");
> +        }
> +
> +        let c_str = CStr::from_bytes_until_nul(&buffer).expect("buffer contains a NUL byte");
> +
> +        String::from_utf8_lossy(c_str.to_bytes()).to_string()
> +    })
> +}

^
FYI: There is proxmox_sys::nodename() already, which also does caching. Unless I'm missing something
you could just use that instead of re-implementing it?
It uses `uname` from the nix-crate, not sure if there any differences to using `gethostname` (but
I don't think so).

-- 
- Lukas




^ permalink raw reply	[flat|nested] 67+ messages in thread

* Re: [pve-devel] [PATCH proxmox-firewall 09/37] config: firewall: add types for rules
  2024-04-03 10:46   ` Max Carrara
  2024-04-09  8:36     ` Stefan Hanreich
@ 2024-04-09 14:55     ` Lukas Wagner
  1 sibling, 0 replies; 67+ messages in thread
From: Lukas Wagner @ 2024-04-09 14:55 UTC (permalink / raw)
  To: Proxmox VE development discussion, Max Carrara, Stefan Hanreich


On  2024-04-03 12:46, Max Carrara wrote:
>> +
>> +#[derive(Clone, Debug)]
>> +#[cfg_attr(test, derive(Eq, PartialEq))]
>> +pub enum IcmpType {
>> +    Numeric(u8),
>> +    Named(&'static str),
>> +}
>> +
>> +// MUST BE SORTED!
> 
> Should maaaybe note that it must be sorted for binary search, not just
> for any reason. :P
> 
>> +const ICMP_TYPES: &[(&str, u8)] = &[
>> +    ("address-mask-reply", 18),
>> +    ("address-mask-request", 17),
>> +    ("destination-unreachable", 3),
>> +    ("echo-reply", 0),
>> +    ("echo-request", 8),
>> +    ("info-reply", 16),
>> +    ("info-request", 15),
>> +    ("parameter-problem", 12),
>> +    ("redirect", 5),
>> +    ("router-advertisement", 9),
>> +    ("router-solicitation", 10),
>> +    ("source-quench", 4),
>> +    ("time-exceeded", 11),
>> +    ("timestamp-reply", 14),
>> +    ("timestamp-request", 13),
>> +];

I think `proxmox-sortable-macro` might come in handy here. From its examples:

    #[sortable]
    const FOO2: [(&str, usize); 3] = sorted!([("3", 1), ("2", 2), ("1", 3)]);
    assert_eq!(FOO2, [("1", 3), ("2", 2), ("3", 1)]);


-- 
- Lukas




^ permalink raw reply	[flat|nested] 67+ messages in thread

* Re: [pve-devel] [RFC container/firewall/manager/proxmox-firewall/qemu-server 00/37] proxmox firewall nftables implementation
  2024-04-02 17:15 [pve-devel] [RFC container/firewall/manager/proxmox-firewall/qemu-server 00/37] proxmox firewall nftables implementation Stefan Hanreich
                   ` (40 preceding siblings ...)
  2024-04-03 10:46 ` Max Carrara
@ 2024-04-10 10:25 ` Lukas Wagner
  2024-04-11  5:21   ` Stefan Hanreich
  41 siblings, 1 reply; 67+ messages in thread
From: Lukas Wagner @ 2024-04-10 10:25 UTC (permalink / raw)
  To: Proxmox VE development discussion, Stefan Hanreich



On  2024-04-02 19:15, Stefan Hanreich wrote:
> ## Introduction
> This RFC provides a drop-in replacement for the current pve-firewall package
> that is based on Rust and nftables.
> 
> It consists of three crates:
> * proxmox-ve-config
>   for parsing firewall and guest configuration files, as well as some helpers
>   to access host configuration (particularly networking)
> * proxmox-nftables
>   contains bindings for libnftables as well as types that implement the JSON
>   schema defined by libnftables-json
> * proxmox-firewall
>   uses the other two crates to read the firewall configuration and create the
>   respective nftables configuration
> 

Great work!

Did a relatively shallow review of the Rust parts, digging deeper only into
a smaller subset of the code.
Some aspects where I see room for improvement are mostly documentation,
as Max already mentioned, and some more automated testing. I think it would
greatly benefit the long-term maintainability of this tool to test the
the full 'config files' --> 'Command' transformation. This would require some
refactoring in the part reading the configuration, because currently the
config paths seem to be mostly hard coded. 
Since `Command` is serializable anyway, we could have a nice test suite of
firewall/VM config files and expected commands as JSON dumps. 
This will be tedious to setup at first, but will help to detect any unwanted
regressions in the long-term.

-- 
- Lukas




^ permalink raw reply	[flat|nested] 67+ messages in thread

* Re: [pve-devel] [RFC container/firewall/manager/proxmox-firewall/qemu-server 00/37] proxmox firewall nftables implementation
  2024-04-10 10:25 ` Lukas Wagner
@ 2024-04-11  5:21   ` Stefan Hanreich
  2024-04-11  7:34     ` Thomas Lamprecht
  0 siblings, 1 reply; 67+ messages in thread
From: Stefan Hanreich @ 2024-04-11  5:21 UTC (permalink / raw)
  To: Lukas Wagner, Proxmox VE development discussion


On 4/10/24 12:25, Lukas Wagner wrote:
> Did a relatively shallow review of the Rust parts, digging deeper only into
> a smaller subset of the code.
> Some aspects where I see room for improvement are mostly documentation,
> as Max already mentioned, and some more automated testing. I think it would
> greatly benefit the long-term maintainability of this tool to test the
> the full 'config files' --> 'Command' transformation. This would require some
> refactoring in the part reading the configuration, because currently the
> config paths seem to be mostly hard coded. 
> Since `Command` is serializable anyway, we could have a nice test suite of
> firewall/VM config files and expected commands as JSON dumps. 
> This will be tedious to setup at first, but will help to detect any unwanted
> regressions in the long-term.

Yes, that is certainly something that is on the menu, as we've already
talked off-list using something like insta[1], which is already
packaged, would be a good approach to this imo.

[1] https://github.com/mitsuhiko/insta




^ permalink raw reply	[flat|nested] 67+ messages in thread

* Re: [pve-devel] [RFC container/firewall/manager/proxmox-firewall/qemu-server 00/37] proxmox firewall nftables implementation
  2024-04-11  5:21   ` Stefan Hanreich
@ 2024-04-11  7:34     ` Thomas Lamprecht
  2024-04-11  7:55       ` Stefan Hanreich
  0 siblings, 1 reply; 67+ messages in thread
From: Thomas Lamprecht @ 2024-04-11  7:34 UTC (permalink / raw)
  To: Proxmox VE development discussion, Stefan Hanreich, Lukas Wagner

Am 11/04/2024 um 07:21 schrieb Stefan Hanreich:
>> Since `Command` is serializable anyway, we could have a nice test suite of
>> firewall/VM config files and expected commands as JSON dumps. 
>> This will be tedious to setup at first, but will help to detect any unwanted
>> regressions in the long-term.
> 
> Yes, that is certainly something that is on the menu, as we've already
> talked off-list using something like insta[1], which is already
> packaged, would be a good approach to this imo.
> 
> [1] https://github.com/mitsuhiko/insta

Does a simple serialize config and then diff that to the reference
really needs that elaborate crate that comes with its own cargo sub
command? I mean it looks Like I do not need to use the latter for
running the tests, so I guess if it's packaged in Debian we could
try it if you really think it provides that much convenience.




^ permalink raw reply	[flat|nested] 67+ messages in thread

* Re: [pve-devel] [RFC container/firewall/manager/proxmox-firewall/qemu-server 00/37] proxmox firewall nftables implementation
  2024-04-11  7:34     ` Thomas Lamprecht
@ 2024-04-11  7:55       ` Stefan Hanreich
  0 siblings, 0 replies; 67+ messages in thread
From: Stefan Hanreich @ 2024-04-11  7:55 UTC (permalink / raw)
  To: Thomas Lamprecht, Proxmox VE development discussion, Lukas Wagner

On 4/11/24 09:34, Thomas Lamprecht wrote:
> Am 11/04/2024 um 07:21 schrieb Stefan Hanreich:
>>> Since `Command` is serializable anyway, we could have a nice test suite of
>>> firewall/VM config files and expected commands as JSON dumps. 
>>> This will be tedious to setup at first, but will help to detect any unwanted
>>> regressions in the long-term.
>>
>> Yes, that is certainly something that is on the menu, as we've already
>> talked off-list using something like insta[1], which is already
>> packaged, would be a good approach to this imo.
>>
>> [1] https://github.com/mitsuhiko/insta
> 
> Does a simple serialize config and then diff that to the reference
> really needs that elaborate crate that comes with its own cargo sub
> command? I mean it looks Like I do not need to use the latter for
> running the tests, so I guess if it's packaged in Debian we could
> try it if you really think it provides that much convenience.

Imo the main upside would be that it also takes care of managing all the
reference values, which I think could get quite unwieldy in the future
when we make changes to the way rules are generated.

A quick check of the generated JSON shows that for a relatively small
firewall configuration we already generate 36K worth of JSON (which is
mostly due to the overhead of generating the chains for the options and
so on).

With insta we could generate the reference values for the first run,
check the output, and then regenerate the JSON in case of changes and
then only review the diff (which is conveniently displayed via the
inbuilt tool).

I wanted to evaluate it at least, because I think this could greatly
simplify updating the test cases in the case of changing rule outputs -
which might otherwise turn out quite cumbersome. Particularly since
there are still some low-hanging fruits wrt optimization of generated
rules I'd imagine this would reduce the churn of updating all the tests
when I introduce such optimizations.




^ permalink raw reply	[flat|nested] 67+ messages in thread

end of thread, other threads:[~2024-04-11  7:55 UTC | newest]

Thread overview: 67+ messages (download: mbox.gz / follow: Atom feed)
-- links below jump to the message on this page --
2024-04-02 17:15 [pve-devel] [RFC container/firewall/manager/proxmox-firewall/qemu-server 00/37] proxmox firewall nftables implementation Stefan Hanreich
2024-04-02 17:15 ` [pve-devel] [PATCH proxmox-firewall 01/37] config: add proxmox-ve-config crate Stefan Hanreich
2024-04-02 17:15 ` [pve-devel] [PATCH proxmox-firewall 02/37] config: firewall: add types for ip addresses Stefan Hanreich
2024-04-03 10:46   ` Max Carrara
2024-04-09  8:26     ` Stefan Hanreich
2024-04-02 17:15 ` [pve-devel] [PATCH proxmox-firewall 03/37] config: firewall: add types for ports Stefan Hanreich
2024-04-02 17:15 ` [pve-devel] [PATCH proxmox-firewall 04/37] config: firewall: add types for log level and rate limit Stefan Hanreich
2024-04-02 17:15 ` [pve-devel] [PATCH proxmox-firewall 05/37] config: firewall: add types for aliases Stefan Hanreich
2024-04-02 17:15 ` [pve-devel] [PATCH proxmox-firewall 06/37] config: host: add helpers for host network configuration Stefan Hanreich
2024-04-03 10:46   ` Max Carrara
2024-04-09  8:32     ` Stefan Hanreich
2024-04-09 14:20   ` Lukas Wagner
2024-04-02 17:15 ` [pve-devel] [PATCH proxmox-firewall 07/37] config: guest: add helpers for parsing guest network config Stefan Hanreich
2024-04-02 17:16 ` [pve-devel] [PATCH proxmox-firewall 08/37] config: firewall: add types for ipsets Stefan Hanreich
2024-04-02 17:16 ` [pve-devel] [PATCH proxmox-firewall 09/37] config: firewall: add types for rules Stefan Hanreich
2024-04-03 10:46   ` Max Carrara
2024-04-09  8:36     ` Stefan Hanreich
2024-04-09 14:55     ` Lukas Wagner
2024-04-02 17:16 ` [pve-devel] [PATCH proxmox-firewall 10/37] config: firewall: add types for security groups Stefan Hanreich
2024-04-02 17:16 ` [pve-devel] [PATCH proxmox-firewall 11/37] config: firewall: add generic parser for firewall configs Stefan Hanreich
2024-04-03 10:47   ` Max Carrara
2024-04-09  8:38     ` Stefan Hanreich
2024-04-02 17:16 ` [pve-devel] [PATCH proxmox-firewall 12/37] config: firewall: add cluster-specific config + option types Stefan Hanreich
2024-04-02 17:16 ` [pve-devel] [PATCH proxmox-firewall 13/37] config: firewall: add host specific " Stefan Hanreich
2024-04-03 10:47   ` Max Carrara
2024-04-09  8:55     ` Stefan Hanreich
2024-04-02 17:16 ` [pve-devel] [PATCH proxmox-firewall 14/37] config: firewall: add guest-specific " Stefan Hanreich
2024-04-02 17:16 ` [pve-devel] [PATCH proxmox-firewall 15/37] config: firewall: add firewall macros Stefan Hanreich
2024-04-02 17:16 ` [pve-devel] [PATCH proxmox-firewall 16/37] config: firewall: add conntrack helper types Stefan Hanreich
2024-04-02 17:16 ` [pve-devel] [PATCH proxmox-firewall 17/37] nftables: add crate for libnftables bindings Stefan Hanreich
2024-04-02 17:16 ` [pve-devel] [PATCH proxmox-firewall 18/37] nftables: add helpers Stefan Hanreich
2024-04-02 17:16 ` [pve-devel] [PATCH proxmox-firewall 19/37] nftables: expression: add types Stefan Hanreich
2024-04-02 17:16 ` [pve-devel] [PATCH proxmox-firewall 20/37] nftables: expression: implement conversion traits for firewall config Stefan Hanreich
2024-04-02 17:16 ` [pve-devel] [PATCH proxmox-firewall 21/37] nftables: statement: add types Stefan Hanreich
2024-04-03 10:47   ` Max Carrara
2024-04-09  8:58     ` Stefan Hanreich
2024-04-02 17:16 ` [pve-devel] [PATCH proxmox-firewall 22/37] nftables: statement: add conversion traits for config types Stefan Hanreich
2024-04-02 17:16 ` [pve-devel] [PATCH proxmox-firewall 23/37] nftables: commands: add types Stefan Hanreich
2024-04-02 17:16 ` [pve-devel] [PATCH proxmox-firewall 24/37] nftables: types: add conversion traits Stefan Hanreich
2024-04-02 17:16 ` [pve-devel] [PATCH proxmox-firewall 25/37] nftables: add libnftables bindings Stefan Hanreich
2024-04-02 17:16 ` [pve-devel] [PATCH proxmox-firewall 26/37] firewall: add firewall crate Stefan Hanreich
2024-04-02 17:16 ` [pve-devel] [PATCH proxmox-firewall 27/37] firewall: add base ruleset Stefan Hanreich
2024-04-02 17:16 ` [pve-devel] [PATCH proxmox-firewall 28/37] firewall: add config loader Stefan Hanreich
2024-04-02 17:16 ` [pve-devel] [PATCH proxmox-firewall 29/37] firewall: add rule generation logic Stefan Hanreich
2024-04-02 17:16 ` [pve-devel] [PATCH proxmox-firewall 30/37] firewall: add object " Stefan Hanreich
2024-04-02 17:16 ` [pve-devel] [PATCH proxmox-firewall 31/37] firewall: add ruleset " Stefan Hanreich
2024-04-02 17:16 ` [pve-devel] [PATCH proxmox-firewall 32/37] firewall: add proxmox-firewall binary Stefan Hanreich
2024-04-02 17:16 ` [pve-devel] [PATCH proxmox-firewall 33/37] firewall: add files for debian packaging Stefan Hanreich
2024-04-03 13:14   ` Fabian Grünbichler
2024-04-09  8:56     ` Stefan Hanreich
2024-04-02 17:16 ` [pve-devel] [PATCH qemu-server 34/37] firewall: add handling for new nft firewall Stefan Hanreich
2024-04-02 17:16 ` [pve-devel] [PATCH pve-container 35/37] " Stefan Hanreich
2024-04-02 17:16 ` [pve-devel] [PATCH pve-firewall 36/37] add configuration option for new nftables firewall Stefan Hanreich
2024-04-02 17:16 ` [pve-devel] [PATCH pve-manager 37/37] firewall: expose " Stefan Hanreich
2024-04-02 20:47 ` [pve-devel] [RFC container/firewall/manager/proxmox-firewall/qemu-server 00/37] proxmox firewall nftables implementation Laurent GUERBY
2024-04-03  7:33   ` Stefan Hanreich
     [not found] ` <mailman.54.1712122640.450.pve-devel@lists.proxmox.com>
2024-04-03  7:52   ` Stefan Hanreich
2024-04-03 12:26   ` Stefan Hanreich
     [not found] ` <mailman.56.1712124362.450.pve-devel@lists.proxmox.com>
2024-04-03  8:15   ` Stefan Hanreich
     [not found]     ` <mailman.77.1712145853.450.pve-devel@lists.proxmox.com>
2024-04-03 12:25       ` Stefan Hanreich
     [not found]         ` <mailman.78.1712149473.450.pve-devel@lists.proxmox.com>
2024-04-03 13:08           ` Stefan Hanreich
2024-04-03 10:46 ` Max Carrara
2024-04-09  9:21   ` Stefan Hanreich
2024-04-10 10:25 ` Lukas Wagner
2024-04-11  5:21   ` Stefan Hanreich
2024-04-11  7:34     ` Thomas Lamprecht
2024-04-11  7:55       ` Stefan Hanreich

This is a public inbox, see mirroring instructions
for how to clone and mirror all data and code used for this inbox
Service provided by Proxmox Server Solutions GmbH | Privacy | Legal