nftblock 0.1.2

Atomically apply CIDR lists with nftables netlink batches
Documentation
use crate::config::{Direction, RuleMapping, Rules};
use anyhow::{Context, Result, bail};
use serde::{Deserialize, Deserializer, de};
use std::{
    collections::{BTreeMap, BTreeSet},
    path::Path,
};

#[derive(Debug, Clone, Deserialize)]
#[serde(untagged)]
enum ZonesDocument {
    List { zones: Vec<ZoneEntry> },
    Map(BTreeMap<String, ZoneValue>),
}

#[derive(Debug, Clone, Deserialize)]
#[serde(untagged)]
enum ZoneValue {
    Interfaces(Vec<String>),
    Detail {
        #[serde(default)]
        interfaces: Vec<String>,
        #[serde(default)]
        local: bool,
    },
}

#[derive(Debug, Clone, Deserialize)]
struct ZoneEntry {
    name: String,
    #[serde(default)]
    interfaces: Vec<String>,
    #[serde(default)]
    local: bool,
}

#[derive(Debug, Clone)]
struct Zone {
    interfaces: Vec<String>,
    local: bool,
}

#[derive(Debug, Clone)]
pub struct Zones(BTreeMap<String, Zone>);

impl<'de> Deserialize<'de> for Zones {
    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
    where
        D: Deserializer<'de>,
    {
        let map = BTreeMap::<String, ZoneValue>::deserialize(deserializer)?;
        Self::from_map(map).map_err(de::Error::custom)
    }
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Chain {
    Input,
    Forward,
    Output,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ResolvedRule {
    pub chain: Chain,
    pub blocklist: Direction,
    pub ingress: Vec<String>,
    pub egress: Vec<String>,
}

impl Zones {
    pub fn load(path: &Path) -> Result<Self> {
        let text =
            std::fs::read_to_string(path).with_context(|| format!("read {}", path.display()))?;
        let doc: ZonesDocument =
            serde_json::from_str(&text).with_context(|| format!("parse {}", path.display()))?;
        let entries = match doc {
            ZonesDocument::List { zones } => zones,
            ZonesDocument::Map(map) => return Self::from_map(map),
        };
        Self::from_entries(entries)
    }

    fn from_map(map: BTreeMap<String, ZoneValue>) -> Result<Self> {
        Self::from_entries(map.into_iter().map(|(name, value)| match value {
            ZoneValue::Interfaces(interfaces) => ZoneEntry {
                local: name.eq_ignore_ascii_case("LOCAL"),
                name,
                interfaces,
            },
            ZoneValue::Detail { interfaces, local } => ZoneEntry {
                name,
                interfaces,
                local,
            },
        }))
    }

    fn from_entries(entries: impl IntoIterator<Item = ZoneEntry>) -> Result<Self> {
        let mut zones = BTreeMap::new();
        for entry in entries {
            if entry.name.trim().is_empty() {
                bail!("zone has an empty name")
            }
            let local = entry.local || entry.name.eq_ignore_ascii_case("LOCAL");
            if !local && entry.interfaces.is_empty() {
                bail!("non-local zone {:?} has no interfaces", entry.name)
            }
            if entry
                .interfaces
                .iter()
                .any(|i| i.is_empty() || i.as_bytes().contains(&0))
            {
                bail!("zone {:?} has an invalid interface", entry.name)
            }
            if zones
                .insert(
                    entry.name.clone(),
                    Zone {
                        interfaces: entry.interfaces,
                        local,
                    },
                )
                .is_some()
            {
                bail!("duplicate zone {:?}", entry.name)
            }
        }
        Ok(Self(zones))
    }

    pub fn resolve(&self, rules: &Rules) -> Result<Vec<ResolvedRule>> {
        let mut result = Vec::new();
        for rule in &rules.input {
            result.push(self.resolve_one(Chain::Input, rule)?);
        }
        for rule in &rules.forward {
            result.push(self.resolve_one(Chain::Forward, rule)?);
        }
        for rule in &rules.output {
            result.push(self.resolve_one(Chain::Output, rule)?);
        }
        Ok(result)
    }

    fn resolve_one(&self, chain: Chain, rule: &RuleMapping) -> Result<ResolvedRule> {
        match chain {
            Chain::Input if !rule.egress_zones.is_empty() => {
                bail!("input rule cannot have egress_zones")
            }
            Chain::Output if !rule.ingress_zones.is_empty() => {
                bail!("output rule cannot have ingress_zones")
            }
            Chain::Forward if rule.ingress_zones.is_empty() || rule.egress_zones.is_empty() => {
                bail!("forward rule requires ingress_zones and egress_zones")
            }
            _ => {}
        }
        let ingress = self.expand(&rule.ingress_zones, chain == Chain::Forward)?;
        let egress = self.expand(&rule.egress_zones, chain == Chain::Forward)?;
        Ok(ResolvedRule {
            chain,
            blocklist: rule.blocklist,
            ingress,
            egress,
        })
    }

    fn expand(&self, names: &[String], forward: bool) -> Result<Vec<String>> {
        let mut interfaces = BTreeSet::new();
        for name in names {
            let zone = self
                .0
                .get(name)
                .with_context(|| format!("unknown zone {name:?}"))?;
            if forward && zone.local {
                bail!("local zone {name:?} cannot be used as a forward interface")
            }
            interfaces.extend(zone.interfaces.iter().cloned());
        }
        Ok(interfaces.into_iter().collect())
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::config::{Direction, RuleMapping};
    use std::io::Write;

    fn zones() -> Zones {
        let mut file = tempfile::NamedTempFile::new().unwrap();
        write!(file, r#"{{"WAN":["eth0"],"LAN":{{"interfaces":["eth1"],"local":false}},"LOCAL":{{"interfaces":[],"local":true}}}}"#).unwrap();
        Zones::load(file.path()).unwrap()
    }

    #[test]
    fn resolves_multiple_mappings_and_interfaces() {
        let rules = Rules {
            input: vec![RuleMapping {
                blocklist: Direction::Inbound,
                ingress_zones: vec!["WAN".into()],
                egress_zones: vec![],
            }],
            forward: vec![],
            output: vec![],
        };
        assert_eq!(zones().resolve(&rules).unwrap()[0].ingress, vec!["eth0"]);
    }

    #[test]
    fn rejects_local_forward_zone() {
        let rules = Rules {
            input: vec![],
            forward: vec![RuleMapping {
                blocklist: Direction::Inbound,
                ingress_zones: vec!["LOCAL".into()],
                egress_zones: vec!["WAN".into()],
            }],
            output: vec![],
        };
        assert!(
            zones()
                .resolve(&rules)
                .unwrap_err()
                .to_string()
                .contains("local zone")
        );
    }
}