use std::fmt;
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Route {
pub dest: Ipv4Net,
pub via: String,
}
impl fmt::Display for Route {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{} via {}", self.dest, self.via)
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Ipv4Net {
pub addr: u32,
pub prefix: u8,
}
impl Ipv4Net {
pub fn new(a: u32, prefix: u8) -> Ipv4Net {
Ipv4Net { addr: a & mask(prefix), prefix }
}
pub fn parse(s: &str, prefix: u8) -> Option<Ipv4Net> {
Some(Ipv4Net::new(parse_v4(s)?, prefix))
}
pub fn contains(&self, ip: &str) -> bool {
parse_v4(ip).is_some_and(|v| v & mask(self.prefix) == self.addr)
}
pub fn gateway(&self) -> String {
render_v4(self.addr | 1)
}
}
impl fmt::Display for Ipv4Net {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}/{}", render_v4(self.addr), self.prefix)
}
}
fn mask(prefix: u8) -> u32 {
if prefix == 0 {
0
} else {
u32::MAX << (32 - prefix.min(32))
}
}
pub fn parse_v4(s: &str) -> Option<u32> {
let mut out: u32 = 0;
let mut n = 0;
for part in s.split('.') {
let b: u8 = part.parse().ok()?;
out = (out << 8) | b as u32;
n += 1;
}
(n == 4).then_some(out)
}
pub fn render_v4(a: u32) -> String {
format!("{}.{}.{}.{}", a >> 24, (a >> 16) & 255, (a >> 8) & 255, a & 255)
}
pub const UTILITY_A: (u32, u8) = (0x0A0D_0800, 22); pub const UTILITY_B: (u32, u8) = (0x0A0D_0C00, 22);
pub fn utility_nets() -> [Ipv4Net; 2] {
[Ipv4Net::new(UTILITY_A.0, UTILITY_A.1), Ipv4Net::new(UTILITY_B.0, UTILITY_B.1)]
}
pub fn net_of(ip: &str) -> Option<Ipv4Net> {
utility_nets().into_iter().find(|n| n.contains(ip))
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct DhcpOffer {
pub address: String,
pub prefix: u8,
pub router: Option<String>,
pub classless_static_routes: Vec<Route>,
}
impl DhcpOffer {
pub fn for_address(ip: &str) -> DhcpOffer {
let own = net_of(ip);
let routes = own
.map(|o| {
utility_nets()
.into_iter()
.filter(|n| *n != o)
.map(|n| Route { dest: n, via: o.gateway() })
.collect()
})
.unwrap_or_default();
DhcpOffer {
address: ip.to_string(),
prefix: own.map(|o| o.prefix).unwrap_or(24),
router: None,
classless_static_routes: routes,
}
}
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum DhcpClient {
ReadsOption121,
IgnoresOption121,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Reach {
Ok,
NoRouteOutbound { dest: String, needed: Route },
NoHairpin { dest: String, port: u16 },
Refused { dest: String, port: u16 },
Dropped { dest: String, port: u16 },
}
impl Reach {
pub fn is_ok(&self) -> bool {
matches!(self, Reach::Ok)
}
pub fn why(&self) -> String {
match self {
Reach::Ok => "ok".into(),
Reach::NoRouteOutbound { dest, needed } => {
format!("no route to {dest}: option 121 offered `{needed}` and this guest did not install it")
}
Reach::NoHairpin { dest, port } => {
format!("connect {dest}:{port}: connection refused (locally-generated traffic does not traverse prerouting)")
}
Reach::Refused { dest, port } => format!("connect {dest}:{port}: connection refused"),
Reach::Dropped { dest, port } => format!("connect {dest}:{port}: timed out (dropped by the firewall; nothing comes back)"),
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Dnat {
pub on_server: String,
pub port: u16,
pub to_address: String,
pub to_port: u16,
}
pub fn inbound_reaches(listening: bool) -> bool {
listening
}
pub fn outbound_reach(
from_ip: &str,
from_public: &str,
from_uuid: &str,
client: DhcpClient,
dest: &str,
port: u16,
dnats: &[Dnat],
listening: impl Fn(&str, u16) -> bool,
) -> Reach {
if dest == from_public {
if dnats.iter().any(|d| d.on_server == from_uuid && d.port == port) {
return Reach::NoHairpin { dest: dest.to_string(), port };
}
if !listening(dest, port) {
return Reach::Refused { dest: dest.to_string(), port };
}
return Reach::Ok;
}
if let (Some(dest_net), Some(own_net)) = (net_of(dest), net_of(from_ip)) {
if dest_net != own_net && client == DhcpClient::IgnoresOption121 {
return Reach::NoRouteOutbound {
dest: dest.to_string(),
needed: Route { dest: dest_net, via: own_net.gateway() },
};
}
}
if listening(dest, port) {
Reach::Ok
} else {
Reach::Refused { dest: dest.to_string(), port }
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_two_utility_prefixes_are_different_slash_22s() {
let [a, b] = utility_nets();
assert_ne!(a, b);
assert!(a.contains("10.13.8.101"));
assert!(a.contains("10.13.11.255"));
assert!(!a.contains("10.13.12.1"));
assert!(b.contains("10.13.12.1"));
assert_eq!(a.gateway(), "10.13.8.1");
assert_eq!(b.gateway(), "10.13.12.1");
assert_eq!(a.to_string(), "10.13.8.0/22");
}
#[test]
fn the_offer_carries_option_121_and_no_router() {
let o = DhcpOffer::for_address("10.13.8.101");
assert_eq!(o.router, None, "no default gateway on the utility network, by design");
assert_eq!(o.classless_static_routes.len(), 1);
assert_eq!(o.classless_static_routes[0].to_string(), "10.13.12.0/22 via 10.13.8.1");
}
#[test]
fn ignoring_option_121_breaks_outbound_and_leaves_inbound_healthy() {
let up = |_: &str, _: u16| true;
let bad = outbound_reach(
"10.13.8.101",
"203.0.113.10",
"appliance",
DhcpClient::IgnoresOption121,
"10.13.12.9",
443,
&[],
up,
);
match &bad {
Reach::NoRouteOutbound { needed, .. } => {
assert_eq!(needed.to_string(), "10.13.12.0/22 via 10.13.8.1");
assert!(bad.why().contains("did not install it"), "{}", bad.why());
}
other => panic!("{other:?}"),
}
assert!(inbound_reaches(true));
let good = outbound_reach(
"10.13.8.101",
"203.0.113.10",
"appliance",
DhcpClient::ReadsOption121,
"10.13.12.9",
443,
&[],
up,
);
assert_eq!(good, Reach::Ok);
}
#[test]
fn the_broken_guest_reaches_its_own_prefix() {
let up = |_: &str, _: u16| true;
assert_eq!(
outbound_reach("10.13.8.101", "1.2.3.4", "a", DhcpClient::IgnoresOption121, "10.13.8.99", 22, &[], up),
Reach::Ok
);
}
#[test]
fn there_is_no_hairpin() {
let dnats = vec![Dnat {
on_server: "front".into(),
port: 2222,
to_address: "10.13.8.101".into(),
to_port: 2222,
}];
let up = |_: &str, _: u16| true;
let r = outbound_reach(
"10.13.12.9",
"203.0.113.10",
"front",
DhcpClient::ReadsOption121,
"203.0.113.10",
2222,
&dnats,
up,
);
assert!(matches!(r, Reach::NoHairpin { .. }), "{r:?}");
assert!(r.why().contains("connection refused"), "the kernel's own words: {}", r.why());
assert!(r.why().contains("prerouting"), "and the reason, so nobody re-diagnoses it: {}", r.why());
let outside = outbound_reach(
"10.13.8.101",
"198.51.100.30",
"someone-else",
DhcpClient::ReadsOption121,
"203.0.113.10",
2222,
&dnats,
up,
);
assert_eq!(outside, Reach::Ok);
}
#[test]
fn a_public_destination_is_not_affected_by_the_missing_route() {
let up = |_: &str, _: u16| true;
assert_eq!(
outbound_reach("10.13.8.101", "1.2.3.4", "a", DhcpClient::IgnoresOption121, "198.51.100.30", 443, &[], up),
Reach::Ok
);
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Admit {
Accept,
Drop,
}
pub fn firewall_admits(
firewall_on: bool,
rules: &[crate::estate::Rule],
proto: &str,
src_ip: &str,
src_port: u16,
dst_port: u16,
) -> Admit {
if !firewall_on || rules.is_empty() {
return Admit::Accept;
}
let in_range = |v: u32, lo: &str, hi: &str| -> bool {
match (lo.trim().parse::<u32>().ok(), hi.trim().parse::<u32>().ok()) {
(None, None) => true,
(Some(l), None) => v == l,
(None, Some(h)) => v == h,
(Some(l), Some(h)) => (l..=h).contains(&v),
}
};
let src = parse_v4(src_ip);
for r in rules.iter().filter(|r| r.direction.is_empty() || r.direction == "in") {
if !r.protocol.is_empty() && !r.protocol.eq_ignore_ascii_case(proto) {
continue;
}
let (lo, hi) = (r.source_address_start.trim(), r.source_address_end.trim());
if !(lo.is_empty() && hi.is_empty()) {
let (Some(s), Some(l)) = (src, parse_v4(if lo.is_empty() { hi } else { lo })) else { continue };
let h = parse_v4(if hi.is_empty() { lo } else { hi }).unwrap_or(l);
if !(l..=h).contains(&s) {
continue;
}
}
let sp_given = !(r.source_port_start.trim().is_empty() && r.source_port_end.trim().is_empty());
if sp_given && (src_port == 0 || !in_range(src_port as u32, &r.source_port_start, &r.source_port_end)) {
continue;
}
let dp_given = !(r.destination_port_start.trim().is_empty() && r.destination_port_end.trim().is_empty());
if dp_given && (dst_port == 0 || !in_range(dst_port as u32, &r.destination_port_start, &r.destination_port_end)) {
continue;
}
return if r.action.eq_ignore_ascii_case("accept") { Admit::Accept } else { Admit::Drop };
}
Admit::Accept
}
#[cfg(test)]
mod firewall_tests {
use super::*;
use crate::estate::Rule;
fn rule(action: &str, proto: &str, src: &str, dport: &str) -> Rule {
Rule {
direction: "in".into(),
action: action.into(),
family: "IPv4".into(),
protocol: proto.into(),
source_address_start: src.into(),
source_address_end: src.into(),
destination_port_start: dport.into(),
destination_port_end: dport.into(),
..Rule::default()
}
}
#[test]
fn no_rules_is_wide_open() {
assert_eq!(firewall_admits(false, &[rule("drop", "", "", "")], "tcp", "1.2.3.4", 0, 22), Admit::Accept);
assert_eq!(firewall_admits(true, &[], "tcp", "1.2.3.4", 0, 22), Admit::Accept);
}
#[test]
fn first_match_wins_and_a_rule_after_the_drop_is_dead() {
let rules = vec![rule("accept", "tcp", "", "22"), rule("drop", "", "", ""), rule("accept", "tcp", "", "80")];
assert_eq!(firewall_admits(true, &rules, "tcp", "1.2.3.4", 0, 22), Admit::Accept);
assert_eq!(firewall_admits(true, &rules, "tcp", "1.2.3.4", 0, 80), Admit::Drop);
}
#[test]
fn the_utility_network_is_filtered_too() {
let rules = vec![rule("accept", "tcp", "10.13.8.99", "50051"), rule("drop", "", "", "")];
assert_eq!(firewall_admits(true, &rules, "tcp", "10.13.8.99", 0, 50051), Admit::Accept);
assert_eq!(firewall_admits(true, &rules, "tcp", "10.13.7.210", 0, 50051), Admit::Drop);
}
}