Skip to main content

agentmux_srv/backend/
lan_firewall.rs

1// Copyright 2025-2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Would the OS firewall let LAN peers reach us? Pure rule logic, no OS calls.
5//!
6//! SPEC_LAN_FIREWALL_SETUP_2026_10_01.md 4.2 and 4.3. On 2026-09-30 narko had LAN
7//! enabled, started its listeners, registered its mDNS service, logged no
8//! warning, and never saw a peer, because Windows Defender Firewall had no
9//! inbound allow rule for the active Private profile (and 606 stale per-path
10//! rules, none of them for the running binary). Nothing in AgentMux could say so.
11//!
12//! This module decides, from a snapshot of the firewall rules and the adapters'
13//! network categories, whether each interface is covered, and what the status
14//! bar should say. Reading the snapshot is OS-specific and lives elsewhere
15//! (`lan_firewall_windows.rs`); keeping the decision pure means the cases that
16//! actually bit us are unit tests that run on every platform.
17//!
18//! Windows semantics encoded here:
19//! - Inbound traffic is blocked unless a matching enabled **allow** rule exists,
20//!   and an enabled matching **block** rule beats any allow.
21//! - A rule applies per network **profile** (Domain, Private, Public), and each
22//!   adapter's connection has its own category.
23//! - A rule naming a program matches that exact path. The sidecar's file name
24//!   embeds its version (`agentmux-srv-0.59.1-windows.x64.exe`) and portable and
25//!   dev builds sit at a new path every time, so a per-program rule stops
26//!   matching on every update (spec R7). A **port** rule does not.
27
28// Only Windows reads its firewall so far (`lan_firewall_windows.rs`; macOS and
29// Linux are later PRs of the spec): elsewhere only the tests build rules.
30#![cfg_attr(not(windows), allow(dead_code))]
31
32use std::net::Ipv4Addr;
33
34/// `NET_FW_PROFILE2_*` bits.
35pub const PROFILE_DOMAIN: u32 = 1;
36pub const PROFILE_PRIVATE: u32 = 2;
37pub const PROFILE_PUBLIC: u32 = 4;
38
39/// A network's category, as `INetworkListManager` reports it.
40#[derive(Debug, Clone, Copy, PartialEq, Eq)]
41pub enum Category {
42    Public,
43    Private,
44    Domain,
45}
46
47impl Category {
48    pub fn bit(self) -> u32 {
49        match self {
50            Category::Domain => PROFILE_DOMAIN,
51            Category::Private => PROFILE_PRIVATE,
52            Category::Public => PROFILE_PUBLIC,
53        }
54    }
55}
56
57#[derive(Debug, Clone, Copy, PartialEq, Eq)]
58pub enum Proto {
59    Tcp,
60    Udp,
61    Any,
62    /// ICMP and anything else: never covers a TCP or UDP need.
63    Other,
64}
65
66impl Proto {
67    /// `NET_FW_IP_PROTOCOL_*`: TCP 6, UDP 17, ANY 256.
68    pub fn from_win(n: i32) -> Proto {
69        match n {
70            6 => Proto::Tcp,
71            17 => Proto::Udp,
72            256 => Proto::Any,
73            _ => Proto::Other,
74        }
75    }
76
77    fn covers(self, want: Proto) -> bool {
78        match self {
79            Proto::Any => matches!(want, Proto::Tcp | Proto::Udp),
80            Proto::Other => false,
81            p => p == want,
82        }
83    }
84}
85
86/// A rule's `LocalPorts`. Anything we cannot read exactly is `Unparseable` and
87/// matches nothing: claiming coverage we cannot prove is the worse error.
88#[derive(Debug, Clone, PartialEq, Eq)]
89pub enum PortSpec {
90    Any,
91    Ranges(Vec<(u16, u16)>),
92    Unparseable,
93}
94
95impl PortSpec {
96    /// `""` and `"*"` are any port; otherwise a comma list of ports and
97    /// `low-high` ranges. Windows also allows keywords (`RPC`, `IPHTTPS`,
98    /// `Teredo`, ...): those are `Unparseable`.
99    pub fn parse(spec: &str) -> PortSpec {
100        let spec = spec.trim();
101        if spec.is_empty() || spec == "*" {
102            return PortSpec::Any;
103        }
104        let mut ranges = Vec::new();
105        for part in spec.split(',') {
106            let part = part.trim();
107            let (lo, hi) = match part.split_once('-') {
108                Some((a, b)) => (a.trim().parse::<u16>(), b.trim().parse::<u16>()),
109                None => {
110                    let p = part.parse::<u16>();
111                    (p.clone(), p)
112                }
113            };
114            match (lo, hi) {
115                (Ok(lo), Ok(hi)) if lo <= hi => ranges.push((lo, hi)),
116                _ => return PortSpec::Unparseable,
117            }
118        }
119        PortSpec::Ranges(ranges)
120    }
121
122    pub fn contains(&self, port: u16) -> bool {
123        match self {
124            PortSpec::Any => true,
125            PortSpec::Ranges(r) => r.iter().any(|&(lo, hi)| (lo..=hi).contains(&port)),
126            PortSpec::Unparseable => false,
127        }
128    }
129}
130
131/// A rule's `RemoteAddresses`, reduced to the one question that matters here:
132/// does it reach LAN peers in general?
133#[derive(Debug, Clone, Copy, PartialEq, Eq)]
134pub enum Remote {
135    /// `*` or empty.
136    Any,
137    /// `LocalSubnet`: the peers' subnet. Covers a LAN peer.
138    LocalSubnet,
139    /// A specific list, `Internet`, `DNS`, ...: does not cover peers in general.
140    Other,
141}
142
143impl Remote {
144    pub fn parse(spec: &str) -> Remote {
145        let spec = spec.trim();
146        if spec.is_empty() || spec == "*" {
147            Remote::Any
148        } else if spec.eq_ignore_ascii_case("LocalSubnet") {
149            Remote::LocalSubnet
150        } else {
151            Remote::Other
152        }
153    }
154
155    fn reaches_lan_peers(self) -> bool {
156        matches!(self, Remote::Any | Remote::LocalSubnet)
157    }
158}
159
160/// A rule's `LocalAddresses`: which of OUR addresses it applies to. A rule
161/// limited to one local IP must only count for the adapter that owns it (Codex P2
162/// on #4151); treating it as "every adapter in the profile" could make an
163/// uncovered adapter look covered, or turn an address-scoped block into a false
164/// global block.
165#[derive(Debug, Clone, PartialEq, Eq)]
166pub enum LocalScope {
167    /// `*`, empty or `LocalSubnet`: every address we have.
168    Any,
169    /// Explicit IPv4 addresses, subnets and ranges, as inclusive `(low, high)`.
170    /// Empty when the list names only IPv6 addresses (it matches no IPv4 adapter).
171    Addrs(Vec<(Ipv4Addr, Ipv4Addr)>),
172    /// Windows keywords we cannot resolve here (`DHCP`, `DNS`, `Defaultgateway`,
173    /// `WINS`, ...). Matches nothing, like every spec we cannot read exactly.
174    Unparseable,
175}
176
177impl LocalScope {
178    pub fn parse(spec: &str) -> LocalScope {
179        let spec = spec.trim();
180        if spec.is_empty() || spec == "*" {
181            return LocalScope::Any;
182        }
183        let mut out = Vec::new();
184        for part in spec.split(',') {
185            let part = part.trim();
186            if part == "*" || part.eq_ignore_ascii_case("LocalSubnet") {
187                return LocalScope::Any;
188            }
189            if part.contains(':') {
190                continue; // IPv6: cannot match an IPv4 adapter
191            }
192            match Self::parse_v4(part) {
193                Some(r) => out.push(r),
194                None => return LocalScope::Unparseable,
195            }
196        }
197        LocalScope::Addrs(out)
198    }
199
200    fn parse_v4(part: &str) -> Option<(Ipv4Addr, Ipv4Addr)> {
201        if let Some((a, b)) = part.split_once('-') {
202            let (lo, hi) = (a.trim().parse::<Ipv4Addr>().ok()?, b.trim().parse::<Ipv4Addr>().ok()?);
203            return (u32::from(lo) <= u32::from(hi)).then_some((lo, hi));
204        }
205        if let Some((a, m)) = part.split_once('/') {
206            let ip = u32::from(a.trim().parse::<Ipv4Addr>().ok()?);
207            let m = m.trim();
208            // `/24` or `/255.255.255.0`
209            let mask = match m.parse::<u32>() {
210                Ok(len) if len <= 32 => if len == 0 { 0 } else { u32::MAX << (32 - len) },
211                _ => u32::from(m.parse::<Ipv4Addr>().ok()?),
212            };
213            return Some((Ipv4Addr::from(ip & mask), Ipv4Addr::from(ip | !mask)));
214        }
215        let ip = part.parse::<Ipv4Addr>().ok()?;
216        Some((ip, ip))
217    }
218
219    /// Does the rule apply to any of these adapter addresses?
220    pub fn applies_to(&self, ips: &[Ipv4Addr]) -> bool {
221        match self {
222            LocalScope::Any => true,
223            LocalScope::Unparseable => false,
224            LocalScope::Addrs(ranges) => ips.iter().any(|ip| {
225                let n = u32::from(*ip);
226                ranges.iter().any(|&(lo, hi)| u32::from(lo) <= n && n <= u32::from(hi))
227            }),
228        }
229    }
230}
231
232/// One firewall rule, with the fields that decide whether it affects us.
233#[derive(Debug, Clone)]
234pub struct FwRule {
235    #[cfg(test)]
236    pub name: String,
237    pub enabled: bool,
238    pub inbound: bool,
239    pub allow: bool,
240    /// `NET_FW_PROFILE2_*` bitmask; Windows uses `0x7FFFFFFF` for "all".
241    pub profiles: u32,
242    /// The program the rule is bound to, `None` for any program.
243    pub program: Option<String>,
244    pub proto: Proto,
245    pub local_ports: PortSpec,
246    pub remote: Remote,
247    /// Which of our own addresses the rule applies to.
248    pub local_addresses: LocalScope,
249    /// Restricted to particular adapters or interface types. Such a rule may not
250    /// cover the adapter we care about, so it is skipped rather than assumed.
251    pub interface_scoped: bool,
252    /// Bound to something other than a program path: a Windows service, a Store
253    /// app package (`LocalAppPackageId`), particular users or machines, or secure
254    /// (authenticated) traffic only. Such a rule has an empty program path and
255    /// looks like "any program", which it is not. Found on narko, 2026-10-01: 25
256    /// Store-app rules ("Solitaire", "Microsoft Store", ...) made coverage look
257    /// complete for a binary no rule named. Skipped, never assumed to apply.
258    pub restricted: bool,
259}
260
261/// Compare two Windows paths: case-insensitive, `/` equals `\`, the `\\?\`
262/// prefix ignored. Rules that use environment variables (`%SystemRoot%`) do not
263/// match: coverage is only claimed when it can be shown.
264pub fn same_path(a: &str, b: &str) -> bool {
265    fn norm(p: &str) -> String {
266        p.trim()
267            .trim_start_matches(r"\\?\")
268            .replace('/', "\\")
269            .to_lowercase()
270    }
271    let (a, b) = (norm(a), norm(b));
272    !a.is_empty() && a == b
273}
274
275/// Something the running srv must be reachable on from the LAN.
276#[derive(Debug, Clone, Copy, PartialEq, Eq)]
277pub struct Need {
278    pub proto: Proto,
279    pub port: u16,
280}
281
282/// The inbound traffic a LAN peer needs: our web and ws ports (TCP), the mDNS
283/// port (UDP 5353) and the UDP broadcast-probe responder that mobile clients
284/// use when mDNS is filtered (`lan_discovery::UDP_DISCOVERY_PORT`, spec 4.1 puts
285/// it in the same rule as 5353; Codex P2 on #4151).
286pub fn lan_needs(web_port: u16, ws_port: u16) -> Vec<Need> {
287    vec![
288        Need { proto: Proto::Tcp, port: web_port },
289        Need { proto: Proto::Tcp, port: ws_port },
290        Need { proto: Proto::Udp, port: 5353 },
291        Need { proto: Proto::Udp, port: super::lan_discovery::UDP_DISCOVERY_PORT },
292        Need { proto: Proto::Udp, port: super::lan_discovery::DESKTOP_DISCOVERY_PORT },
293    ]
294}
295
296/// Does a rule apply to `need` on this adapter? Three answers, not two.
297#[derive(Debug, Clone, Copy, PartialEq, Eq)]
298enum Applies {
299    /// Every field says it does.
300    Yes,
301    /// Every field we CAN evaluate says it does, but one we cannot (an interface
302    /// scope, a service, package or user restriction, a port or local-address spec
303    /// we could not read exactly) leaves it open.
304    Maybe,
305    /// Something we can evaluate says it does not.
306    No,
307}
308
309fn applies_to(rule: &FwRule, exe: &str, need: Need, category: Category, local_ips: &[Ipv4Addr]) -> Applies {
310    // Fields that settle the question either way.
311    if !rule.enabled
312        || !rule.inbound
313        || rule.profiles & category.bit() == 0
314        || !rule.proto.covers(need.proto)
315        || !rule.remote.reaches_lan_peers()
316        || rule.program.as_deref().is_some_and(|p| !same_path(p, exe))
317    {
318        return Applies::No;
319    }
320    let mut uncertain = rule.interface_scoped || rule.restricted;
321    match &rule.local_ports {
322        PortSpec::Any => {}
323        PortSpec::Ranges(_) if rule.local_ports.contains(need.port) => {}
324        PortSpec::Ranges(_) => return Applies::No,
325        PortSpec::Unparseable => uncertain = true,
326    }
327    match &rule.local_addresses {
328        LocalScope::Any => {}
329        LocalScope::Addrs(_) if rule.local_addresses.applies_to(local_ips) => {}
330        LocalScope::Addrs(_) => return Applies::No,
331        LocalScope::Unparseable => uncertain = true,
332    }
333    if uncertain {
334        Applies::Maybe
335    } else {
336        Applies::Yes
337    }
338}
339
340/// An ALLOW counts only when every field is evaluable and says yes: counting one
341/// we cannot evaluate would claim coverage we cannot prove. A BLOCK is the
342/// opposite case, handled in `coverage_with`: skipping one we cannot evaluate
343/// would lean toward `Covered`, the very direction the unreadable-means-unknown
344/// rule forbids (ReAgent on #4151).
345fn rule_applies(rule: &FwRule, exe: &str, need: Need, category: Category, local_ips: &[Ipv4Addr]) -> bool {
346    applies_to(rule, exe, need, category, local_ips) == Applies::Yes
347}
348
349/// The rules that decide `need` for `category`, allow or block. For diagnostics
350/// and tests: it is the same predicate `coverage` uses.
351#[cfg(test)]
352pub fn matching_rules<'a>(
353    rules: &'a [FwRule],
354    exe: &str,
355    need: Need,
356    category: Category,
357    local_ips: &[Ipv4Addr],
358    allow: bool,
359) -> Vec<&'a FwRule> {
360    rules
361        .iter()
362        .filter(|r| r.allow == allow && rule_applies(r, exe, need, category, local_ips))
363        .collect()
364}
365
366/// One profile's own switches, which decide whether any rule is needed at all
367/// (ReAgent P1 on #4151: judging from rules alone reported `needs-setup` on a
368/// profile whose firewall is off, or whose default inbound action is Allow).
369#[derive(Debug, Clone, Copy, PartialEq, Eq)]
370pub struct ProfileSettings {
371    /// The firewall is on for this profile. Off filters nothing, so nothing is
372    /// blocked, block rules included.
373    pub enabled: bool,
374    /// "Block all incoming connections": overrides every allow rule.
375    pub block_all_inbound: bool,
376    /// The default for inbound traffic no rule matches is Allow (Windows' own
377    /// default is Block). Then no allow rule is needed; a matching block still wins.
378    pub default_inbound_allow: bool,
379}
380
381impl Default for ProfileSettings {
382    /// Windows' defaults: firewall on, default inbound Block.
383    fn default() -> Self {
384        ProfileSettings { enabled: true, block_all_inbound: false, default_inbound_allow: false }
385    }
386}
387
388#[derive(Debug, Clone, Copy, Default)]
389pub struct Profiles {
390    pub domain: ProfileSettings,
391    pub private: ProfileSettings,
392    pub public: ProfileSettings,
393}
394
395impl Profiles {
396    pub fn get(&self, c: Category) -> ProfileSettings {
397        match c {
398            Category::Domain => self.domain,
399            Category::Private => self.private,
400            Category::Public => self.public,
401        }
402    }
403}
404
405#[derive(Debug, Clone, Copy, PartialEq, Eq)]
406pub enum Coverage {
407    /// Every need is allowed and none is blocked.
408    Covered,
409    /// An enabled inbound block rule matches at least one need.
410    Blocked,
411    /// No block, but at least one need has no allow rule.
412    Missing,
413    /// Every need is allowed, and no block is certain, but a block rule that might
414    /// apply could not be ruled out (scoped to an interface type, bound to a
415    /// service or user, or with a port or address spec we could not read). Coverage
416    /// cannot be proven.
417    Uncertain,
418}
419
420/// [`coverage_with`] under Windows' default profile settings, for an adapter
421/// whose addresses are unknown (only rules open to every local address match).
422#[cfg(test)]
423pub fn coverage(rules: &[FwRule], exe: &str, needs: &[Need], category: Category) -> Coverage {
424    coverage_with(rules, exe, needs, category, ProfileSettings::default(), &[])
425}
426
427/// Does `category`'s profile let LAN peers reach `exe` on every need?
428///
429/// Order, as Windows applies it: a profile with the firewall off filters nothing;
430/// "block all incoming" beats every allow; a matching block rule beats any allow;
431/// then, with a default inbound action of Allow, nothing more is needed;
432/// otherwise every need must have a matching allow rule.
433pub fn coverage_with(
434    rules: &[FwRule],
435    exe: &str,
436    needs: &[Need],
437    category: Category,
438    settings: ProfileSettings,
439    local_ips: &[Ipv4Addr],
440) -> Coverage {
441    if !settings.enabled {
442        return Coverage::Covered;
443    }
444    if settings.block_all_inbound {
445        return Coverage::Blocked;
446    }
447    let applies = |allow: bool, need: Need| {
448        rules
449            .iter()
450            .any(|r| r.allow == allow && rule_applies(r, exe, need, category, local_ips))
451    };
452    if needs.iter().any(|&n| applies(false, n)) {
453        return Coverage::Blocked;
454    }
455    // A block we cannot rule out, next to an allow we can see.
456    let maybe_blocked = needs.iter().any(|&n| {
457        rules
458            .iter()
459            .any(|r| !r.allow && applies_to(r, exe, n, category, local_ips) == Applies::Maybe)
460    });
461    if settings.default_inbound_allow || needs.iter().all(|&n| applies(true, n)) {
462        if maybe_blocked {
463            Coverage::Uncertain
464        } else {
465            Coverage::Covered
466        }
467    } else {
468        Coverage::Missing
469    }
470}
471
472/// A network adapter and the category of the network it is connected to.
473#[derive(Debug, Clone)]
474pub struct Adapter {
475    /// The adapter GUID (`if_addrs::Interface::adapter_name` on Windows).
476    #[cfg(test)]
477    pub id: String,
478    pub name: String,
479    pub ipv4: Vec<Ipv4Addr>,
480    /// `None` when Windows could not say (no profile yet, or the lookup failed).
481    pub category: Option<Category>,
482}
483
484#[derive(Debug, Clone, Copy, PartialEq, Eq)]
485pub enum AdapterState {
486    Covered,
487    NeedsSetup,
488    Blocked,
489    /// A Public network with no allow rule: not set up on purpose. Opening it
490    /// needs the user's consent for that network (spec 4.4), unlike Private.
491    PublicNotTrusted,
492    /// Category unknown: no claim either way.
493    Unknown,
494}
495
496#[cfg(test)]
497pub fn adapter_state(rules: &[FwRule], exe: &str, needs: &[Need], adapter: &Adapter) -> AdapterState {
498    adapter_state_with(rules, exe, needs, adapter, Profiles::default())
499}
500
501pub fn adapter_state_with(
502    rules: &[FwRule],
503    exe: &str,
504    needs: &[Need],
505    adapter: &Adapter,
506    profiles: Profiles,
507) -> AdapterState {
508    let Some(category) = adapter.category else {
509        return AdapterState::Unknown;
510    };
511    match coverage_with(rules, exe, needs, category, profiles.get(category), &adapter.ipv4) {
512        Coverage::Covered => AdapterState::Covered,
513        Coverage::Blocked => AdapterState::Blocked,
514        Coverage::Missing if category == Category::Public => AdapterState::PublicNotTrusted,
515        Coverage::Missing => AdapterState::NeedsSetup,
516        // Cannot prove it either way: no claim, like an unknown category.
517        Coverage::Uncertain => AdapterState::Unknown,
518    }
519}
520
521/// What the status bar shows about the firewall (spec 4.3).
522#[derive(Debug, Clone, Copy, PartialEq, Eq)]
523pub enum FirewallStatus {
524    /// At least one adapter is covered: LAN can work.
525    Ok,
526    NeedsSetup,
527    Blocked,
528    PublicNetwork,
529    /// An administrator's policy makes local rules ineffective: a setup helper
530    /// cannot fix it.
531    Managed,
532    /// Nothing can be said (no adapters, or categories unknown).
533    Unknown,
534}
535
536impl FirewallStatus {
537    pub fn wire(self) -> &'static str {
538        match self {
539            FirewallStatus::Ok => "ok",
540            FirewallStatus::NeedsSetup => "needs-setup",
541            FirewallStatus::Blocked => "blocked",
542            FirewallStatus::PublicNetwork => "public-network",
543            FirewallStatus::Managed => "managed",
544            FirewallStatus::Unknown => "unknown",
545        }
546    }
547}
548
549/// The overall status from every adapter. One covered adapter is enough for
550/// "ok": a host also has virtual adapters (VMware, WSL, Hyper-V) that are
551/// rightly uncovered, and they must not flag a machine whose real LAN works.
552/// Otherwise the most actionable problem wins: a block, then a managed policy
553/// (no helper can help), then missing rules, then an untrusted Public network.
554pub fn overall(states: &[AdapterState], local_rules_ignored: bool) -> FirewallStatus {
555    let any = |s: AdapterState| states.iter().any(|&x| x == s);
556    if any(AdapterState::Covered) {
557        FirewallStatus::Ok
558    } else if any(AdapterState::Blocked) {
559        FirewallStatus::Blocked
560    } else if local_rules_ignored && (any(AdapterState::NeedsSetup) || any(AdapterState::PublicNotTrusted)) {
561        FirewallStatus::Managed
562    } else if any(AdapterState::NeedsSetup) {
563        FirewallStatus::NeedsSetup
564    } else if any(AdapterState::PublicNotTrusted) {
565        FirewallStatus::PublicNetwork
566    } else {
567        FirewallStatus::Unknown
568    }
569}
570
571/// Everything read from the OS in one go.
572#[derive(Debug, Clone, Default)]
573pub struct Snapshot {
574    pub rules: Vec<FwRule>,
575    pub adapters: Vec<Adapter>,
576    /// Group policy stops locally created rules from applying.
577    pub local_rules_ignored: bool,
578    /// Each profile's own on/off, block-all and default-inbound settings.
579    pub profiles: Profiles,
580    /// Rules that could not be read. One of them may be the block or the allow
581    /// that decides the verdict, so while any exist coverage cannot be proven.
582    pub unreadable_rules: u32,
583}
584
585#[derive(Debug, Clone)]
586pub struct Report {
587    pub status: FirewallStatus,
588    pub per_adapter: Vec<(Adapter, AdapterState)>,
589}
590
591pub fn report(snapshot: &Snapshot, exe: &str, needs: &[Need]) -> Report {
592    let per_adapter: Vec<(Adapter, AdapterState)> = snapshot
593        .adapters
594        .iter()
595        .map(|a| (a.clone(), adapter_state_with(&snapshot.rules, exe, needs, a, snapshot.profiles)))
596        .collect();
597    let states: Vec<AdapterState> = per_adapter.iter().map(|(_, s)| *s).collect();
598    let mut status = overall(&states, snapshot.local_rules_ignored);
599    // Never claim coverage that cannot be proven (Codex P2 on #4151): with an
600    // unreadable rule there may be a block we did not see, so `ok` becomes
601    // `unknown`. The problem verdicts stand: they are warnings, and a rule we
602    // could not read does not make a missing allow less missing.
603    if status == FirewallStatus::Ok && snapshot.unreadable_rules > 0 {
604        status = FirewallStatus::Unknown;
605    }
606    Report { status, per_adapter }
607}
608
609/// Whether this status goes to the windows on this tick. A standing problem is
610/// re-sent every tick so a window opened later, or a reconnected WebSocket,
611/// learns of it: the frontend only learns from events and cannot ask (the same
612/// lesson as `lan_mdns_health::should_publish`, ReAgent P1 on #4148). `Ok` and
613/// `Unknown` go out only on change.
614pub fn should_publish(status: FirewallStatus, changed: bool) -> bool {
615    match status {
616        FirewallStatus::Ok | FirewallStatus::Unknown => changed,
617        FirewallStatus::NeedsSetup
618        | FirewallStatus::Blocked
619        | FirewallStatus::PublicNetwork
620        | FirewallStatus::Managed => true,
621    }
622}
623
624fn category_wire(c: Option<Category>) -> &'static str {
625    match c {
626        Some(Category::Public) => "public",
627        Some(Category::Private) => "private",
628        Some(Category::Domain) => "domain",
629        None => "unknown",
630    }
631}
632
633fn state_wire(s: AdapterState) -> &'static str {
634    match s {
635        AdapterState::Covered => "covered",
636        AdapterState::NeedsSetup => "needs-setup",
637        AdapterState::Blocked => "blocked",
638        AdapterState::PublicNotTrusted => "public-not-trusted",
639        AdapterState::Unknown => "unknown",
640    }
641}
642
643/// The `laninstances:firewall` event body.
644pub fn payload(
645    status: FirewallStatus,
646    per_adapter: &[(Adapter, AdapterState)],
647    local_rules_ignored: bool,
648) -> LanFirewall {
649    LanFirewall {
650        status: status.wire().to_string(),
651        local_rules_ignored,
652        adapters: per_adapter
653            .iter()
654            .map(|(a, s)| LanFirewallAdapter {
655                name: a.name.clone(),
656                category: category_wire(a.category).to_string(),
657                state: state_wire(*s).to_string(),
658            })
659            .collect(),
660    }
661}
662
663/// `laninstances:firewall`: what the OS firewall would do to LAN peers reaching this machine.
664#[derive(Debug, Clone, serde::Serialize, ts_rs::TS)]
665#[ts(export, export_to = "../../../frontend/types/rpc/")]
666pub struct LanFirewall {
667    #[ts(type = r#""ok" | "needs-setup" | "blocked" | "public-network" | "managed" | "unknown" | "off""#)]
668    pub status: String,
669    /// Per adapter: its network category and whether a rule covers it.
670    pub adapters: Vec<LanFirewallAdapter>,
671    /// Group policy makes locally created rules ineffective.
672    #[serde(rename = "localRulesIgnored")]
673    #[ts(rename = "localRulesIgnored")]
674    pub local_rules_ignored: bool,
675}
676
677impl LanFirewall {
678    /// No verdict to show: LAN went off, or the firewall couldn't be read.
679    fn bare(status: &str) -> Self {
680        LanFirewall { status: status.to_string(), adapters: Vec::new(), local_rules_ignored: false }
681    }
682}
683
684/// One adapter in a `LanFirewall` verdict.
685#[derive(Debug, Clone, serde::Serialize, ts_rs::TS)]
686#[ts(export, export_to = "../../../frontend/types/rpc/")]
687pub struct LanFirewallAdapter {
688    pub name: String,
689    #[ts(type = r#""public" | "private" | "domain" | "unknown""#)]
690    pub category: String,
691    #[ts(type = r#""covered" | "needs-setup" | "blocked" | "public-not-trusted" | "unknown""#)]
692    pub state: String,
693}
694
695/// How often the firewall is read while LAN discovery is wanted. A read is cheap
696/// (about 60 ms for 1,300 rules on narko) and a changed rule or network should
697/// show promptly.
698const WATCH_TICK: std::time::Duration = std::time::Duration::from_secs(30);
699
700/// Watch the OS firewall while LAN discovery is wanted and tell the windows what
701/// it found, via `laninstances:firewall`. Only Windows can read its firewall
702/// today (macOS and Linux are later PRs): elsewhere this does nothing, so the
703/// status bar simply has no firewall verdict.
704///
705/// Read-only. It changes no rule and gates nothing: the indicator tells the
706/// truth first (spec 4.3); acting on it is a later step.
707pub fn spawn_watcher(
708    lan: std::sync::Arc<super::lan_discovery::LanDiscoveryController>,
709    web_port: u16,
710    ws_port: u16,
711    event_bus: std::sync::Arc<super::eventbus::EventBus>,
712) {
713    if !cfg!(windows) {
714        return;
715    }
716    tokio::spawn(async move {
717        let mut last: Option<String> = None;
718        let mut tick = tokio::time::interval(WATCH_TICK);
719        tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
720        loop {
721            tick.tick().await;
722            if !lan.is_wanted() {
723                // LAN went off: clear whatever the indicator was showing.
724                if last.as_deref().is_some_and(|s| s != "off") {
725                    event_bus.broadcast_event(&super::eventbus::EventBus::typed_ws_event::<crate::backend::mps_events::LanInstancesFirewall>(&LanFirewall::bare("off"), String::new()));
726                    last = Some("off".to_string());
727                }
728                continue;
729            }
730            let exe = match std::env::current_exe() {
731                Ok(p) => p.to_string_lossy().to_string(),
732                Err(e) => {
733                    tracing::warn!("firewall check: cannot find our own executable: {e}");
734                    continue;
735                }
736            };
737            let snapshot = match tokio::task::spawn_blocking(read_snapshot).await {
738                Ok(Ok(s)) => s,
739                Ok(Err(e)) => {
740                    // "Unknown" is a fact worth one line, and one event, not one per
741                    // tick. The event matters: without it a needs-setup or blocked
742                    // sent earlier would stay on the status bar while the backend
743                    // no longer knows (ReAgent P2 on #4151).
744                    if last.as_deref() != Some("unknown") {
745                        tracing::warn!("could not read the OS firewall, so no verdict: {e}");
746                        event_bus.broadcast_event(&super::eventbus::EventBus::typed_ws_event::<crate::backend::mps_events::LanInstancesFirewall>(&LanFirewall::bare("unknown"), String::new()));
747                        last = Some("unknown".to_string());
748                    }
749                    continue;
750                }
751                Err(e) => {
752                    tracing::warn!("firewall reader task failed: {e}");
753                    continue;
754                }
755            };
756            let report = report(&snapshot, &exe, &lan_needs(web_port, ws_port));
757            let wire = report.status.wire().to_string();
758            let changed = last.as_deref() != Some(wire.as_str());
759            if changed && report.status != FirewallStatus::Ok {
760                tracing::warn!(
761                    status = %wire,
762                    adapters = ?report.per_adapter.iter().map(|(a, s)| (a.name.clone(), *s)).collect::<Vec<_>>(),
763                    "OS firewall may keep other machines from reaching this one"
764                );
765            }
766            if should_publish(report.status, changed) {
767                let verdict = payload(report.status, &report.per_adapter, snapshot.local_rules_ignored);
768                event_bus.broadcast_event(&super::eventbus::EventBus::typed_ws_event::<crate::backend::mps_events::LanInstancesFirewall>(&verdict, String::new()));
769            }
770            last = Some(wire);
771        }
772    });
773}
774
775/// Read the OS firewall and the adapters' network categories. Windows only for
776/// now (macOS is PR C, Linux PR D of the spec's delivery plan); elsewhere it is an
777/// `Err`, which callers must treat as "unknown", never as "covered".
778#[cfg(windows)]
779pub use super::lan_firewall_windows::read_snapshot;
780
781#[cfg(not(windows))]
782pub fn read_snapshot() -> Result<Snapshot, String> {
783    Err("firewall inspection is only implemented on Windows".to_string())
784}
785
786#[cfg(test)]
787mod tests {
788    use super::*;
789
790    const ALL: u32 = 0x7FFF_FFFF;
791    const EXE: &str = r"C:\Users\user\Desktop\agentmux-0.59.1\runtime\agentmux-srv-0.59.1-windows.x64.exe";
792    const OLD_EXE: &str = r"C:\Users\user\Desktop\agentmux-0.58.2\runtime\agentmux-srv-0.58.2-windows.x64.exe";
793
794    fn needs() -> Vec<Need> {
795        lan_needs(29700, 29701)
796    }
797
798    fn rule(name: &str) -> FwRule {
799        FwRule {
800            name: name.to_string(),
801            enabled: true,
802            inbound: true,
803            allow: true,
804            profiles: PROFILE_PRIVATE,
805            program: None,
806            proto: Proto::Any,
807            local_ports: PortSpec::Any,
808            remote: Remote::Any,
809            local_addresses: LocalScope::Any,
810            interface_scoped: false,
811            restricted: false,
812        }
813    }
814
815    fn program_rule(exe: &str, proto: Proto, profiles: u32, allow: bool) -> FwRule {
816        FwRule {
817            program: Some(exe.to_string()),
818            proto,
819            profiles,
820            allow,
821            ..rule("agentmux-srv")
822        }
823    }
824
825    fn adapter(name: &str, cat: Option<Category>) -> Adapter {
826        Adapter {
827            id: format!("{{{name}}}"),
828            name: name.to_string(),
829            ipv4: vec!["192.168.1.26".parse().unwrap()],
830            category: cat,
831        }
832    }
833
834    // ---- parsers
835
836    #[test]
837    fn port_specs_parse_the_forms_windows_uses() {
838        assert_eq!(PortSpec::parse(""), PortSpec::Any);
839        assert_eq!(PortSpec::parse("*"), PortSpec::Any);
840        let p = PortSpec::parse("5353, 29700-29799");
841        assert!(p.contains(5353) && p.contains(29700) && p.contains(29799) && p.contains(29750));
842        assert!(!p.contains(29800) && !p.contains(5354) && !p.contains(29699));
843    }
844
845    #[test]
846    fn port_keywords_and_junk_never_match() {
847        for bad in ["RPC", "IPHTTPS", "Teredo", "80-", "-80", "70000", "90-80", "1,,2", "a-b"] {
848            let p = PortSpec::parse(bad);
849            assert_eq!(p, PortSpec::Unparseable, "{bad}");
850            assert!(!p.contains(80));
851        }
852    }
853
854    #[test]
855    fn remote_addresses_reduce_to_whether_lan_peers_are_reached() {
856        assert!(Remote::parse("").reaches_lan_peers());
857        assert!(Remote::parse("*").reaches_lan_peers());
858        assert!(Remote::parse("LocalSubnet").reaches_lan_peers());
859        assert!(Remote::parse("localsubnet").reaches_lan_peers());
860        assert!(!Remote::parse("192.168.1.5").reaches_lan_peers());
861        assert!(!Remote::parse("Internet").reaches_lan_peers());
862        assert!(!Remote::parse("LocalSubnet,10.0.0.1").reaches_lan_peers());
863    }
864
865    #[test]
866    fn protocols_from_the_windows_numbers() {
867        assert_eq!(Proto::from_win(6), Proto::Tcp);
868        assert_eq!(Proto::from_win(17), Proto::Udp);
869        assert_eq!(Proto::from_win(256), Proto::Any);
870        assert_eq!(Proto::from_win(1), Proto::Other);
871        assert!(!Proto::Other.covers(Proto::Tcp));
872        assert!(Proto::Any.covers(Proto::Udp));
873    }
874
875    #[test]
876    fn paths_compare_the_way_windows_does() {
877        assert!(same_path(r"C:\A\B.exe", r"c:\a\b.EXE"));
878        assert!(same_path("C:/A/B.exe", r"C:\A\B.exe"));
879        assert!(same_path(r"\\?\C:\A\B.exe", r"C:\A\B.exe"));
880        assert!(!same_path(r"C:\A\B.exe", r"C:\A\C.exe"));
881        assert!(!same_path("", ""));
882        assert!(!same_path(r"%SystemRoot%\x.exe", r"C:\Windows\x.exe"), "env vars are not expanded");
883    }
884
885    // ---- coverage: the cases that actually happened
886
887    /// narko, 2026-09-30: 606 per-program rules, none for the running binary,
888    /// nothing for the Private profile. LAN was on, nothing warned, no peer
889    /// was ever seen.
890    #[test]
891    fn narko_with_only_stale_per_path_rules_needs_setup() {
892        let mut rules: Vec<FwRule> = (0..606)
893            .map(|i| program_rule(&format!(r"C:\old\{i}\agentmux-srv.exe"), Proto::Any, ALL, true))
894            .collect();
895        rules.push(program_rule(OLD_EXE, Proto::Any, ALL, true));
896        assert_eq!(coverage(&rules, EXE, &needs(), Category::Private), Coverage::Missing);
897        let a = adapter("Ethernet", Some(Category::Private));
898        assert_eq!(adapter_state(&rules, EXE, &needs(), &a), AdapterState::NeedsSetup);
899    }
900
901    /// Area54: Private allow rules for the CURRENT binary, among 400 stale ones.
902    #[test]
903    fn area54_with_rules_for_the_current_binary_is_covered() {
904        let mut rules: Vec<FwRule> = (0..390)
905            .map(|i| program_rule(&format!(r"C:\old\{i}\agentmux-srv.exe"), Proto::Any, ALL, true))
906            .collect();
907        rules.push(program_rule(EXE, Proto::Tcp, PROFILE_PRIVATE, true));
908        rules.push(program_rule(EXE, Proto::Udp, PROFILE_PRIVATE, true));
909        // The Public blocks that Windows adds when a prompt is cancelled.
910        rules.push(program_rule(EXE, Proto::Tcp, PROFILE_PUBLIC, false));
911        rules.push(program_rule(EXE, Proto::Udp, PROFILE_PUBLIC, false));
912        assert_eq!(coverage(&rules, EXE, &needs(), Category::Private), Coverage::Covered);
913        assert_eq!(coverage(&rules, EXE, &needs(), Category::Public), Coverage::Blocked);
914    }
915
916    #[test]
917    fn a_program_rule_for_another_path_does_not_cover_this_binary() {
918        // Spec R7: the version is in the file name, so every update changes the path.
919        let rules = vec![program_rule(OLD_EXE, Proto::Any, ALL, true)];
920        assert_eq!(coverage(&rules, EXE, &needs(), Category::Private), Coverage::Missing);
921    }
922
923    #[test]
924    fn port_rules_survive_updates_and_new_paths() {
925        let tcp = FwRule {
926            proto: Proto::Tcp,
927            local_ports: PortSpec::parse("29700-29799"),
928            remote: Remote::LocalSubnet,
929            ..rule("AgentMux LAN (TCP)")
930        };
931        let udp = FwRule {
932            proto: Proto::Udp,
933            local_ports: PortSpec::parse("5353,29700,47891"),
934            remote: Remote::LocalSubnet,
935            ..rule("AgentMux LAN (UDP)")
936        };
937        let rules = vec![tcp, udp];
938        for exe in [EXE, OLD_EXE, r"D:\portable\anything.exe"] {
939            assert_eq!(coverage(&rules, exe, &needs(), Category::Private), Coverage::Covered, "{exe}");
940        }
941    }
942
943    #[test]
944    fn srv_ports_outside_the_rule_range_are_not_covered() {
945        // The OS-chosen fallback port when the fixed range is exhausted.
946        let tcp = FwRule { proto: Proto::Tcp, local_ports: PortSpec::parse("29700-29799"), ..rule("t") };
947        let udp = FwRule { proto: Proto::Udp, local_ports: PortSpec::parse("5353"), ..rule("u") };
948        let outside = lan_needs(51234, 51235);
949        assert_eq!(coverage(&[tcp, udp], EXE, &outside, Category::Private), Coverage::Missing);
950    }
951
952    // ---- what must not count
953
954    #[test]
955    fn disabled_outbound_interface_scoped_and_remote_limited_rules_do_not_count() {
956        let base = program_rule(EXE, Proto::Any, PROFILE_PRIVATE, true);
957        for broken in [
958            FwRule { enabled: false, ..base.clone() },
959            FwRule { inbound: false, ..base.clone() },
960            FwRule { interface_scoped: true, ..base.clone() },
961            // A Store-app or service rule looks like "any program" but is not.
962            FwRule { restricted: true, program: None, ..base.clone() },
963            FwRule { remote: Remote::Other, ..base.clone() },
964            FwRule { proto: Proto::Other, ..base.clone() },
965            FwRule { local_ports: PortSpec::Unparseable, ..base.clone() },
966        ] {
967            assert_eq!(coverage(&[broken.clone()], EXE, &needs(), Category::Private), Coverage::Missing, "{broken:?}");
968        }
969    }
970
971    #[test]
972    fn a_rule_for_another_profile_does_not_cover_this_one() {
973        let rules = vec![program_rule(EXE, Proto::Any, PROFILE_PUBLIC | PROFILE_DOMAIN, true)];
974        assert_eq!(coverage(&rules, EXE, &needs(), Category::Private), Coverage::Missing);
975        assert_eq!(coverage(&rules, EXE, &needs(), Category::Domain), Coverage::Covered);
976    }
977
978    #[test]
979    fn a_block_beats_an_allow_as_in_windows() {
980        let rules = vec![
981            program_rule(EXE, Proto::Any, ALL, true),
982            program_rule(EXE, Proto::Tcp, PROFILE_PRIVATE, false),
983        ];
984        assert_eq!(coverage(&rules, EXE, &needs(), Category::Private), Coverage::Blocked);
985        // The other profiles are untouched by it.
986        assert_eq!(coverage(&rules, EXE, &needs(), Category::Domain), Coverage::Covered);
987    }
988
989    #[test]
990    fn a_block_for_a_different_program_does_not_block_us() {
991        let rules = vec![
992            program_rule(EXE, Proto::Any, ALL, true),
993            program_rule(OLD_EXE, Proto::Any, ALL, false),
994        ];
995        assert_eq!(coverage(&rules, EXE, &needs(), Category::Private), Coverage::Covered);
996    }
997
998    #[test]
999    fn tcp_alone_is_not_enough_mdns_needs_udp_too() {
1000        let rules = vec![program_rule(EXE, Proto::Tcp, PROFILE_PRIVATE, true)];
1001        assert_eq!(coverage(&rules, EXE, &needs(), Category::Private), Coverage::Missing);
1002    }
1003
1004    // ---- per adapter and overall
1005
1006    #[test]
1007    fn a_public_adapter_with_no_rule_is_not_trusted_rather_than_needs_setup() {
1008        let rules = vec![program_rule(EXE, Proto::Any, PROFILE_PRIVATE, true)];
1009        let public = adapter("Wi-Fi", Some(Category::Public));
1010        assert_eq!(adapter_state(&rules, EXE, &needs(), &public), AdapterState::PublicNotTrusted);
1011        let private = adapter("Ethernet", Some(Category::Private));
1012        assert_eq!(adapter_state(&rules, EXE, &needs(), &private), AdapterState::Covered);
1013    }
1014
1015    #[test]
1016    fn an_adapter_with_an_unknown_category_makes_no_claim() {
1017        let rules = vec![program_rule(EXE, Proto::Any, ALL, true)];
1018        assert_eq!(adapter_state(&rules, EXE, &needs(), &adapter("x", None)), AdapterState::Unknown);
1019    }
1020
1021    #[test]
1022    fn one_covered_adapter_is_enough_virtual_adapters_do_not_flag_a_working_lan() {
1023        use AdapterState::*;
1024        assert_eq!(overall(&[Covered, PublicNotTrusted, NeedsSetup, Blocked], false), FirewallStatus::Ok);
1025    }
1026
1027    #[test]
1028    fn overall_priority_is_block_then_managed_then_setup_then_public() {
1029        use AdapterState::*;
1030        assert_eq!(overall(&[NeedsSetup, Blocked], false), FirewallStatus::Blocked);
1031        assert_eq!(overall(&[NeedsSetup], true), FirewallStatus::Managed);
1032        assert_eq!(overall(&[PublicNotTrusted], true), FirewallStatus::Managed);
1033        assert_eq!(overall(&[PublicNotTrusted, NeedsSetup], false), FirewallStatus::NeedsSetup);
1034        assert_eq!(overall(&[PublicNotTrusted], false), FirewallStatus::PublicNetwork);
1035        assert_eq!(overall(&[Unknown, Unknown], false), FirewallStatus::Unknown);
1036        assert_eq!(overall(&[], false), FirewallStatus::Unknown);
1037    }
1038
1039    #[test]
1040    fn a_managed_policy_does_not_hide_a_working_lan() {
1041        assert_eq!(overall(&[AdapterState::Covered], true), FirewallStatus::Ok);
1042    }
1043
1044    #[test]
1045    fn the_report_ties_the_snapshot_together() {
1046        let snap = Snapshot {
1047            rules: vec![program_rule(EXE, Proto::Any, PROFILE_PRIVATE, true)],
1048            adapters: vec![
1049                adapter("Ethernet", Some(Category::Private)),
1050                adapter("vEthernet (Default Switch)", Some(Category::Public)),
1051            ],
1052            local_rules_ignored: false,
1053            profiles: Profiles::default(),
1054            unreadable_rules: 0,
1055        };
1056        let r = report(&snap, EXE, &needs());
1057        assert_eq!(r.status, FirewallStatus::Ok);
1058        assert_eq!(r.per_adapter[0].1, AdapterState::Covered);
1059        assert_eq!(r.per_adapter[1].1, AdapterState::PublicNotTrusted);
1060
1061        let none = Snapshot { rules: vec![], ..snap };
1062        assert_eq!(report(&none, EXE, &needs()).status, FirewallStatus::NeedsSetup);
1063    }
1064
1065    #[test]
1066    fn a_standing_problem_is_resent_every_tick_ok_and_unknown_only_on_change() {
1067        for problem in [
1068            FirewallStatus::NeedsSetup,
1069            FirewallStatus::Blocked,
1070            FirewallStatus::PublicNetwork,
1071            FirewallStatus::Managed,
1072        ] {
1073            assert!(should_publish(problem, false), "{problem:?}: a late window must learn of it");
1074        }
1075        for quiet in [FirewallStatus::Ok, FirewallStatus::Unknown] {
1076            assert!(!should_publish(quiet, false));
1077            assert!(should_publish(quiet, true), "{quiet:?}: a change must clear an earlier warning");
1078        }
1079    }
1080
1081    #[test]
1082    fn the_event_payload_is_the_frontend_contract() {
1083        let a = adapter("Ethernet", Some(Category::Private));
1084        let v = serde_json::to_value(payload(FirewallStatus::NeedsSetup, &[(a, AdapterState::NeedsSetup)], false)).unwrap();
1085        assert_eq!(v["status"], "needs-setup");
1086        assert_eq!(v["localRulesIgnored"], false);
1087        assert_eq!(v["adapters"][0]["name"], "Ethernet");
1088        assert_eq!(v["adapters"][0]["category"], "private");
1089        assert_eq!(v["adapters"][0]["state"], "needs-setup");
1090    }
1091
1092    #[test]
1093    fn a_rule_bound_to_a_service_or_store_app_is_not_an_any_program_rule() {
1094        // Found on narko: 25 Store-app rules have an empty program path.
1095        let store = FwRule { restricted: true, program: None, ..rule("Microsoft Store") };
1096        let rules = vec![store];
1097        assert_eq!(coverage(&rules, EXE, &needs(), Category::Private), Coverage::Missing);
1098    }
1099
1100    // ReAgent P1 on #4151: the profile's own switches decide whether any rule is needed.
1101    #[test]
1102    fn a_profile_with_the_firewall_off_needs_no_rule_and_blocks_nothing() {
1103        let off = ProfileSettings { enabled: false, ..ProfileSettings::default() };
1104        assert_eq!(coverage_with(&[], EXE, &needs(), Category::Private, off, &[]), Coverage::Covered);
1105        // Block rules are not enforced either when the firewall is off.
1106        let blocked = vec![program_rule(EXE, Proto::Any, ALL, false)];
1107        assert_eq!(coverage_with(&blocked, EXE, &needs(), Category::Private, off, &[]), Coverage::Covered);
1108    }
1109
1110    #[test]
1111    fn a_default_inbound_allow_needs_no_allow_rule_but_a_block_still_wins() {
1112        let open = ProfileSettings { default_inbound_allow: true, ..ProfileSettings::default() };
1113        assert_eq!(coverage_with(&[], EXE, &needs(), Category::Private, open, &[]), Coverage::Covered);
1114        let blocked = vec![program_rule(EXE, Proto::Tcp, PROFILE_PRIVATE, false)];
1115        assert_eq!(coverage_with(&blocked, EXE, &needs(), Category::Private, open, &[]), Coverage::Blocked);
1116    }
1117
1118    #[test]
1119    fn block_all_incoming_beats_every_allow_rule() {
1120        let all = ProfileSettings { block_all_inbound: true, ..ProfileSettings::default() };
1121        let rules = vec![program_rule(EXE, Proto::Any, ALL, true)];
1122        assert_eq!(coverage_with(&rules, EXE, &needs(), Category::Private, all, &[]), Coverage::Blocked);
1123    }
1124
1125    #[test]
1126    fn settings_are_per_profile_in_the_report() {
1127        let profiles = Profiles {
1128            private: ProfileSettings { enabled: false, ..ProfileSettings::default() },
1129            ..Profiles::default()
1130        };
1131        let snap = Snapshot {
1132            rules: vec![],
1133            adapters: vec![
1134                adapter("Ethernet", Some(Category::Private)),
1135                adapter("Wi-Fi", Some(Category::Public)),
1136            ],
1137            local_rules_ignored: false,
1138            profiles,
1139            unreadable_rules: 0,
1140        };
1141        let r = report(&snap, EXE, &needs());
1142        assert_eq!(r.per_adapter[0].1, AdapterState::Covered, "Private firewall is off");
1143        assert_eq!(r.per_adapter[1].1, AdapterState::PublicNotTrusted, "Public still defaults to block");
1144        assert_eq!(r.status, FirewallStatus::Ok);
1145    }
1146
1147    // Codex P2 on #4151: the UDP broadcast responder is a need too.
1148    #[test]
1149    fn the_udp_broadcast_responder_port_is_a_need() {
1150        let port = crate::backend::lan_discovery::UDP_DISCOVERY_PORT;
1151        assert!(needs().iter().any(|n| n.proto == Proto::Udp && n.port == port));
1152        // TCP plus mDNS but not the responder port: not covered.
1153        let tcp = FwRule { proto: Proto::Tcp, local_ports: PortSpec::parse("29700-29799"), ..rule("t") };
1154        let mdns = FwRule { proto: Proto::Udp, local_ports: PortSpec::parse("5353"), ..rule("u") };
1155        assert_eq!(coverage(&[tcp.clone(), mdns], EXE, &needs(), Category::Private), Coverage::Missing);
1156        // The desktop discovery port is a need too: a rule that names mDNS and the
1157        // mobile port but not it leaves peers unable to find us when mDNS is down.
1158        let no_desktop = FwRule { proto: Proto::Udp, local_ports: PortSpec::parse("5353,47891"), ..rule("u") };
1159        assert_eq!(coverage(&[tcp.clone(), no_desktop], EXE, &needs(), Category::Private), Coverage::Missing);
1160        let both = FwRule { proto: Proto::Udp, local_ports: PortSpec::parse("5353,29700,47891"), ..rule("u") };
1161        assert_eq!(coverage(&[tcp, both], EXE, &needs(), Category::Private), Coverage::Covered);
1162    }
1163
1164    // Codex P2 on #4151: a rule limited to one local IP counts only for its adapter.
1165    #[test]
1166    fn local_address_scopes_parse_the_forms_windows_uses() {
1167        assert_eq!(LocalScope::parse(""), LocalScope::Any);
1168        assert_eq!(LocalScope::parse("*"), LocalScope::Any);
1169        assert_eq!(LocalScope::parse("LocalSubnet"), LocalScope::Any);
1170        let ip = |s: &str| s.parse::<Ipv4Addr>().unwrap();
1171        let one = LocalScope::parse("192.168.1.26");
1172        assert!(one.applies_to(&[ip("192.168.1.26")]) && !one.applies_to(&[ip("192.168.1.27")]));
1173        let net = LocalScope::parse("192.168.1.0/24, 10.0.0.5");
1174        assert!(net.applies_to(&[ip("192.168.1.200")]) && net.applies_to(&[ip("10.0.0.5")]));
1175        assert!(!net.applies_to(&[ip("192.168.2.1")]));
1176        let masked = LocalScope::parse("172.17.16.0/255.255.255.0");
1177        assert!(masked.applies_to(&[ip("172.17.16.1")]) && !masked.applies_to(&[ip("172.17.17.1")]));
1178        let range = LocalScope::parse("192.168.1.10-192.168.1.20");
1179        assert!(range.applies_to(&[ip("192.168.1.15")]) && !range.applies_to(&[ip("192.168.1.21")]));
1180        // Keywords and junk match nothing; an IPv6-only list matches no IPv4 adapter.
1181        for bad in ["DHCP", "Defaultgateway", "192.168.1", "1.2.3.4/33", "9-1", "10.0.0.1-bad"] {
1182            assert_eq!(LocalScope::parse(bad), LocalScope::Unparseable, "{bad}");
1183        }
1184        assert!(!LocalScope::parse("fe80::1").applies_to(&[ip("192.168.1.26")]));
1185        // The adapter's own addresses decide, any one is enough.
1186        assert!(one.applies_to(&[ip("10.9.9.9"), ip("192.168.1.26")]));
1187        assert!(!one.applies_to(&[]));
1188    }
1189
1190    #[test]
1191    fn an_address_scoped_allow_covers_only_the_adapter_that_owns_the_address() {
1192        let scoped = |name: &str| FwRule {
1193            local_addresses: LocalScope::parse("192.168.1.26"),
1194            program: Some(EXE.to_string()),
1195            ..rule(name)
1196        };
1197        let rules = vec![scoped("a")];
1198        let owner = adapter("Ethernet", Some(Category::Private)); // 192.168.1.26
1199        let other = Adapter { ipv4: vec!["10.0.0.5".parse().unwrap()], ..adapter("Wi-Fi", Some(Category::Private)) };
1200        assert_eq!(adapter_state(&rules, EXE, &needs(), &owner), AdapterState::Covered);
1201        assert_eq!(adapter_state(&rules, EXE, &needs(), &other), AdapterState::NeedsSetup);
1202    }
1203
1204    #[test]
1205    fn an_address_scoped_block_does_not_block_other_adapters() {
1206        let allow = program_rule(EXE, Proto::Any, ALL, true);
1207        let block = FwRule { local_addresses: LocalScope::parse("10.0.0.5"), ..program_rule(EXE, Proto::Any, ALL, false) };
1208        let rules = vec![allow, block];
1209        let ethernet = adapter("Ethernet", Some(Category::Private)); // 192.168.1.26
1210        let wifi = Adapter { ipv4: vec!["10.0.0.5".parse().unwrap()], ..adapter("Wi-Fi", Some(Category::Private)) };
1211        assert_eq!(adapter_state(&rules, EXE, &needs(), &ethernet), AdapterState::Covered);
1212        assert_eq!(adapter_state(&rules, EXE, &needs(), &wifi), AdapterState::Blocked);
1213    }
1214
1215    // Codex P2 on #4151: an unreadable rule may be the block that decides the verdict.
1216    #[test]
1217    fn an_unreadable_rule_stops_the_snapshot_from_claiming_ok() {
1218        let snap = Snapshot {
1219            rules: vec![program_rule(EXE, Proto::Any, PROFILE_PRIVATE, true)],
1220            adapters: vec![adapter("Ethernet", Some(Category::Private))],
1221            local_rules_ignored: false,
1222            profiles: Profiles::default(),
1223            unreadable_rules: 0,
1224        };
1225        assert_eq!(report(&snap, EXE, &needs()).status, FirewallStatus::Ok);
1226
1227        let hidden = Snapshot { unreadable_rules: 1, ..snap.clone() };
1228        let r = report(&hidden, EXE, &needs());
1229        assert_eq!(r.status, FirewallStatus::Unknown, "coverage cannot be proven");
1230        assert_eq!(r.per_adapter[0].1, AdapterState::Covered, "the adapter-level reading is unchanged");
1231
1232        // A missing allow stays a warning: an unreadable rule does not make it less missing.
1233        let none = Snapshot { rules: vec![], unreadable_rules: 3, ..snap };
1234        assert_eq!(report(&none, EXE, &needs()).status, FirewallStatus::NeedsSetup);
1235    }
1236
1237    // ReAgent on #4151: skipping a block we cannot evaluate leans toward Covered.
1238    #[test]
1239    fn a_block_that_might_apply_cannot_be_skipped_toward_covered() {
1240        let allow = program_rule(EXE, Proto::Any, ALL, true);
1241        let block = || program_rule(EXE, Proto::Any, ALL, false);
1242        let cases = [
1243            // The review's example: a Block limited to the Wireless interface type,
1244            // next to an any-interface Allow, while Windows blocks that adapter.
1245            ("interface-scoped", FwRule { interface_scoped: true, ..block() }),
1246            ("restricted", FwRule { restricted: true, ..block() }),
1247            ("unparseable ports", FwRule { local_ports: PortSpec::Unparseable, ..block() }),
1248            ("unparseable local addresses", FwRule { local_addresses: LocalScope::Unparseable, ..block() }),
1249        ];
1250        for (label, b) in cases {
1251            let rules = vec![allow.clone(), b];
1252            assert_eq!(coverage(&rules, EXE, &needs(), Category::Private), Coverage::Uncertain, "{label}");
1253            let a = adapter("Ethernet", Some(Category::Private));
1254            assert_eq!(adapter_state(&rules, EXE, &needs(), &a), AdapterState::Unknown, "{label}");
1255        }
1256        let snap = Snapshot {
1257            rules: vec![allow.clone(), FwRule { interface_scoped: true, ..block() }],
1258            adapters: vec![adapter("Ethernet", Some(Category::Private))],
1259            ..Snapshot::default()
1260        };
1261        assert_eq!(report(&snap, EXE, &needs()).status, FirewallStatus::Unknown);
1262    }
1263
1264    #[test]
1265    fn a_block_that_cannot_apply_is_still_ignored() {
1266        let allow = program_rule(EXE, Proto::Any, ALL, true);
1267        for no in [
1268            FwRule { enabled: false, ..program_rule(EXE, Proto::Any, ALL, false) },
1269            FwRule { inbound: false, ..program_rule(EXE, Proto::Any, ALL, false) },
1270            program_rule(OLD_EXE, Proto::Any, ALL, false),
1271            program_rule(EXE, Proto::Any, PROFILE_PUBLIC, false),
1272            FwRule { proto: Proto::Other, ..program_rule(EXE, Proto::Any, ALL, false) },
1273            // A block for specific remote addresses does not block LAN peers in general.
1274            FwRule { remote: Remote::Other, ..program_rule(EXE, Proto::Any, ALL, false) },
1275            // Ports that exclude ours are settled, not uncertain.
1276            FwRule { local_ports: PortSpec::parse("80"), ..program_rule(EXE, Proto::Any, ALL, false) },
1277            FwRule { local_addresses: LocalScope::parse("10.9.9.9"), ..program_rule(EXE, Proto::Any, ALL, false) },
1278        ] {
1279            let rules = vec![allow.clone(), no.clone()];
1280            assert_eq!(coverage(&rules, EXE, &needs(), Category::Private), Coverage::Covered, "{no:?}");
1281        }
1282    }
1283
1284    #[test]
1285    fn an_uncertain_block_does_not_make_a_missing_allow_less_missing() {
1286        let uncertain = FwRule { interface_scoped: true, ..program_rule(EXE, Proto::Any, ALL, false) };
1287        assert_eq!(coverage(&[uncertain], EXE, &needs(), Category::Private), Coverage::Missing);
1288    }
1289
1290    #[test]
1291    fn a_certain_block_still_wins_over_an_uncertain_one() {
1292        let rules = vec![
1293            program_rule(EXE, Proto::Any, ALL, true),
1294            FwRule { interface_scoped: true, ..program_rule(EXE, Proto::Any, ALL, false) },
1295            program_rule(EXE, Proto::Tcp, PROFILE_PRIVATE, false),
1296        ];
1297        assert_eq!(coverage(&rules, EXE, &needs(), Category::Private), Coverage::Blocked);
1298    }
1299
1300    #[test]
1301    fn wire_names_are_the_frontend_contract() {
1302        assert_eq!(FirewallStatus::Ok.wire(), "ok");
1303        assert_eq!(FirewallStatus::NeedsSetup.wire(), "needs-setup");
1304        assert_eq!(FirewallStatus::Blocked.wire(), "blocked");
1305        assert_eq!(FirewallStatus::PublicNetwork.wire(), "public-network");
1306        assert_eq!(FirewallStatus::Managed.wire(), "managed");
1307        assert_eq!(FirewallStatus::Unknown.wire(), "unknown");
1308    }
1309}