cellos-supervisor 0.5.1

CellOS execution-cell runner — boots cells in Firecracker microVMs or gVisor, enforces narrow typed authority, emits signed CloudEvents.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
//! Network-policy free helpers split out from `supervisor.rs` (P0-2).
//!
//! Hosts:
//! - `generate_nft_ruleset` — pure-string nftables ruleset generator (Linux).
//! - `apply_nft_in_ns` — best-effort `nsenter ... nft -f -` application (Linux).
//! - `scan_nft_counters_in_ns` — FC-38 Phase 1 post-run counter scrape (Linux).
//! - `pick_resolver_socket_addr` — SEC-21 upstream resolver selector.
//! - `parse_do53_endpoint` — `do53-udp` / `do53-tcp` endpoint parser.
//! - `parse_udp_resolver_endpoint` — SEC-22 Phase 3d UDP resolver parser (Linux).
//! - `egress_destination_matches_declared_resolver` — DNS egress matching predicate.
//! - `read_proc_caps` — `/proc/self/status` capability bitmask reader (Linux).
//!
//! Pure code-move from `supervisor.rs`. No logic changes.

#[cfg(target_os = "linux")]
use cellos_core::EgressRule;

/// Match an `EgressRule` (port 53) destination against the declared `do53-*`
/// resolvers in `dnsAuthority.resolvers[]`.
///
/// Returns `true` when at least one declared resolver's `host:port` parses as
/// `do53-udp` / `do53-tcp` and equals the egress rule's `host` + `53`. Returns
/// `false` for `dot` / `doh` / `doq` resolvers (those carry URL-form
/// endpoints, not `host:port`, and Phase 1 punts SNI/host enforcement for
/// those to the proxy-side Phase 2 work).
pub(crate) fn egress_destination_matches_declared_resolver(
    rule_host: &str,
    rule_port: u16,
    resolvers: &[cellos_core::DnsResolver],
) -> bool {
    resolvers.iter().any(|resolver| {
        parse_do53_endpoint(resolver)
            .is_some_and(|(host, port)| host == rule_host && port == rule_port)
    })
}

/// Generate an nftables ruleset for a cell's network namespace.
///
/// Policy: drop all non-loopback output; accept declared egress targets.
/// Host values must be IP addresses — hostname-based rules require pre-resolution (future work).
/// Applied via [`apply_nft_in_ns`] as a best-effort supplementary layer on top of `CLONE_NEWNET`.
///
/// SEC-22 extension: when `dns_authority` is `Some` and
/// `block_direct_workload_dns == true`, an additional resolver-aware pass is
/// inserted at the top of the chain (above the existing egress-accept lines):
///
/// 1. For each declared `do53-udp` / `do53-tcp` resolver in
///    `dns_authority.resolvers[]`, emit an explicit `accept` for that resolver's
///    `host:port` so workload traffic to a declared resolver still passes.
/// 2. Drop **all other** port-53 traffic (`udp`/`tcp`), even if a generic
///    `egressRules[]` entry on port 53 would otherwise have allowed it.
///
/// This is the kernel-level backstop for SEC-22 Phase 1. Hostname / SNI
/// enforcement (DoH, DoQ, fronting) is documented as residual risk; the
/// observability companion is `maybe_emit_l7_gate_observability` in
/// `supervisor.rs`.
///
/// SEC-22 Phase 3d extension: when `block_udp_doq` is true, an analogous pass
/// emits `accept` lines for each declared UDP-transport resolver
/// (`do53-udp` / `doh` / `doq`) on its declared port, then drops `udp dport
/// 853` (DoQ default port) for everything else. When `block_udp_http3` is
/// true, the same resolver-allowlist pass is performed (deduped against the
/// DoQ pass when both are set) and `udp dport 443` is dropped for everything
/// else. Both flags are independent of `block_direct_workload_dns` and may be
/// combined with it. Phase 3d is **kernel-only**: it does not parse QUIC
/// ClientHello, so a workload that fronts traffic via QUIC to a port the
/// drops do not cover (e.g. a custom UDP port) is still residual — that is
/// follow-up work (`docs/sec22-residual-risk.md`).
#[cfg(target_os = "linux")]
pub(crate) fn generate_nft_ruleset(
    cell_id: &str,
    egress_rules: &[EgressRule],
    dns_authority: Option<&cellos_core::DnsAuthority>,
) -> String {
    let raw = cellos_core::sanitize_cgroup_leaf_segment(cell_id);
    let safe_id = if raw.len() > 32 {
        &raw[..32]
    } else {
        raw.as_str()
    };
    let table = format!("cellos_{safe_id}");
    let mut lines = vec![
        format!("table inet {table} {{"),
        "  chain output {".to_string(),
        "    type filter hook output priority 0; policy drop;".to_string(),
        "    oif \"lo\" accept".to_string(),
    ];

    // SEC-22 Phase 1: resolver-aware port-53 backstop.
    //
    // Inserted BEFORE the generic egress-accept lines so a `drop` here takes
    // precedence over a downstream `accept` that happens to match port 53.
    // Only do53 resolver endpoints are parsed in Phase 1 — DoT / DoH / DoQ
    // resolvers run on TLS/HTTPS/QUIC ports and the kernel-level enforcement
    // for those is the existing default-DROP plus the operator's declared
    // egress rules. Hostname-aware enforcement for those protocols is the
    // SEC-22 Phase 2 proxy-side work (see docs/sec22-residual-risk.md).
    if let Some(dns_auth) = dns_authority {
        if dns_auth.block_direct_workload_dns {
            for resolver in &dns_auth.resolvers {
                if let Some((ip, port)) = parse_do53_endpoint(resolver) {
                    let nft_proto = match resolver.protocol {
                        cellos_core::DnsResolverProtocol::Do53Udp => "udp",
                        cellos_core::DnsResolverProtocol::Do53Tcp => "tcp",
                        _ => continue,
                    };
                    let fam = if ip.contains(':') { "ip6" } else { "ip" };
                    lines.push(format!(
                        "    {fam} daddr {ip} {nft_proto} dport {port} accept"
                    ));
                }
            }
            // After resolver-allowlist, drop everything else on port 53 (both
            // udp and tcp, both v4 and v6). This drop sits above the generic
            // egress-accept rules so a stray `egressRules[]` entry pointing at
            // an external resolver gets killed at the kernel level rather than
            // silently allowed.
            lines.push("    udp dport 53 drop".to_string());
            lines.push("    tcp dport 53 drop".to_string());
        }

        // SEC-22 Phase 3d: kernel-level UDP/853 (DoQ) + UDP/443 (HTTP/3) drop.
        //
        // For each declared resolver that runs over UDP transport
        // (`do53-udp`, `doh`, `doq`), emit one explicit accept line for its
        // `<ip> udp dport <port>` shape, then drop the protocol's default
        // port for everything else. The accept lines are deduped between the
        // two flags so a single resolver doesn't appear twice when an
        // operator opts into both DoQ + HTTP/3 blocking.
        //
        // Honest scope: this is kernel-only. We are NOT parsing the QUIC
        // ClientHello — a workload pointing UDP/443 traffic at a non-resolver
        // IP is dropped at the netfilter hook before any QUIC handshake
        // bytes are exchanged. SNI-aware QUIC inspection (the parallel of
        // the existing TLS sni_proxy) requires QUIC-Initial decryption and
        // header protection, which is Phase 4.
        if dns_auth.block_udp_doq || dns_auth.block_udp_http3 {
            let mut udp_resolver_accepts: Vec<String> = Vec::new();
            for resolver in &dns_auth.resolvers {
                if let Some((ip, port)) = parse_udp_resolver_endpoint(resolver) {
                    let fam = if ip.contains(':') { "ip6" } else { "ip" };
                    let line = format!("    {fam} daddr {ip} udp dport {port} accept");
                    if !udp_resolver_accepts.contains(&line) {
                        udp_resolver_accepts.push(line);
                    }
                }
            }
            for accept_line in udp_resolver_accepts {
                lines.push(accept_line);
            }
            if dns_auth.block_udp_doq {
                lines.push("    udp dport 853 drop".to_string());
            }
            if dns_auth.block_udp_http3 {
                lines.push("    udp dport 443 drop".to_string());
            }
        }
    }

    for rule in egress_rules {
        let proto = rule.protocol.as_deref().unwrap_or("tcp");
        lines.push(format!(
            "    ip daddr {} {} dport {} accept",
            rule.host, proto, rule.port
        ));
        lines.push(format!(
            "    ip6 daddr {} {} dport {} accept",
            rule.host, proto, rule.port
        ));
    }
    lines.push("  }".to_string());
    lines.push("}".to_string());
    lines.join("\n")
}

/// SEC-21 Phase 3 — pick the upstream nameserver address the
/// hickory-resolver-backed refresh path should query.
///
/// Strategy:
///
/// 1. Walk `resolvers` in declaration order; for the first `do53-udp` /
///    `do53-tcp` resolver whose `host:port` parses to a `SocketAddr`
///    (literal IPv4/IPv6 with `parse_do53_endpoint`), return it.
/// 2. Fall back to `127.0.0.53:53` — the systemd-resolved stub on Linux,
///    which forwards to whatever nameservers the OS picked. On macOS / BSD
///    this address is unlikely to respond; the resolver call surfaces
///    `io::ErrorKind::Other`, the ticker treats it as a transient failure,
///    and the operator gets a clean "no drift events" signal rather than a
///    panic.
///
/// **Why not `from_system_conf`?** hickory-resolver 0.24's
/// `TokioAsyncResolver::from_system_conf` uses `/etc/resolv.conf`-style
/// parsing on Unix, but the dataplane refresh path needs to query the
/// *spec-declared* resolver (the one whose `resolverId` we stamp into
/// `dns_authority_drift` events) — not the host's default. The 127.0.0.53
/// fallback is deliberately a "no spec-declared resolver" diagnostic
/// signal: an operator who cares about real TTL will declare a do53
/// resolver explicitly.
pub(crate) fn pick_resolver_socket_addr(
    resolvers: &[cellos_core::DnsResolver],
) -> std::net::SocketAddr {
    use std::net::{IpAddr, Ipv4Addr, SocketAddr};
    for resolver in resolvers {
        if let Some((host, port)) = parse_do53_endpoint(resolver) {
            if let Ok(ip) = host.parse::<IpAddr>() {
                return SocketAddr::new(ip, port);
            }
        }
    }
    SocketAddr::new(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 53)), 53)
}

/// Parse a `do53-*` resolver `host:port` endpoint, returning `(host, port)`.
///
/// scope (SEC-22): only do53 resolvers are consumed for kernel-level
/// enforcement. `dot` / `doh` / `doq` resolvers carry URL-form endpoints
/// whose hostname resolution is dataplane-side work; this helper returns
/// `None` for them.
///
/// `host` may be a literal IPv4/IPv6 address. Bracketed `[::1]:53` form is
/// handled. Returns `None` if the endpoint is unparseable or the protocol is
/// not a `do53-*` variant.
///
/// Cross-platform: pure parsing, no Linux-specific calls. The non-linux
/// `egress_destination_matches_declared_resolver` observability path
/// reuses this helper.
pub(crate) fn parse_do53_endpoint(resolver: &cellos_core::DnsResolver) -> Option<(String, u16)> {
    use cellos_core::DnsResolverProtocol;
    match resolver.protocol {
        DnsResolverProtocol::Do53Udp | DnsResolverProtocol::Do53Tcp => {}
        _ => return None,
    }
    let endpoint = resolver.endpoint.trim();
    // Bracketed IPv6 literal: `[fe80::1]:53`.
    if let Some(stripped) = endpoint.strip_prefix('[') {
        let close = stripped.find(']')?;
        let host = &stripped[..close];
        let rest = &stripped[close + 1..];
        let port_str = rest.strip_prefix(':')?;
        let port: u16 = port_str.parse().ok()?;
        return Some((host.to_string(), port));
    }
    // `host:port` form — split at the LAST colon to tolerate IPv4 / hostnames.
    // (Bare unbracketed IPv6 is ambiguous and not accepted here; operators
    // must bracket IPv6 endpoints to make their intent unambiguous, matching
    // common dataplane practice.)
    let (host, port_str) = endpoint.rsplit_once(':')?;
    let port: u16 = port_str.parse().ok()?;
    if host.is_empty() {
        return None;
    }
    Some((host.to_string(), port))
}

/// SEC-22 Phase 3d — parse the `host:port` endpoint of any UDP-transport
/// resolver (`do53-udp`, `doh`, `doq`). DoT is TCP-only and is rejected.
///
/// `do53-udp` carries a literal `host:port`; `doh` carries an `https://...`
/// URL with optional `:port`; `doq` carries a `quic://host:port` (or bare
/// `host:port`) form. We accept any of these as long as the trimmed endpoint
/// resolves to a literal IP plus port we can write into nft. Hostname-only
/// endpoints (`https://dns.example.com/dns-query` with no port) return
/// `None` — Phase 3d is kernel-only and cannot resolve hostnames; operators
/// who declare a hostname-only resolver get a no-op accept-leg (the kernel
/// drop still applies and the workload is blocked, which is the safer
/// failure mode than silently allowing).
///
/// IPv6 endpoints must be bracketed (`[2001:db8::1]:443`) to be parseable,
/// matching `parse_do53_endpoint` convention.
#[cfg(target_os = "linux")]
pub(crate) fn parse_udp_resolver_endpoint(
    resolver: &cellos_core::DnsResolver,
) -> Option<(String, u16)> {
    use cellos_core::DnsResolverProtocol;
    match resolver.protocol {
        DnsResolverProtocol::Do53Udp | DnsResolverProtocol::Doh | DnsResolverProtocol::Doq => {}
        DnsResolverProtocol::Do53Tcp | DnsResolverProtocol::Dot => return None,
    }
    let endpoint = resolver.endpoint.trim();
    // Strip a known scheme prefix if present so `https://1.1.1.1:443/dns-query`
    // and `quic://1.1.1.1:853` both reduce to a `host:port[/...]` shape we
    // can parse with the same path as `parse_do53_endpoint`.
    let stripped_scheme = endpoint
        .strip_prefix("https://")
        .or_else(|| endpoint.strip_prefix("quic://"))
        .or_else(|| endpoint.strip_prefix("h3://"))
        .unwrap_or(endpoint);
    // Trim a trailing path component (`/dns-query`) so only `host:port` is
    // left for the parser below.
    let host_port = match stripped_scheme.find('/') {
        Some(idx) => &stripped_scheme[..idx],
        None => stripped_scheme,
    };
    // Bracketed IPv6 literal: `[fe80::1]:443`.
    if let Some(after_open) = host_port.strip_prefix('[') {
        let close = after_open.find(']')?;
        let host = &after_open[..close];
        let rest = &after_open[close + 1..];
        let port_str = rest.strip_prefix(':')?;
        let port: u16 = port_str.parse().ok()?;
        return Some((host.to_string(), port));
    }
    let (host, port_str) = host_port.rsplit_once(':')?;
    let port: u16 = port_str.parse().ok()?;
    if host.is_empty() {
        return None;
    }
    Some((host.to_string(), port))
}

/// Apply an nftables ruleset inside the child's network namespace via `nsenter`.
///
/// Best-effort: logs a warning if `nsenter`/`nft` is unavailable or fails.
/// The child's `/proc/<pid>/ns/net` must still exist (i.e. the process is alive).
/// Returns `true` if the ruleset was applied successfully.
#[cfg(target_os = "linux")]
pub(crate) fn apply_nft_in_ns(child_pid: u32, ruleset: &str) -> bool {
    use std::io::Write;
    let netns = format!("/proc/{child_pid}/ns/net");
    let mut nft = match std::process::Command::new("nsenter")
        .args(["--net", &netns, "nft", "-f", "-"])
        .stdin(std::process::Stdio::piped())
        .stdout(std::process::Stdio::null())
        .stderr(std::process::Stdio::piped())
        .spawn()
    {
        Ok(c) => c,
        Err(e) => {
            tracing::warn!(
                target: "cellos.supervisor.linux_isolation",
                error = %e,
                "nsenter unavailable — nftables policy not applied (install util-linux + nftables)"
            );
            return false;
        }
    };
    if let Some(mut stdin) = nft.stdin.take() {
        if let Err(e) = stdin.write_all(ruleset.as_bytes()) {
            tracing::warn!(
                target: "cellos.supervisor.linux_isolation",
                error = %e,
                "nft stdin write failed"
            );
        }
    }
    match nft.wait() {
        Ok(status) if status.success() => true,
        Ok(status) => {
            tracing::warn!(
                target: "cellos.supervisor.linux_isolation",
                code = ?status.code(),
                "nft ruleset apply failed (non-zero exit) — process may have already exited"
            );
            false
        }
        Err(e) => {
            tracing::warn!(
                target: "cellos.supervisor.linux_isolation",
                error = %e,
                "nft wait failed"
            );
            false
        }
    }
}

/// FC-38 Phase 1: scrape `nft list ruleset --json` from inside the child's
/// network namespace and parse the resulting counter rows.
///
/// `netns_path` should be a path that names the cell's network namespace and
/// remains valid for the duration of this call. Two valid forms in practice:
/// (a) `/proc/<child_pid>/ns/net` while the child is still alive (or has not
/// yet been reaped), or (b) `/proc/self/fd/<N>` where the supervisor holds an
/// `OpenOptions::new().read(true).open("/proc/<pid>/ns/net")` file handle. The
/// latter pins the netns alive even after `child.wait()` reaps, because Linux
/// keeps the namespace inode alive as long as ANY fd refers to it.
///
/// Best-effort: returns `Ok(vec![])` rather than an error if the data source
/// is unavailable. The hot path that calls this is gated by
/// `CELLOS_PER_FLOW_ENFORCEMENT_EVENTS=1` (default off), so a "no nft binary"
/// host with the flag enabled simply emits no per-flow events.
///
/// Honest scope (FC-38): this is the post-run "which rule fired with what
/// counter delta" view, NOT a real-time per-packet stream. Future eBPF /
/// nflog work targets real-time per-flow events; the four reason codes in
/// [`crate::nft_counters::classify_rule_repr`] are the classes attributed today.
#[cfg(target_os = "linux")]
pub(crate) fn scan_nft_counters_in_ns(netns_path: &str) -> Vec<crate::nft_counters::NftCounterRow> {
    let output = std::process::Command::new("nsenter")
        .args(["--net", netns_path, "nft", "-j", "list", "ruleset"])
        .stdin(std::process::Stdio::null())
        .stdout(std::process::Stdio::piped())
        .stderr(std::process::Stdio::piped())
        .output();
    let raw = match output {
        Ok(o) if o.status.success() => o.stdout,
        Ok(o) => {
            tracing::warn!(
                target: "cellos.supervisor.per_flow_enforcement",
                code = ?o.status.code(),
                stderr = %String::from_utf8_lossy(&o.stderr),
                "nft list ruleset --json failed (non-zero exit) — per-flow events skipped"
            );
            return Vec::new();
        }
        Err(e) => {
            tracing::warn!(
                target: "cellos.supervisor.per_flow_enforcement",
                error = %e,
                "nsenter/nft unavailable — per-flow events skipped (install util-linux + nftables)"
            );
            return Vec::new();
        }
    };
    let raw_str = match std::str::from_utf8(&raw) {
        Ok(s) => s,
        Err(e) => {
            tracing::warn!(
                target: "cellos.supervisor.per_flow_enforcement",
                error = %e,
                "nft JSON output was not valid UTF-8 — per-flow events skipped"
            );
            return Vec::new();
        }
    };
    match crate::nft_counters::parse_nft_list_ruleset_json(raw_str) {
        Ok(rows) => rows,
        Err(e) => {
            tracing::warn!(
                target: "cellos.supervisor.per_flow_enforcement",
                error = %e,
                "nft JSON parse failed — per-flow events skipped"
            );
            Vec::new()
        }
    }
}

/// Read capability bitmasks from `/proc/self/status` (Linux only).
///
/// Returns `[cap_eff, cap_prm, cap_bnd, cap_amb, cap_inh]` as `Option<String>`
/// hex strings in the same format the kernel writes them (16 hex digits, no
/// leading `0x`).  Returns `[None; 5]` if the file is unreadable or the fields
/// are absent — callers treat absence as "unknown," not "clean."
#[cfg(target_os = "linux")]
pub(crate) fn read_proc_caps() -> [Option<String>; 5] {
    let Ok(status) = std::fs::read_to_string("/proc/self/status") else {
        return [None, None, None, None, None];
    };
    let mut eff = None;
    let mut prm = None;
    let mut bnd = None;
    let mut amb = None;
    let mut inh = None;
    for line in status.lines() {
        if let Some(val) = line.strip_prefix("CapEff:\t") {
            eff = Some(val.trim().to_ascii_lowercase());
        } else if let Some(val) = line.strip_prefix("CapPrm:\t") {
            prm = Some(val.trim().to_ascii_lowercase());
        } else if let Some(val) = line.strip_prefix("CapBnd:\t") {
            bnd = Some(val.trim().to_ascii_lowercase());
        } else if let Some(val) = line.strip_prefix("CapAmb:\t") {
            amb = Some(val.trim().to_ascii_lowercase());
        } else if let Some(val) = line.strip_prefix("CapInh:\t") {
            inh = Some(val.trim().to_ascii_lowercase());
        }
    }
    [eff, prm, bnd, amb, inh]
}

#[cfg(test)]
#[cfg(target_os = "linux")]
mod tests {
    use super::generate_nft_ruleset;
    use cellos_core::{DnsAuthority, DnsResolver, DnsResolverProtocol, EgressRule};

    fn declared_resolver() -> DnsResolver {
        DnsResolver {
            resolver_id: "resolver-do53-internal".into(),
            endpoint: "10.10.0.53:53".into(),
            protocol: DnsResolverProtocol::Do53Udp,
            trust_kid: None,
            dnssec: None,
        }
    }
    #[test]
    fn nft_blocks_external_resolver_when_block_direct_workload_dns_set() {
        // External resolver on port 53 in egressRules; declared resolver
        // is 10.10.0.53. Expectation: drop lines for udp/tcp dport 53
        // appear above the generic accept lines, so an attempted egress
        // to 8.8.8.8:53 is killed at the kernel even though the rule
        // would otherwise allow it.
        let egress = vec![EgressRule {
            host: "8.8.8.8".into(),
            port: 53,
            protocol: Some("udp".into()),
            dns_egress_justification: None,
        }];
        let dns_auth = DnsAuthority {
            resolvers: vec![declared_resolver()],
            block_direct_workload_dns: true,
            ..DnsAuthority::default()
        };
        let ruleset = generate_nft_ruleset("cell-test", &egress, Some(&dns_auth));
        // Drop lines must appear.
        assert!(
            ruleset.contains("udp dport 53 drop"),
            "expected udp dport 53 drop in:\n{ruleset}"
        );
        assert!(
            ruleset.contains("tcp dport 53 drop"),
            "expected tcp dport 53 drop in:\n{ruleset}"
        );
        // Drop lines must precede the generic accept for 8.8.8.8:53 so
        // the kernel evaluates them first.
        let drop_idx = ruleset
            .find("udp dport 53 drop")
            .expect("drop line present");
        let accept_idx = ruleset
            .find("ip daddr 8.8.8.8 udp dport 53 accept")
            .expect("egress accept line present");
        assert!(
            drop_idx < accept_idx,
            "drop must precede accept; drop@{drop_idx} accept@{accept_idx}\n{ruleset}"
        );
    }

    #[test]
    fn nft_allows_declared_resolver_endpoint() {
        // No egress rule on :53 — only the resolver-allow line. Verify
        // the declared 10.10.0.53:53 resolver is allowed and that the
        // udp/tcp :53 drop still applies for everything else.
        let egress: Vec<EgressRule> = Vec::new();
        let dns_auth = DnsAuthority {
            resolvers: vec![declared_resolver()],
            block_direct_workload_dns: true,
            ..DnsAuthority::default()
        };
        let ruleset = generate_nft_ruleset("cell-allow", &egress, Some(&dns_auth));
        assert!(
            ruleset.contains("ip daddr 10.10.0.53 udp dport 53 accept"),
            "expected resolver allow line in:\n{ruleset}"
        );
        // Resolver allow must precede the global :53 drop so legitimate
        // resolver traffic is not killed.
        let allow_idx = ruleset
            .find("ip daddr 10.10.0.53 udp dport 53 accept")
            .expect("resolver allow present");
        let drop_idx = ruleset
            .find("udp dport 53 drop")
            .expect("drop line present");
        assert!(
            allow_idx < drop_idx,
            "resolver allow must precede :53 drop; allow@{allow_idx} drop@{drop_idx}\n{ruleset}"
        );
    }

    #[test]
    fn nft_unchanged_when_block_direct_workload_dns_disabled() {
        // Backwards compatibility: when the flag is false (or missing),
        // the generated ruleset must NOT contain the new SEC-22 drop
        // lines. Existing default-DROP semantics still apply via the
        // chain policy.
        let egress = vec![EgressRule {
            host: "10.0.0.1".into(),
            port: 443,
            protocol: Some("tcp".into()),
            dns_egress_justification: None,
        }];
        // Case 1: dns_authority = None (legacy spec).
        let ruleset_a = generate_nft_ruleset("cell-legacy", &egress, None);
        assert!(
            !ruleset_a.contains("udp dport 53 drop"),
            "legacy ruleset must not gain SEC-22 drop:\n{ruleset_a}"
        );
        // Case 2: dns_authority present but flag false.
        let dns_auth = DnsAuthority {
            resolvers: vec![declared_resolver()],
            block_direct_workload_dns: false,
            ..DnsAuthority::default()
        };
        let ruleset_b = generate_nft_ruleset("cell-flagoff", &egress, Some(&dns_auth));
        assert!(
            !ruleset_b.contains("udp dport 53 drop"),
            "ruleset must not gain SEC-22 drop when flag false:\n{ruleset_b}"
        );
        // Existing accept rule for declared egress still present.
        assert!(
            ruleset_b.contains("ip daddr 10.0.0.1 tcp dport 443 accept"),
            "legacy egress accept must still be emitted:\n{ruleset_b}"
        );
    }

    #[test]
    fn nft_handles_bracketed_ipv6_resolver_endpoint() {
        // Operators that point at an IPv6 resolver endpoint must use the
        // bracketed `[host]:port` form (matching dataplane convention).
        // The generator should emit an `ip6 daddr ...` accept for that
        // resolver on top of the global :53 drop.
        let dns_auth = DnsAuthority {
            resolvers: vec![DnsResolver {
                resolver_id: "resolver-v6".into(),
                endpoint: "[fe80::1]:53".into(),
                protocol: DnsResolverProtocol::Do53Tcp,
                trust_kid: None,
                dnssec: None,
            }],
            block_direct_workload_dns: true,
            ..DnsAuthority::default()
        };
        let ruleset = generate_nft_ruleset("cell-v6", &[], Some(&dns_auth));
        assert!(
            ruleset.contains("ip6 daddr fe80::1 tcp dport 53 accept"),
            "expected ip6 resolver allow in:\n{ruleset}"
        );
    }

    // ── FC-34a / FC-34b: negative-leg port-53 tests ──────────────────
    //
    // The W1 audit (`docs/firecracker-dns-audit.md`) flagged that the
    // existing `nftables_ruleset_format` test (FC backend) is positive-leg
    // only — it asserts that a `dns-acknowledged` egress rule produces a
    // `udp dport 53 accept` line, but never asserts the **converse**:
    // that a spec with NO port-53 entry yields a ruleset which DROPS
    // both UDP/53 and TCP/53. Without the negative leg, "default-deny"
    // is structurally true but not test-evidenced — the same class of
    // gap FC-30 forbids ("declared posture without isolation evidence").
    //
    // These two tests pin the property at the supervisor's nft generator
    // (the stub backend's `generate_nft_ruleset`, which is the path
    // exercised by `apply_nft_in_ns` in clone_newnet mode). They are
    // pure-string assertions on the generated ruleset — no network
    // syscalls, no `nft` invocation, no namespace work — so they are
    // safe in CI and run alongside the existing W2/SEC-22 tests.

    #[test]
    fn nft_drops_port_53_udp_when_not_in_egress_rules() {
        // FC-34a: a spec with declared egress on a non-DNS port must NOT
        // produce any `udp dport 53 accept` rule. The chain-level
        // `policy drop` must remain in place so UDP/53 packets are
        // dropped by default. This proves criterion 1 of FC-34
        // (UDP/53 blocked when port 53 not in egressRules) at the
        // dataplane-shape level.
        let egress = vec![EgressRule {
            host: "10.0.0.1".into(),
            port: 443,
            protocol: Some("tcp".into()),
            dns_egress_justification: None,
        }];
        // No DnsAuthority — exercises the legacy / pre-SEC-22 path so
        // this test isolates the FC-34 property (default-deny on 53)
        // from the SEC-22 explicit-drop path tested above.
        let ruleset = generate_nft_ruleset("cell-fc34a", &egress, None);

        assert!(
            !ruleset.contains("udp dport 53 accept"),
            "no udp/53 accept must be emitted when no port-53 egress declared:\n{ruleset}"
        );
        // Default-DROP chain policy must be present.
        assert!(
            ruleset.contains("type filter hook output priority 0; policy drop;"),
            "missing chain-level policy drop in:\n{ruleset}"
        );
        // The legitimate non-DNS rule should still be allowed — confirms
        // we did not accidentally produce an empty / broken ruleset.
        assert!(
            ruleset.contains("ip daddr 10.0.0.1 tcp dport 443 accept"),
            "expected non-DNS egress accept to remain present:\n{ruleset}"
        );
    }

    #[test]
    fn nft_drops_port_53_tcp_when_not_in_egress_rules() {
        // FC-34b: same property as above for TCP/53. Closes the audit's
        // drift item 3 ("TCP/53 path is not exercised") at the
        // dataplane-shape layer. The complementary admission-time gate
        // is asserted by `policy::tests::dns_egress_ack_gate_covers_tcp_protocol`.
        let egress = vec![EgressRule {
            host: "203.0.113.7".into(),
            port: 22,
            protocol: Some("tcp".into()),
            dns_egress_justification: None,
        }];
        let ruleset = generate_nft_ruleset("cell-fc34b", &egress, None);

        assert!(
            !ruleset.contains("tcp dport 53 accept"),
            "no tcp/53 accept must be emitted when no port-53 egress declared:\n{ruleset}"
        );
        assert!(
            ruleset.contains("type filter hook output priority 0; policy drop;"),
            "missing chain-level policy drop in:\n{ruleset}"
        );
        assert!(
            ruleset.contains("ip daddr 203.0.113.7 tcp dport 22 accept"),
            "expected non-DNS egress accept to remain present:\n{ruleset}"
        );
    }

    // ── SEC-22 Phase 3d: kernel-level UDP/853 (DoQ) + UDP/443 (HTTP/3) ──
    //
    // Pure-string tests that mirror the FC-34a/b shape: assert on the
    // generated ruleset the two new opt-in flags
    // (`block_udp_doq` / `block_udp_http3`) drop the right UDP ports
    // unless a declared UDP-transport resolver carves an explicit
    // accept. The accept-leg dedupe between the two flags is also
    // pinned here.

    fn doh_resolver_one_one_one_one() -> DnsResolver {
        DnsResolver {
            resolver_id: "test-doh".into(),
            endpoint: "https://1.1.1.1:443/dns-query".into(),
            protocol: DnsResolverProtocol::Doh,
            trust_kid: None,
            dnssec: None,
        }
    }

    fn doq_resolver_one_one_one_one() -> DnsResolver {
        DnsResolver {
            resolver_id: "test-doq".into(),
            endpoint: "quic://1.1.1.1:853".into(),
            protocol: DnsResolverProtocol::Doq,
            trust_kid: None,
            dnssec: None,
        }
    }

    #[test]
    fn nft_drops_udp_853_when_block_udp_doq_set() {
        // Operator opts into DoQ blocking but declares no UDP-853
        // resolver. Expect: `udp dport 853 drop` line emitted, no
        // resolver accept-leg present for port 853.
        let dns_auth = DnsAuthority {
            resolvers: vec![doh_resolver_one_one_one_one()],
            block_udp_doq: true,
            ..DnsAuthority::default()
        };
        let ruleset = generate_nft_ruleset("cell-doq-drop", &[], Some(&dns_auth));
        assert!(
            ruleset.contains("udp dport 853 drop"),
            "expected udp dport 853 drop in:\n{ruleset}"
        );
        // Nothing carves an accept on UDP/853 — only the doh resolver
        // (which is on UDP/443) gets an accept.
        assert!(
            !ruleset.contains("udp dport 853 accept"),
            "no resolver accept on UDP/853 expected when only DoH declared:\n{ruleset}"
        );
        // The doh resolver SHOULD still get its UDP/443 accept-leg even
        // though only block_udp_doq is set — the accept-leg is shared
        // between both flags and emits for any declared UDP resolver.
        assert!(
            ruleset.contains("ip daddr 1.1.1.1 udp dport 443 accept"),
            "expected DoH resolver accept-leg in:\n{ruleset}"
        );
    }

    #[test]
    fn nft_allows_udp_853_to_declared_resolver_when_block_udp_doq_set() {
        // Operator opts into DoQ blocking AND declares a DoQ resolver on
        // UDP/853 — expect an accept-leg for that resolver above the drop.
        let dns_auth = DnsAuthority {
            resolvers: vec![doq_resolver_one_one_one_one()],
            block_udp_doq: true,
            ..DnsAuthority::default()
        };
        let ruleset = generate_nft_ruleset("cell-doq-allow", &[], Some(&dns_auth));
        assert!(
            ruleset.contains("ip daddr 1.1.1.1 udp dport 853 accept"),
            "expected DoQ resolver accept-leg in:\n{ruleset}"
        );
        assert!(
            ruleset.contains("udp dport 853 drop"),
            "expected UDP/853 default drop in:\n{ruleset}"
        );
        // Accept must come BEFORE the drop so the kernel evaluates it
        // first and lets the legitimate resolver traffic through.
        let allow_idx = ruleset
            .find("ip daddr 1.1.1.1 udp dport 853 accept")
            .expect("resolver allow present");
        let drop_idx = ruleset
            .find("udp dport 853 drop")
            .expect("drop line present");
        assert!(
            allow_idx < drop_idx,
            "DoQ accept must precede DoQ drop; allow@{allow_idx} drop@{drop_idx}\n{ruleset}"
        );
    }

    #[test]
    fn nft_does_not_drop_udp_853_when_block_udp_doq_unset() {
        // When `block_udp_doq=false`, the generator must NOT emit the
        // `udp dport 853 drop` line — the chain-level default-DROP
        // policy still blocks UDP/853 by default, but we don't want a
        // surplus explicit drop that would shadow a future operator
        // egressRule on UDP/853 (Phase 4 territory).
        let dns_auth = DnsAuthority {
            resolvers: vec![doq_resolver_one_one_one_one()],
            ..DnsAuthority::default()
        };
        let ruleset = generate_nft_ruleset("cell-doq-off", &[], Some(&dns_auth));
        assert!(
            !ruleset.contains("udp dport 853 drop"),
            "no UDP/853 drop expected when block_udp_doq=false:\n{ruleset}"
        );
    }

    #[test]
    fn nft_drops_udp_443_when_block_udp_http3_set() {
        // Operator opts into HTTP/3 blocking with a do53-udp resolver
        // declared. Expect: UDP/443 drop, plus the do53-udp resolver
        // accept-leg on UDP/53 (the resolver-allowlist applies to all
        // UDP-transport resolvers regardless of which flag is on).
        let dns_auth = DnsAuthority {
            resolvers: vec![DnsResolver {
                resolver_id: "test-do53".into(),
                endpoint: "8.8.8.8:53".into(),
                protocol: DnsResolverProtocol::Do53Udp,
                trust_kid: None,
                dnssec: None,
            }],
            block_udp_http3: true,
            ..DnsAuthority::default()
        };
        let ruleset = generate_nft_ruleset("cell-h3-drop", &[], Some(&dns_auth));
        assert!(
            ruleset.contains("udp dport 443 drop"),
            "expected udp dport 443 drop in:\n{ruleset}"
        );
        assert!(
            ruleset.contains("ip daddr 8.8.8.8 udp dport 53 accept"),
            "expected do53-udp resolver accept-leg in:\n{ruleset}"
        );
    }

    #[test]
    fn nft_allows_udp_443_to_declared_resolver_when_block_udp_http3_set() {
        // Operator opts into HTTP/3 blocking AND declares a DoH
        // resolver on UDP/443 — that resolver's accept-leg must
        // precede the UDP/443 drop.
        let dns_auth = DnsAuthority {
            resolvers: vec![doh_resolver_one_one_one_one()],
            block_udp_http3: true,
            ..DnsAuthority::default()
        };
        let ruleset = generate_nft_ruleset("cell-h3-allow", &[], Some(&dns_auth));
        assert!(
            ruleset.contains("ip daddr 1.1.1.1 udp dport 443 accept"),
            "expected DoH resolver accept-leg in:\n{ruleset}"
        );
        assert!(
            ruleset.contains("udp dport 443 drop"),
            "expected UDP/443 drop in:\n{ruleset}"
        );
        let allow_idx = ruleset
            .find("ip daddr 1.1.1.1 udp dport 443 accept")
            .expect("resolver allow present");
        let drop_idx = ruleset
            .find("udp dport 443 drop")
            .expect("drop line present");
        assert!(
            allow_idx < drop_idx,
            "HTTP/3 accept must precede HTTP/3 drop; allow@{allow_idx} drop@{drop_idx}\n{ruleset}"
        );
    }

    #[test]
    fn nft_does_not_drop_udp_443_when_block_udp_http3_unset() {
        // Mirror of the DoQ-off case. When `block_udp_http3=false`, no
        // explicit UDP/443 drop is emitted; the chain default-DROP
        // remains the only block. This protects operator-declared
        // egressRules on UDP/443 from being shadowed.
        let dns_auth = DnsAuthority {
            resolvers: vec![doh_resolver_one_one_one_one()],
            ..DnsAuthority::default()
        };
        let ruleset = generate_nft_ruleset("cell-h3-off", &[], Some(&dns_auth));
        assert!(
            !ruleset.contains("udp dport 443 drop"),
            "no UDP/443 drop expected when block_udp_http3=false:\n{ruleset}"
        );
    }

    #[test]
    fn nft_block_udp_doq_and_block_udp_http3_dedupes_resolver_accept_lines() {
        // When BOTH flags are on and the operator declared a single
        // DoH resolver, the resolver's accept-leg
        // (`ip daddr 1.1.1.1 udp dport 443 accept`) must appear EXACTLY
        // ONCE in the ruleset — the two flags share the same
        // resolver-allowlist pass and the generator dedupes lines
        // before emitting them. Both drop lines must be present.
        let dns_auth = DnsAuthority {
            resolvers: vec![doh_resolver_one_one_one_one()],
            block_udp_doq: true,
            block_udp_http3: true,
            ..DnsAuthority::default()
        };
        let ruleset = generate_nft_ruleset("cell-both", &[], Some(&dns_auth));
        let accept_count = ruleset
            .matches("ip daddr 1.1.1.1 udp dport 443 accept")
            .count();
        assert_eq!(
            accept_count, 1,
            "DoH accept-leg must dedupe to one line when both flags set:\n{ruleset}"
        );
        assert!(
            ruleset.contains("udp dport 853 drop"),
            "expected DoQ drop in:\n{ruleset}"
        );
        assert!(
            ruleset.contains("udp dport 443 drop"),
            "expected HTTP/3 drop in:\n{ruleset}"
        );
    }
}