micromegas_auth/ip_allowlist.rs
1//! A typed, allowlist-only IP allowlist: a set of CIDR ranges (or bare IPs, treated as a `/32` or
2//! `/128`) a credential may be presented from. An empty allowlist means "no restriction" -- the
3//! backward-compatible default for every key minted before this feature existed.
4
5use anyhow::{Context, Result};
6use ipnet::IpNet;
7use std::net::IpAddr;
8
9/// A set of CIDR ranges (or bare IPs) a credential may be used from.
10///
11/// Empty (the `Default`/`parse(&[])` result) means "no restriction": [`Self::allows`] always
12/// returns `true`. Once non-empty, an unresolved client IP (`None`) never satisfies the
13/// restriction -- "unknown" must never be treated as "allowed".
14#[derive(Debug, Clone, PartialEq, Eq)]
15pub struct IpAllowlist(Vec<IpNet>);
16
17impl IpAllowlist {
18 /// Parses `entries` (CIDR notation, e.g. `"10.0.0.0/8"`, or a bare IP, e.g.
19 /// `"203.0.113.7"`) into an [`IpAllowlist`]. An empty slice parses to an empty (unrestricted)
20 /// allowlist. Fails on the first entry that is neither a valid CIDR nor a valid bare IP
21 /// address.
22 ///
23 /// `IpNet::from_str` doesn't accept a bare IP without a prefix, so this falls back to
24 /// parsing a bare `IpAddr` and widening it to a `/32`/`/128` via `IpNet::from`.
25 ///
26 /// Also rejects a CIDR whose address has host bits set (e.g. `10.0.0.5/8`): `ipnet` parses
27 /// it successfully but silently widens the range to the whole `/8`, which is the opposite of
28 /// what an operator narrowing an allowlist intends.
29 pub fn parse(entries: &[String]) -> Result<Self> {
30 let mut nets = Vec::with_capacity(entries.len());
31 for entry in entries {
32 let net = entry
33 .parse::<IpNet>()
34 .or_else(|_| entry.parse::<IpAddr>().map(IpNet::from))
35 .with_context(|| format!("invalid CIDR or IP address: {entry:?}"))?;
36 let net = canonicalize(net);
37 if net.addr() != net.network() {
38 anyhow::bail!(
39 "CIDR entry {entry:?} has host bits set; did you mean {:?}?",
40 net.trunc().to_string()
41 );
42 }
43 nets.push(net);
44 }
45 Ok(Self(nets))
46 }
47
48 /// `true` when `ip` is permitted by this allowlist: always `true` when the allowlist is
49 /// empty (no restriction); otherwise `true` only when `ip` is `Some` and falls within at
50 /// least one stored network. An unresolved client IP (`None`) never satisfies a non-empty
51 /// allowlist.
52 pub fn allows(&self, ip: Option<IpAddr>) -> bool {
53 if self.0.is_empty() {
54 return true;
55 }
56 match ip {
57 Some(ip) => self.0.iter().any(|net| net.contains(&ip)),
58 None => false,
59 }
60 }
61}
62
63/// Downgrades an IPv4-mapped IPv6 entry (e.g. `::ffff:203.0.113.7/128`, `::ffff:203.0.113.0/120`,
64/// or a bare `::ffff:203.0.113.7`) to its IPv4 form. `resolve_client_ip` always canonicalizes the
65/// client IP it checks against (`to_canonical()`), so an unnormalized V6-mapped entry here
66/// would never match: `ipnet`'s `contains` treats a V6 net and a V4 address as different
67/// families regardless of value. The mapped prefix `::ffff:0:0/96` is exactly the fixed 96 bits
68/// ahead of the embedded v4 address, so any prefix `>= 96` has a well-defined v4 equivalent at
69/// `prefix_len - 96`.
70fn canonicalize(net: IpNet) -> IpNet {
71 if net.prefix_len() >= 96 {
72 let canonical = net.addr().to_canonical();
73 if canonical.is_ipv4() {
74 return IpNet::new(canonical, net.prefix_len() - 96)
75 .expect("a v4 address always accepts a prefix in 0..=32");
76 }
77 }
78 net
79}
80
81impl Default for IpAllowlist {
82 /// The empty, unrestricted allowlist.
83 fn default() -> Self {
84 Self(Vec::new())
85 }
86}