Skip to main content

mock_upcloud/
net.rs

1//! **The network, and the two ways it is asymmetric.**
2//!
3//! Both of these were diagnosed as something else first, and both cost real
4//! time, because they break traffic in ONE direction while leaving the other
5//! looking healthy. A mock that gives full connectivity or no connectivity
6//! cannot reproduce either, and a test written against such a mock proves
7//! nothing about the estate it is meant to protect.
8//!
9//! # 1. DHCP option 121, and the guest that does not read it
10//!
11//! The appliance's utility NIC gets its address by DHCP. The front is on a
12//! **different /22**. The route between them arrives only as **classless static
13//! routes — DHCP option 121** — and not as a default gateway.
14//!
15//! A guest whose DHCP client ignores option 121 therefore comes up with a
16//! perfectly good address, answers inbound traffic fine, and **cannot reach the
17//! front at all**. Its clock sync and its boot narration die OUTBOUND while
18//! every inbound probe says the box is healthy. It reads exactly like a
19//! two-hour clock bug and is not one — that is how it took an afternoon.
20//! Measured, and fixed in gunnar `35ac0c3`.
21//!
22//! The asymmetry IS the behaviour. [`Reach::NoRouteOutbound`] is returned for
23//! the guest's own outbound traffic while [`inbound_reaches`] stays `true`.
24//!
25//! # 2. Hairpin NAT does not exist
26//!
27//! The front DNATs `:2222` to the appliance's utility address. From OUTSIDE,
28//! `git.gunnar.rs:2222` works. **From the front itself, to its own public
29//! address, it is `connection refused`** — locally-generated traffic never
30//! traverses `prerouting`, so the DNAT it would need is never applied. A
31//! healthy forge was diagnosed as broken on exactly this.
32//!
33//! So a reachability question here always names WHO is asking. There is no such
34//! thing as "is `git.gunnar.rs:2222` up"; there is only "is it up from here".
35
36use std::fmt;
37
38/// One classless static route, as DHCP option 121 carries it:
39/// destination prefix → gateway.
40#[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/// An IPv4 prefix. Small, because the whole need is "are these two addresses in
53/// the same /22", and a CIDR crate is a dependency for one comparison.
54#[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    /// The `.1` of the prefix: what a DHCP offer names as the gateway.
74    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
108/// **The two /22s the estate's utility network is laid across.**
109///
110/// The appliance and the front land in different ones — that is what makes
111/// option 121 load-bearing rather than decorative. If every server shared a
112/// prefix the guest that ignores the option would work perfectly and the bug
113/// would be invisible.
114pub const UTILITY_A: (u32, u8) = (0x0A0D_0800, 22); // 10.13.8.0/22
115pub const UTILITY_B: (u32, u8) = (0x0A0D_0C00, 22); // 10.13.12.0/22
116
117pub fn utility_nets() -> [Ipv4Net; 2] {
118    [Ipv4Net::new(UTILITY_A.0, UTILITY_A.1), Ipv4Net::new(UTILITY_B.0, UTILITY_B.1)]
119}
120
121/// The net an address belongs to, if it is one of the estate's utility nets.
122pub fn net_of(ip: &str) -> Option<Ipv4Net> {
123    utility_nets().into_iter().find(|n| n.contains(ip))
124}
125
126/// **What the DHCP server offers on the utility NIC.**
127///
128/// Note what is NOT here: `router` is `None`. The utility network has **no
129/// default gateway** — the only way off your own /22 is the classless static
130/// routes, and that is precisely why ignoring them is fatal and looks like
131/// something else.
132#[derive(Clone, Debug, PartialEq, Eq)]
133pub struct DhcpOffer {
134    pub address: String,
135    pub prefix: u8,
136    /// Option 3. Absent on the utility network.
137    pub router: Option<String>,
138    /// **Option 121.** The routes to every other utility prefix.
139    pub classless_static_routes: Vec<Route>,
140}
141
142impl DhcpOffer {
143    /// The offer a server on `ip` receives: its own prefix, no default gateway,
144    /// and a route to every OTHER utility prefix via its own gateway.
145    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/// Whether a guest's DHCP client reads option 121.
166///
167/// This is the whole bug, as a two-variant enum — the same shape as
168/// [`crate::guest_clock::RtcInterpretation`], and for the same reason: the
169/// provider offers the right thing and the GUEST does or does not take it.
170#[derive(Clone, Copy, PartialEq, Eq, Debug)]
171pub enum DhcpClient {
172    /// Reads option 121 and installs the routes. The fixed appliance, and every
173    /// ordinary distro.
174    ReadsOption121,
175    /// Takes the address, ignores the routes, and has no way off its own /22.
176    /// **The appliance before gunnar `35ac0c3`.**
177    IgnoresOption121,
178}
179
180/// The answer to "can THIS box reach THAT address, from where it is standing".
181#[derive(Clone, Debug, PartialEq, Eq)]
182pub enum Reach {
183    Ok,
184    /// **The packet had nowhere to go.** The destination is off this guest's own
185    /// prefix and it never installed the route that would have taken it there.
186    /// Inbound to this same guest still works — see [`inbound_reaches`].
187    NoRouteOutbound { dest: String, needed: Route },
188    /// **Locally-generated traffic does not traverse `prerouting`.** This box
189    /// DNATs that port on that address for everyone else, and not for itself.
190    NoHairpin { dest: String, port: u16 },
191    /// Nothing is listening.
192    Refused { dest: String, port: u16 },
193    /// **Behaviour 62: the firewall dropped it.** Silence until the caller's
194    /// own timeout — NOT a refusal. MEASURED 2026-09-20: the twin's :2222 was
195    /// refused from one host (admitted, nothing listening) and timed out from
196    /// the other (dropped), and the timeout read as "slow".
197    Dropped { dest: String, port: u16 },
198}
199
200impl Reach {
201    pub fn is_ok(&self) -> bool {
202        matches!(self, Reach::Ok)
203    }
204
205    /// The sentence a caller sees. `NoHairpin` deliberately reads
206    /// `connection refused`, because that is what the kernel actually returns
207    /// and what sent somebody looking at a healthy forge.
208    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/// One destination-NAT rule, as the front holds for the forge.
224#[derive(Clone, Debug, PartialEq, Eq)]
225pub struct Dnat {
226    /// The server whose PUBLIC address carries the rule.
227    pub on_server: String,
228    pub port: u16,
229    /// The address it is forwarded to.
230    pub to_address: String,
231    pub to_port: u16,
232}
233
234/// **Inbound always works**, whatever is wrong with the guest's routing table.
235///
236/// It is its own function, with no arguments about routes, so the asymmetry is
237/// visible in the API and not just in the behaviour: a caller cannot ask one
238/// question and get both answers, because there is no one answer.
239pub fn inbound_reaches(listening: bool) -> bool {
240    listening
241}
242
243/// Can `from` reach `dest:port`?
244///
245/// `from_ip` is the asking box's own utility address, `from_public` its public
246/// one, `client` what its DHCP client does with option 121, and `dnats` every
247/// rule in the estate.
248pub 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    // (1) Hairpin, first, because it fires even when the routing is perfect —
259    // and because the box asking is the box that holds the rule, so every
260    // route-based explanation looks fine.
261    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    // (2) Routing. Only the utility network needs option 121; a public address
272    // is reached over the public interface, which does have a default route.
273    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    // (3) Is anything there.
283    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    /// **The offer has no default gateway.** That is why option 121 is
308    /// load-bearing: there is no second way off the prefix.
309    #[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    /// **The whole afternoon, in one test.** A guest that ignores option 121
318    /// cannot reach the other /22 — and inbound to that same guest is fine, so
319    /// every health check says the box is up.
320    #[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        // And the same guest answers everything sent TO it. That is the
341        // asymmetry, and it is why this looked like a clock bug.
342        assert!(inbound_reaches(true));
343
344        // Same box, same address, a client that reads the option: fine.
345        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    /// Within its OWN /22 the broken guest is fine, which is the other half of
359    /// why it is hard to see.
360    #[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    /// **From outside the front, the forge answers. From the front itself, it
370    /// does not.** Same address, same port, same rule.
371    #[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        // The front, to its own public address.
381        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        // Anybody else, to the same address and port: through.
396        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    /// A public destination needs no option 121 — the public NIC has a default
410    /// route. A mock that broke ALL outbound traffic would be a different bug.
411    #[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// ── behaviour 62: the firewall, evaluated ────────────────────────────────────
422
423/// What the provider's firewall does with one inbound packet.
424#[derive(Clone, Copy, Debug, PartialEq, Eq)]
425pub enum Admit {
426    Accept,
427    Drop,
428}
429
430/// **Behaviour 62.** UpCloud's firewall on one inbound packet, as this estate
431/// has measured and relied on it:
432///
433/// * `firewall` off, or on with NO rules: wide open (private-holger-ops
434///   `terraform_gate.rs` — "a server with no rule set is wide open, so every
435///   rule set ends in a catch-all drop").
436/// * Rules are evaluated top-down and the FIRST match decides (private-gunnar-ops
437///   `reimage.rs`: a rule placed after the drop never matches).
438/// * No rule matching is an accept — the same fact as the first bullet.
439/// * It filters the UTILITY interface too (MEASURED 2026-09-13: :50051 from
440///   the front was dropped until a rule named the front's utility address).
441/// * It is stateless for UDP: the REPLY to an outbound DNS/NTP query arrives as
442///   an inbound packet from port 53/123 and meets the catch-all drop (MEASURED
443///   2026-09-14: `dig +tcp` answers, `+notcp` times out). TCP replies pass,
444///   which [`crate::estate::Estate::udp_reply_arrives`] does not need to model
445///   because only UDP is asked.
446///
447/// Only `direction == "in"` rules are read. `src_port`/`dst_port` 0 means "not
448/// given", and a rule that constrains a port that was not given does not match.
449pub 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    /// Off, or on with nothing written: wide open.
514    #[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    /// Top-down, first match; a rule after the catch-all never matches.
521    #[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    /// The utility network is filtered like any other source.
529    #[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}