1use std::fmt;
37
38#[derive(Clone, Debug, PartialEq, Eq)]
41pub struct Route {
42 pub dest: Ipv4Net,
43 pub via: String,
44}
45
46impl fmt::Display for Route {
47 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
48 write!(f, "{} via {}", self.dest, self.via)
49 }
50}
51
52#[derive(Clone, Copy, Debug, PartialEq, Eq)]
55pub struct Ipv4Net {
56 pub addr: u32,
57 pub prefix: u8,
58}
59
60impl Ipv4Net {
61 pub fn new(a: u32, prefix: u8) -> Ipv4Net {
62 Ipv4Net { addr: a & mask(prefix), prefix }
63 }
64
65 pub fn parse(s: &str, prefix: u8) -> Option<Ipv4Net> {
66 Some(Ipv4Net::new(parse_v4(s)?, prefix))
67 }
68
69 pub fn contains(&self, ip: &str) -> bool {
70 parse_v4(ip).is_some_and(|v| v & mask(self.prefix) == self.addr)
71 }
72
73 pub fn gateway(&self) -> String {
75 render_v4(self.addr | 1)
76 }
77}
78
79impl fmt::Display for Ipv4Net {
80 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
81 write!(f, "{}/{}", render_v4(self.addr), self.prefix)
82 }
83}
84
85fn mask(prefix: u8) -> u32 {
86 if prefix == 0 {
87 0
88 } else {
89 u32::MAX << (32 - prefix.min(32))
90 }
91}
92
93pub fn parse_v4(s: &str) -> Option<u32> {
94 let mut out: u32 = 0;
95 let mut n = 0;
96 for part in s.split('.') {
97 let b: u8 = part.parse().ok()?;
98 out = (out << 8) | b as u32;
99 n += 1;
100 }
101 (n == 4).then_some(out)
102}
103
104pub fn render_v4(a: u32) -> String {
105 format!("{}.{}.{}.{}", a >> 24, (a >> 16) & 255, (a >> 8) & 255, a & 255)
106}
107
108pub const UTILITY_A: (u32, u8) = (0x0A0D_0800, 22); pub const UTILITY_B: (u32, u8) = (0x0A0D_0C00, 22); pub fn utility_nets() -> [Ipv4Net; 2] {
118 [Ipv4Net::new(UTILITY_A.0, UTILITY_A.1), Ipv4Net::new(UTILITY_B.0, UTILITY_B.1)]
119}
120
121pub fn net_of(ip: &str) -> Option<Ipv4Net> {
123 utility_nets().into_iter().find(|n| n.contains(ip))
124}
125
126#[derive(Clone, Debug, PartialEq, Eq)]
133pub struct DhcpOffer {
134 pub address: String,
135 pub prefix: u8,
136 pub router: Option<String>,
138 pub classless_static_routes: Vec<Route>,
140}
141
142impl DhcpOffer {
143 pub fn for_address(ip: &str) -> DhcpOffer {
146 let own = net_of(ip);
147 let routes = own
148 .map(|o| {
149 utility_nets()
150 .into_iter()
151 .filter(|n| *n != o)
152 .map(|n| Route { dest: n, via: o.gateway() })
153 .collect()
154 })
155 .unwrap_or_default();
156 DhcpOffer {
157 address: ip.to_string(),
158 prefix: own.map(|o| o.prefix).unwrap_or(24),
159 router: None,
160 classless_static_routes: routes,
161 }
162 }
163}
164
165#[derive(Clone, Copy, PartialEq, Eq, Debug)]
171pub enum DhcpClient {
172 ReadsOption121,
175 IgnoresOption121,
178}
179
180#[derive(Clone, Debug, PartialEq, Eq)]
182pub enum Reach {
183 Ok,
184 NoRouteOutbound { dest: String, needed: Route },
188 NoHairpin { dest: String, port: u16 },
191 Refused { dest: String, port: u16 },
193 Dropped { dest: String, port: u16 },
198}
199
200impl Reach {
201 pub fn is_ok(&self) -> bool {
202 matches!(self, Reach::Ok)
203 }
204
205 pub fn why(&self) -> String {
209 match self {
210 Reach::Ok => "ok".into(),
211 Reach::NoRouteOutbound { dest, needed } => {
212 format!("no route to {dest}: option 121 offered `{needed}` and this guest did not install it")
213 }
214 Reach::NoHairpin { dest, port } => {
215 format!("connect {dest}:{port}: connection refused (locally-generated traffic does not traverse prerouting)")
216 }
217 Reach::Refused { dest, port } => format!("connect {dest}:{port}: connection refused"),
218 Reach::Dropped { dest, port } => format!("connect {dest}:{port}: timed out (dropped by the firewall; nothing comes back)"),
219 }
220 }
221}
222
223#[derive(Clone, Debug, PartialEq, Eq)]
225pub struct Dnat {
226 pub on_server: String,
228 pub port: u16,
229 pub to_address: String,
231 pub to_port: u16,
232}
233
234pub fn inbound_reaches(listening: bool) -> bool {
240 listening
241}
242
243pub fn outbound_reach(
249 from_ip: &str,
250 from_public: &str,
251 from_uuid: &str,
252 client: DhcpClient,
253 dest: &str,
254 port: u16,
255 dnats: &[Dnat],
256 listening: impl Fn(&str, u16) -> bool,
257) -> Reach {
258 if dest == from_public {
262 if dnats.iter().any(|d| d.on_server == from_uuid && d.port == port) {
263 return Reach::NoHairpin { dest: dest.to_string(), port };
264 }
265 if !listening(dest, port) {
266 return Reach::Refused { dest: dest.to_string(), port };
267 }
268 return Reach::Ok;
269 }
270
271 if let (Some(dest_net), Some(own_net)) = (net_of(dest), net_of(from_ip)) {
274 if dest_net != own_net && client == DhcpClient::IgnoresOption121 {
275 return Reach::NoRouteOutbound {
276 dest: dest.to_string(),
277 needed: Route { dest: dest_net, via: own_net.gateway() },
278 };
279 }
280 }
281
282 if listening(dest, port) {
284 Reach::Ok
285 } else {
286 Reach::Refused { dest: dest.to_string(), port }
287 }
288}
289
290#[cfg(test)]
291mod tests {
292 use super::*;
293
294 #[test]
295 fn the_two_utility_prefixes_are_different_slash_22s() {
296 let [a, b] = utility_nets();
297 assert_ne!(a, b);
298 assert!(a.contains("10.13.8.101"));
299 assert!(a.contains("10.13.11.255"));
300 assert!(!a.contains("10.13.12.1"));
301 assert!(b.contains("10.13.12.1"));
302 assert_eq!(a.gateway(), "10.13.8.1");
303 assert_eq!(b.gateway(), "10.13.12.1");
304 assert_eq!(a.to_string(), "10.13.8.0/22");
305 }
306
307 #[test]
310 fn the_offer_carries_option_121_and_no_router() {
311 let o = DhcpOffer::for_address("10.13.8.101");
312 assert_eq!(o.router, None, "no default gateway on the utility network, by design");
313 assert_eq!(o.classless_static_routes.len(), 1);
314 assert_eq!(o.classless_static_routes[0].to_string(), "10.13.12.0/22 via 10.13.8.1");
315 }
316
317 #[test]
321 fn ignoring_option_121_breaks_outbound_and_leaves_inbound_healthy() {
322 let up = |_: &str, _: u16| true;
323 let bad = outbound_reach(
324 "10.13.8.101",
325 "203.0.113.10",
326 "appliance",
327 DhcpClient::IgnoresOption121,
328 "10.13.12.9",
329 443,
330 &[],
331 up,
332 );
333 match &bad {
334 Reach::NoRouteOutbound { needed, .. } => {
335 assert_eq!(needed.to_string(), "10.13.12.0/22 via 10.13.8.1");
336 assert!(bad.why().contains("did not install it"), "{}", bad.why());
337 }
338 other => panic!("{other:?}"),
339 }
340 assert!(inbound_reaches(true));
343
344 let good = outbound_reach(
346 "10.13.8.101",
347 "203.0.113.10",
348 "appliance",
349 DhcpClient::ReadsOption121,
350 "10.13.12.9",
351 443,
352 &[],
353 up,
354 );
355 assert_eq!(good, Reach::Ok);
356 }
357
358 #[test]
361 fn the_broken_guest_reaches_its_own_prefix() {
362 let up = |_: &str, _: u16| true;
363 assert_eq!(
364 outbound_reach("10.13.8.101", "1.2.3.4", "a", DhcpClient::IgnoresOption121, "10.13.8.99", 22, &[], up),
365 Reach::Ok
366 );
367 }
368
369 #[test]
372 fn there_is_no_hairpin() {
373 let dnats = vec![Dnat {
374 on_server: "front".into(),
375 port: 2222,
376 to_address: "10.13.8.101".into(),
377 to_port: 2222,
378 }];
379 let up = |_: &str, _: u16| true;
380 let r = outbound_reach(
382 "10.13.12.9",
383 "203.0.113.10",
384 "front",
385 DhcpClient::ReadsOption121,
386 "203.0.113.10",
387 2222,
388 &dnats,
389 up,
390 );
391 assert!(matches!(r, Reach::NoHairpin { .. }), "{r:?}");
392 assert!(r.why().contains("connection refused"), "the kernel's own words: {}", r.why());
393 assert!(r.why().contains("prerouting"), "and the reason, so nobody re-diagnoses it: {}", r.why());
394
395 let outside = outbound_reach(
397 "10.13.8.101",
398 "198.51.100.30",
399 "someone-else",
400 DhcpClient::ReadsOption121,
401 "203.0.113.10",
402 2222,
403 &dnats,
404 up,
405 );
406 assert_eq!(outside, Reach::Ok);
407 }
408
409 #[test]
412 fn a_public_destination_is_not_affected_by_the_missing_route() {
413 let up = |_: &str, _: u16| true;
414 assert_eq!(
415 outbound_reach("10.13.8.101", "1.2.3.4", "a", DhcpClient::IgnoresOption121, "198.51.100.30", 443, &[], up),
416 Reach::Ok
417 );
418 }
419}
420
421#[derive(Clone, Copy, Debug, PartialEq, Eq)]
425pub enum Admit {
426 Accept,
427 Drop,
428}
429
430pub fn firewall_admits(
450 firewall_on: bool,
451 rules: &[crate::estate::Rule],
452 proto: &str,
453 src_ip: &str,
454 src_port: u16,
455 dst_port: u16,
456) -> Admit {
457 if !firewall_on || rules.is_empty() {
458 return Admit::Accept;
459 }
460 let in_range = |v: u32, lo: &str, hi: &str| -> bool {
461 match (lo.trim().parse::<u32>().ok(), hi.trim().parse::<u32>().ok()) {
462 (None, None) => true,
463 (Some(l), None) => v == l,
464 (None, Some(h)) => v == h,
465 (Some(l), Some(h)) => (l..=h).contains(&v),
466 }
467 };
468 let src = parse_v4(src_ip);
469 for r in rules.iter().filter(|r| r.direction.is_empty() || r.direction == "in") {
470 if !r.protocol.is_empty() && !r.protocol.eq_ignore_ascii_case(proto) {
471 continue;
472 }
473 let (lo, hi) = (r.source_address_start.trim(), r.source_address_end.trim());
474 if !(lo.is_empty() && hi.is_empty()) {
475 let (Some(s), Some(l)) = (src, parse_v4(if lo.is_empty() { hi } else { lo })) else { continue };
476 let h = parse_v4(if hi.is_empty() { lo } else { hi }).unwrap_or(l);
477 if !(l..=h).contains(&s) {
478 continue;
479 }
480 }
481 let sp_given = !(r.source_port_start.trim().is_empty() && r.source_port_end.trim().is_empty());
482 if sp_given && (src_port == 0 || !in_range(src_port as u32, &r.source_port_start, &r.source_port_end)) {
483 continue;
484 }
485 let dp_given = !(r.destination_port_start.trim().is_empty() && r.destination_port_end.trim().is_empty());
486 if dp_given && (dst_port == 0 || !in_range(dst_port as u32, &r.destination_port_start, &r.destination_port_end)) {
487 continue;
488 }
489 return if r.action.eq_ignore_ascii_case("accept") { Admit::Accept } else { Admit::Drop };
490 }
491 Admit::Accept
492}
493
494#[cfg(test)]
495mod firewall_tests {
496 use super::*;
497 use crate::estate::Rule;
498
499 fn rule(action: &str, proto: &str, src: &str, dport: &str) -> Rule {
500 Rule {
501 direction: "in".into(),
502 action: action.into(),
503 family: "IPv4".into(),
504 protocol: proto.into(),
505 source_address_start: src.into(),
506 source_address_end: src.into(),
507 destination_port_start: dport.into(),
508 destination_port_end: dport.into(),
509 ..Rule::default()
510 }
511 }
512
513 #[test]
515 fn no_rules_is_wide_open() {
516 assert_eq!(firewall_admits(false, &[rule("drop", "", "", "")], "tcp", "1.2.3.4", 0, 22), Admit::Accept);
517 assert_eq!(firewall_admits(true, &[], "tcp", "1.2.3.4", 0, 22), Admit::Accept);
518 }
519
520 #[test]
522 fn first_match_wins_and_a_rule_after_the_drop_is_dead() {
523 let rules = vec![rule("accept", "tcp", "", "22"), rule("drop", "", "", ""), rule("accept", "tcp", "", "80")];
524 assert_eq!(firewall_admits(true, &rules, "tcp", "1.2.3.4", 0, 22), Admit::Accept);
525 assert_eq!(firewall_admits(true, &rules, "tcp", "1.2.3.4", 0, 80), Admit::Drop);
526 }
527
528 #[test]
530 fn the_utility_network_is_filtered_too() {
531 let rules = vec![rule("accept", "tcp", "10.13.8.99", "50051"), rule("drop", "", "", "")];
532 assert_eq!(firewall_admits(true, &rules, "tcp", "10.13.8.99", 0, 50051), Admit::Accept);
533 assert_eq!(firewall_admits(true, &rules, "tcp", "10.13.7.210", 0, 50051), Admit::Drop);
534 }
535}