nftblock 0.1.0

Atomically apply CIDR lists with nftables netlink batches
Documentation
use ipnet::IpNet;
use std::{
    collections::BTreeSet,
    io::BufRead,
    net::{Ipv4Addr, Ipv6Addr},
};
use thiserror::Error;

#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct ParsedBlocklist {
    pub ipv4: Vec<ipnet::Ipv4Net>,
    pub ipv6: Vec<ipnet::Ipv6Net>,
}

#[derive(Debug, Error)]
pub enum ParseError {
    #[error("I/O while reading blocklist at line {line}: {source}")]
    Io { line: usize, source: std::io::Error },
    #[error("invalid CIDR at line {line}: {text:?}: {source}")]
    Invalid {
        line: usize,
        text: String,
        source: ipnet::AddrParseError,
    },
}

pub fn parse(reader: impl BufRead) -> Result<ParsedBlocklist, ParseError> {
    let mut v4 = BTreeSet::new();
    let mut v6 = BTreeSet::new();
    for (index, line) in reader.lines().enumerate() {
        let number = index + 1;
        let line = line.map_err(|source| ParseError::Io {
            line: number,
            source,
        })?;
        let value = line.trim();
        if value.is_empty() || value.starts_with('#') {
            continue;
        }
        match value
            .parse::<IpNet>()
            .map_err(|source| ParseError::Invalid {
                line: number,
                text: value.to_owned(),
                source,
            })? {
            IpNet::V4(net) => {
                v4.insert(net.trunc());
            }
            IpNet::V6(net) => {
                v6.insert(net.trunc());
            }
        }
    }
    Ok(ParsedBlocklist {
        ipv4: v4.into_iter().collect(),
        ipv6: v6.into_iter().collect(),
    })
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Interval<T> {
    pub first: T,
    pub after_last: Option<T>,
}

pub fn ipv4_intervals(nets: &[ipnet::Ipv4Net]) -> Vec<Interval<Ipv4Addr>> {
    nets.iter()
        .map(|net| {
            let first = net.network();
            let last = u32::from(net.broadcast());
            Interval {
                first,
                after_last: last.checked_add(1).map(Ipv4Addr::from),
            }
        })
        .collect()
}

pub fn ipv6_intervals(nets: &[ipnet::Ipv6Net]) -> Vec<Interval<Ipv6Addr>> {
    nets.iter()
        .map(|net| {
            let first = net.network();
            let host_bits = 128 - net.prefix_len();
            let base = u128::from(first);
            let last = if host_bits == 128 {
                u128::MAX
            } else {
                base | ((1u128 << host_bits) - 1)
            };
            Interval {
                first,
                after_last: last.checked_add(1).map(Ipv6Addr::from),
            }
        })
        .collect()
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::io::Cursor;

    #[test]
    fn parses_mixed_lists_and_ignores_comments() {
        let got = parse(Cursor::new(
            "# generated at now\n10.0.0.7/24\n\n2001:db8::1/32\n10.0.0.0/24\n",
        ))
        .unwrap();
        assert_eq!(got.ipv4, vec!["10.0.0.0/24".parse().unwrap()]);
        assert_eq!(got.ipv6, vec!["2001:db8::/32".parse().unwrap()]);
    }

    #[test]
    fn rejects_the_whole_file_on_one_bad_line() {
        let err = parse(Cursor::new("10.0.0.0/8\nnot-an-address\n")).unwrap_err();
        assert!(err.to_string().contains("line 2"));
    }

    #[test]
    fn calculates_interval_markers_including_address_space_end() {
        let nets = vec!["255.255.255.0/24".parse().unwrap()];
        assert_eq!(ipv4_intervals(&nets)[0].after_last, None);
        let nets = vec!["10.0.0.0/24".parse().unwrap()];
        assert_eq!(
            ipv4_intervals(&nets)[0].after_last,
            Some("10.0.1.0".parse().unwrap())
        );
    }
}