use std::fmt;
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
use std::str::FromStr;
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct IpGateDecision {
pub is_whitelisted: bool,
pub is_exempt: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum IpGateDenial {
Blacklisted,
NotInWhitelist,
}
impl IpGateDenial {
#[must_use]
pub const fn reason(self) -> &'static str {
match self {
Self::Blacklisted => "IP is blacklisted",
Self::NotInWhitelist => "IP not in whitelist",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum IpGateVerdict {
Allowed(IpGateDecision),
Denied(IpGateDenial),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Entry {
Exact(IpAddr),
Network(IpNet),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct IpNet {
pub(crate) addr: IpAddr,
pub(crate) prefix: u8,
}
impl IpNet {
pub(crate) fn parse(text: &str) -> Option<Self> {
let (addr_part, prefix_part) = text.split_once('/')?;
let addr = IpAddr::from_str(addr_part).ok()?;
let prefix = u8::from_str(prefix_part).ok()?;
let bits = family_bits(addr);
if prefix > bits {
return None;
}
Some(Self {
addr: masked(addr, prefix),
prefix,
})
}
pub(crate) fn contains(&self, addr: IpAddr) -> bool {
match (self.addr, addr) {
(IpAddr::V4(net), IpAddr::V4(ip)) => {
u32::from(net) == (u32::from(ip) & v4_mask(self.prefix))
}
(IpAddr::V6(net), IpAddr::V6(ip)) => {
u128::from(net) == (u128::from(ip) & v6_mask(self.prefix))
}
_ => false,
}
}
}
fn parse_entry(text: &str) -> Option<Entry> {
if text.contains('/') {
return Some(Entry::Network(IpNet::parse(text)?));
}
Some(Entry::Exact(canonical(IpAddr::from_str(text).ok()?)))
}
pub(crate) fn parse_network_entry(text: &str) -> Option<IpNet> {
match parse_entry(text)? {
Entry::Exact(addr) => Some(IpNet {
addr,
prefix: family_bits(addr),
}),
Entry::Network(net) => Some(net),
}
}
pub(crate) const fn canonical(addr: IpAddr) -> IpAddr {
match addr {
IpAddr::V6(v6) => match v6.to_ipv4_mapped() {
Some(v4) => IpAddr::V4(v4),
None => IpAddr::V6(v6),
},
IpAddr::V4(v4) => IpAddr::V4(v4),
}
}
const fn family_bits(addr: IpAddr) -> u8 {
match addr {
IpAddr::V4(_) => 32,
IpAddr::V6(_) => 128,
}
}
fn masked(addr: IpAddr, prefix: u8) -> IpAddr {
match addr {
IpAddr::V4(v4) => IpAddr::V4(Ipv4Addr::from(u32::from(v4) & v4_mask(prefix))),
IpAddr::V6(v6) => IpAddr::V6(Ipv6Addr::from(u128::from(v6) & v6_mask(prefix))),
}
}
const fn v4_mask(prefix: u8) -> u32 {
if prefix == 0 {
0
} else {
u32::MAX << (32 - prefix)
}
}
const fn v6_mask(prefix: u8) -> u128 {
if prefix == 0 {
0
} else {
u128::MAX << (128 - prefix)
}
}
fn entry_matches(entry: &Entry, addr: IpAddr) -> bool {
match entry {
Entry::Exact(expected) => *expected == canonical(addr),
Entry::Network(net) => net.contains(canonical(addr)) || net.contains(addr),
}
}
fn list_matches(entries: &[Entry], addr: IpAddr) -> bool {
entries.iter().any(|entry| entry_matches(entry, addr))
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct IpGateError {
pub list: &'static str,
pub entry: String,
}
impl fmt::Display for IpGateError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
f,
"invalid {} entry '{}': expected an IP address or CIDR range",
self.list, self.entry
)
}
}
impl std::error::Error for IpGateError {}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct IpGateConfig {
whitelist: Vec<Entry>,
blacklist: Vec<Entry>,
exempt_ips: Vec<Entry>,
}
impl IpGateConfig {
pub fn new<W, B, E>(whitelist: W, blacklist: B, exempt_ips: E) -> Result<Self, IpGateError>
where
W: IntoIterator,
W::Item: AsRef<str>,
B: IntoIterator,
B::Item: AsRef<str>,
E: IntoIterator,
E::Item: AsRef<str>,
{
Ok(Self {
whitelist: parse_list("whitelist", whitelist)?,
blacklist: parse_list("blacklist", blacklist)?,
exempt_ips: parse_list("exempt_ips", exempt_ips)?,
})
}
#[must_use]
pub fn evaluate(&self, ip: IpAddr) -> IpGateVerdict {
if !self.whitelist.is_empty() {
if !list_matches(&self.whitelist, ip) {
return IpGateVerdict::Denied(IpGateDenial::NotInWhitelist);
}
return IpGateVerdict::Allowed(IpGateDecision {
is_whitelisted: true,
is_exempt: list_matches(&self.exempt_ips, ip),
});
}
if !self.blacklist.is_empty() && list_matches(&self.blacklist, ip) {
return IpGateVerdict::Denied(IpGateDenial::Blacklisted);
}
IpGateVerdict::Allowed(IpGateDecision {
is_whitelisted: false,
is_exempt: list_matches(&self.exempt_ips, ip),
})
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RouteIpVerdict {
Unrestricted,
Allowed,
Denied,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct RouteIpGate {
whitelist: Vec<Entry>,
blacklist: Vec<Entry>,
}
impl RouteIpGate {
pub fn new<W, B>(whitelist: W, blacklist: B) -> Result<Self, IpGateError>
where
W: IntoIterator,
W::Item: AsRef<str>,
B: IntoIterator,
B::Item: AsRef<str>,
{
Ok(Self {
whitelist: parse_list("ip_whitelist", whitelist)?,
blacklist: parse_list("ip_blacklist", blacklist)?,
})
}
#[must_use]
pub const fn whitelist_configured(&self) -> bool {
!self.whitelist.is_empty()
}
#[must_use]
pub fn evaluate(&self, ip: IpAddr) -> RouteIpVerdict {
if !self.blacklist.is_empty() && list_matches(&self.blacklist, ip) {
return RouteIpVerdict::Denied;
}
if !self.whitelist.is_empty() {
return if list_matches(&self.whitelist, ip) {
RouteIpVerdict::Allowed
} else {
RouteIpVerdict::Denied
};
}
RouteIpVerdict::Unrestricted
}
}
fn parse_list<I>(list: &'static str, entries: I) -> Result<Vec<Entry>, IpGateError>
where
I: IntoIterator,
I::Item: AsRef<str>,
{
let mut parsed = Vec::new();
for entry in entries {
let entry = entry.as_ref();
match parse_entry(entry) {
Some(parsed_entry) => parsed.push(parsed_entry),
None => {
return Err(IpGateError {
list,
entry: entry.to_owned(),
});
}
}
}
Ok(parsed)
}
#[cfg(test)]
mod tests {
use super::*;
const NIL: [&str; 0] = [];
fn ip(text: &str) -> IpAddr {
IpAddr::from_str(text).expect("test address")
}
#[test]
fn parse_entry_forms() {
assert_eq!(
parse_entry("203.0.113.7"),
Some(Entry::Exact(ip("203.0.113.7")))
);
assert_eq!(
parse_entry("198.51.100.16/28"),
Some(Entry::Network(IpNet {
addr: ip("198.51.100.16"),
prefix: 28
}))
);
assert_eq!(
parse_entry("198.51.100.20/28"),
parse_entry("198.51.100.16/28")
);
assert_eq!(
parse_entry("::ffff:203.0.113.7"),
Some(Entry::Exact(ip("203.0.113.7")))
);
assert_eq!(
parse_entry("::ffff:0:0/96"),
Some(Entry::Network(IpNet {
addr: ip("::ffff:0:0"),
prefix: 96
}))
);
}
#[test]
fn parse_entry_rejects_junk() {
for junk in [
"not-an-ip",
"",
"203.0.113.0/33",
"203.0.113.7/",
"/24",
"::1/129",
] {
assert_eq!(parse_entry(junk), None, "{junk} must not parse");
}
}
#[test]
fn family_never_crosses() {
let net = IpNet::parse("203.0.113.0/24").expect("v4 network");
assert!(net.contains(ip("203.0.113.7")));
assert!(!net.contains(ip("::1")));
let net = IpNet::parse("2001:db8::/32").expect("v6 network");
assert!(net.contains(ip("2001:db8::1")));
assert!(!net.contains(ip("203.0.113.7")));
}
#[test]
fn zero_prefix_networks_match_everything_in_family() {
let net = IpNet::parse("0.0.0.0/0").expect("v4 /0");
assert!(net.contains(ip("203.0.113.7")));
assert!(!net.contains(ip("2001:db8::1")));
let net = IpNet::parse("::/0").expect("v6 /0");
assert!(net.contains(ip("2001:db8::1")));
assert!(!net.contains(ip("203.0.113.7")));
}
#[test]
fn new_reports_the_list_and_entry_of_the_first_reject() {
let error = RouteIpGate::new(["203.0.113.0/33"], NIL).unwrap_err();
assert_eq!(error.list, "ip_whitelist");
assert_eq!(error.entry, "203.0.113.0/33");
let error = RouteIpGate::new(NIL, ["junk"]).unwrap_err();
assert_eq!(error.list, "ip_blacklist");
assert_eq!(error.entry, "junk");
let error = IpGateConfig::new(["203.0.113.7"], ["ok-but-not-an-ip"], NIL).unwrap_err();
assert_eq!(error.list, "blacklist");
assert_eq!(error.entry, "ok-but-not-an-ip");
assert_eq!(
error.to_string(),
"invalid blacklist entry 'ok-but-not-an-ip': expected an IP address or CIDR range"
);
let error = IpGateConfig::new(NIL, NIL, ["203.0.113.0/33"]).unwrap_err();
assert_eq!(error.list, "exempt_ips");
let error = IpGateConfig::new(["::1/129"], NIL, NIL).unwrap_err();
assert_eq!(error.list, "whitelist");
}
#[test]
fn new_accepts_empty_lists_and_the_default_is_inert() {
let gate = IpGateConfig::new(NIL, NIL, NIL).expect("empty lists");
for address in ["203.0.113.7", "2001:db8::1", "::ffff:203.0.113.7"] {
assert_eq!(
gate.evaluate(ip(address)),
IpGateVerdict::Allowed(IpGateDecision::default())
);
}
assert_eq!(
IpGateConfig::default().evaluate(ip("192.0.2.1")),
IpGateVerdict::Allowed(IpGateDecision::default())
);
}
#[test]
fn blacklist_denies_and_exemption_does_not_win() {
let gate =
IpGateConfig::new(NIL, ["203.0.113.9", "198.51.100.0/24"], ["203.0.113.9"]).unwrap();
assert_eq!(
gate.evaluate(ip("203.0.113.9")),
IpGateVerdict::Denied(IpGateDenial::Blacklisted)
);
assert_eq!(
gate.evaluate(ip("198.51.100.77")),
IpGateVerdict::Denied(IpGateDenial::Blacklisted)
);
assert_eq!(
gate.evaluate(ip("203.0.113.9")).unwrap_denied().reason(),
"IP is blacklisted"
);
assert!(matches!(
gate.evaluate(ip("198.51.200.1")),
IpGateVerdict::Allowed(_)
));
}
#[test]
fn exemption_never_opens_the_whitelist_gate() {
let gate = IpGateConfig::new(["192.0.2.1"], NIL, ["203.0.113.7"]).unwrap();
assert_eq!(
gate.evaluate(ip("203.0.113.7")),
IpGateVerdict::Denied(IpGateDenial::NotInWhitelist)
);
assert_eq!(
gate.evaluate(ip("203.0.113.7")).unwrap_denied().reason(),
"IP not in whitelist"
);
assert!(matches!(
gate.evaluate(ip("192.0.2.1")),
IpGateVerdict::Allowed(IpGateDecision {
is_whitelisted: true,
is_exempt: false
})
));
}
#[test]
fn an_ip_on_both_lists_is_simply_a_whitelist_match() {
let gate = IpGateConfig::new(["203.0.113.7"], NIL, ["203.0.113.7"]).unwrap();
assert_eq!(
gate.evaluate(ip("203.0.113.7")),
IpGateVerdict::Allowed(IpGateDecision {
is_whitelisted: true,
is_exempt: true
})
);
}
#[test]
fn exempt_exact_and_cidr_matches_set_the_flag_without_deny_paths() {
let gate = IpGateConfig::new(NIL, NIL, ["198.51.100.7", "198.51.100.16/28"]).unwrap();
for address in ["198.51.100.7", "198.51.100.20", "::ffff:198.51.100.7"] {
assert_eq!(
gate.evaluate(ip(address)),
IpGateVerdict::Allowed(IpGateDecision {
is_whitelisted: false,
is_exempt: true
}),
"{address} must be exempt"
);
}
assert_eq!(
gate.evaluate(ip("198.51.100.15")),
IpGateVerdict::Allowed(IpGateDecision::default()),
"the address just below the CIDR range is not exempt"
);
}
#[test]
fn ipv4_mapped_parity_across_all_list_forms() {
let gate = IpGateConfig::new(["::ffff:203.0.113.7"], NIL, NIL).unwrap();
assert!(matches!(
gate.evaluate(ip("203.0.113.7")),
IpGateVerdict::Allowed(IpGateDecision {
is_whitelisted: true,
..
})
));
let gate = IpGateConfig::new(["203.0.113.0/24"], NIL, NIL).unwrap();
assert!(matches!(
gate.evaluate(ip("::ffff:203.0.113.99")),
IpGateVerdict::Allowed(IpGateDecision {
is_whitelisted: true,
..
})
));
let gate = IpGateConfig::new(["::ffff:0:0/96"], NIL, NIL).unwrap();
assert!(matches!(
gate.evaluate(ip("::ffff:203.0.113.99")),
IpGateVerdict::Allowed(IpGateDecision {
is_whitelisted: true,
..
})
));
assert!(!matches!(
gate.evaluate(ip("2001:db8::1")),
IpGateVerdict::Allowed(IpGateDecision {
is_whitelisted: true,
..
})
));
}
#[test]
fn exempt_flag_is_only_set_after_the_deny_checks_pass() {
let gate = IpGateConfig::new(NIL, ["198.51.100.7"], ["198.51.100.7"]).unwrap();
match gate.evaluate(ip("198.51.100.7")) {
IpGateVerdict::Denied(denial) => assert_eq!(denial, IpGateDenial::Blacklisted),
IpGateVerdict::Allowed(_) => panic!("a blacklisted exempt IP must be denied"),
}
}
impl IpGateVerdict {
fn unwrap_denied(self) -> IpGateDenial {
match self {
Self::Allowed(_) => panic!("expected a denial"),
Self::Denied(denial) => denial,
}
}
}
#[test]
fn route_gate_whitelist_takes_over_the_route_verdict() {
let gate = RouteIpGate::new(["192.0.2.40"], NIL).unwrap();
assert!(gate.whitelist_configured());
assert_eq!(
gate.evaluate(ip("192.0.2.40")),
RouteIpVerdict::Allowed,
"a whitelist member passes the route stage"
);
assert_eq!(
gate.evaluate(ip("192.0.2.11")),
RouteIpVerdict::Denied,
"a miss under a configured whitelist denies"
);
}
#[test]
fn route_gate_blacklist_denies_first() {
let gate = RouteIpGate::new(["192.0.2.40"], ["203.0.113.9", "198.51.100.0/24"]).unwrap();
assert_eq!(
gate.evaluate(ip("203.0.113.9")),
RouteIpVerdict::Denied,
"a blacklist match denies"
);
assert_eq!(
gate.evaluate(ip("198.51.100.7")),
RouteIpVerdict::Denied,
"blacklist beats the whitelist"
);
assert_eq!(
gate.evaluate(ip("192.0.2.40")),
RouteIpVerdict::Allowed,
"the whitelist still passes its members"
);
assert_eq!(
gate.evaluate(ip("192.0.2.11")),
RouteIpVerdict::Denied,
"the whitelist still denies its misses"
);
}
#[test]
fn route_gate_blacklist_only_denies_its_matches() {
let gate = RouteIpGate::new(NIL, ["203.0.113.9"]).unwrap();
assert!(!gate.whitelist_configured());
assert_eq!(gate.evaluate(ip("203.0.113.9")), RouteIpVerdict::Denied);
assert_eq!(
gate.evaluate(ip("192.0.2.11")),
RouteIpVerdict::Unrestricted,
"a non-member is left to the global gate"
);
}
#[test]
fn route_gate_empty_is_unrestricted() {
let gate = RouteIpGate::new(NIL, NIL).unwrap();
assert!(!gate.whitelist_configured());
assert_eq!(
gate.evaluate(ip("192.0.2.11")),
RouteIpVerdict::Unrestricted
);
}
#[test]
fn route_gate_entries_match_like_the_global_gate() {
let gate = RouteIpGate::new(["198.51.100.16/28"], NIL).unwrap();
assert_eq!(gate.evaluate(ip("198.51.100.20")), RouteIpVerdict::Allowed);
assert_eq!(gate.evaluate(ip("198.51.100.32")), RouteIpVerdict::Denied);
let gate = RouteIpGate::new(["203.0.113.7"], NIL).unwrap();
assert_eq!(
gate.evaluate(IpAddr::from_str("::ffff:203.0.113.7").unwrap()),
RouteIpVerdict::Allowed,
"a v4-mapped request matches its IPv4 entry"
);
}
}