diff --git a/crates/spt-daemon/src/bootstrap_firewall/linux.rs b/crates/spt-daemon/src/bootstrap_firewall/linux.rs index 88f1489e..08f615c4 100644 --- a/crates/spt-daemon/src/bootstrap_firewall/linux.rs +++ b/crates/spt-daemon/src/bootstrap_firewall/linux.rs @@ -60,7 +60,7 @@ fn command(name: &str, args: &[&str]) -> Result { let path = path .to_str() .ok_or_else(|| format!("{name} executable path is not UTF-8"))?; - run(path, args) + run(name, path, args) } fn unit_state(unit: &str) -> Option { @@ -712,3 +712,338 @@ pub(super) fn cleanup_command() -> String { "UFW: sudo ufw status numbered; sudo ufw show added; delete ONLY rules whose comment starts '{OWNER}' using sudo ufw --force delete NUMBER (re-list after each deletion). nft: sudo nft -a list ruleset; delete ONLY rules with that ownership comment using sudo nft delete rule FAMILY TABLE CHAIN handle HANDLE. firewalld: sudo firewall-cmd --info-policy={POLICY}; sudo firewall-cmd --permanent --policy={POLICY} --get-description (must equal '{POLICY_OWNER}'); remove each owned PORT/tcp with sudo firewall-cmd --policy={POLICY} --remove-port=PORT/tcp; sudo firewall-cmd --permanent --delete-policy={POLICY}. An inert runtime policy requires an operator-approved sudo firewall-cmd --reload AFTER preserving unrelated runtime configuration; bootstrap never performs that reload." ) } + +#[cfg(test)] +mod tests { + use super::*; + + use serde_json::json; + + // A 64-character lowercase-hex digest, fixed so every negative arm below is + // a statement about the grammar rather than about one path's hash. + fn hex() -> String { + "0123456789abcdef".repeat(4) + } + + // The refusal text of a Result whose success value need not be printable. + #[track_caller] + fn refusal(result: Result) -> String { + match result { + Ok(_) => panic!("expected a refusal, got an admission decision"), + Err(error) => error, + } + } + + fn ufw(body: &str) -> UfwRule { + UfwRule { number: 1, body: body.into(), comment: String::new() } + } + + fn ufw_commented(number: u32, body: &str, comment: &str) -> UfwRule { + UfwRule { number, body: body.into(), comment: comment.into() } + } + + // Backend has no PartialEq in the product; name the arm instead of adding one. + fn backend_name(result: Result) -> String { + match result { + Ok(Backend::Ufw) => "ufw".into(), + Ok(Backend::Firewalld) => "firewalld".into(), + Ok(Backend::Nft) => "nft".into(), + Err(error) => format!("refused: {error}"), + } + } + + fn inet_input_chain() -> Value { + json!({"chain": { + "family": "inet", "table": "filter", "name": "input", "handle": 1, + "type": "filter", "hook": "input", "prio": 0, "policy": "accept", + }}) + } + + fn dport(port: u64) -> Value { + json!({"match": { + "op": "==", + "left": {"payload": {"protocol": "tcp", "field": "dport"}}, + "right": port, + }}) + } + + fn nft_rule(comment: &str, expr: Value, handle: Option) -> Value { + let mut rule = json!({ + "family": "inet", "table": "filter", "chain": "input", + "comment": comment, "expr": expr, + }); + if let Some(handle) = handle { + rule["handle"] = json!(handle); + } + json!({"rule": rule}) + } + + // [unit->REQ-WEB-LAN-BOOTSTRAP-FIREWALL] + #[test] + fn marker_is_owner_prefixed_lowercase_hex_bound_to_the_binder_path() { + let one = marker(Path::new("/usr/local/bin/spt")); + let two = marker(Path::new("/opt/spt/bin/spt")); + for identity in [&one, &two] { + let digest = identity + .strip_prefix(OWNER) + .unwrap_or_else(|| panic!("identity carries the ownership prefix: {identity}")); + assert_eq!(digest.len(), 64, "identity is a full hex SHA-256: {identity}"); + assert!( + digest.bytes().all(|c| c.is_ascii_digit() || (b'a'..=b'f').contains(&c)), + "identity digest is lowercase hex: {identity}" + ); + } + assert_ne!(one, two, "a different binder path yields a different owned identity"); + } + + // [unit->REQ-WEB-LAN-BOOTSTRAP-FIREWALL] + #[test] + fn owned_accepts_only_this_versions_full_lowercase_hex_identity() { + let identity = marker(Path::new("/usr/local/bin/spt")); + assert!(owned(&identity), "marker output is owned: {identity}"); + + let digest = hex(); + assert!(owned(&format!("{OWNER}{digest}")), "a full lowercase-hex digest is owned"); + assert!(!owned(&digest), "a digest with no ownership prefix is not owned"); + assert!(!owned(""), "an empty comment is not owned"); + assert!(!owned("spt-bootstrap-v2-"), "a different ownership version is not owned"); + assert!(!owned(&format!("{OWNER}{}", &digest[..63])), "a 63-character digest is not owned"); + assert!(!owned(&format!("{OWNER}{digest}a")), "a 65-character digest is not owned"); + assert!( + !owned(&format!("{OWNER}{}", digest.to_ascii_uppercase())), + "uppercase hex is not owned" + ); + assert!(!owned(&format!("{OWNER}{}g", &digest[..63])), "a non-hex character is not owned"); + } + + // [unit->REQ-WEB-LAN-BOOTSTRAP-FIREWALL] + #[test] + fn ufw_scope_accepts_only_unrestricted_single_tcp_port_allowances() { + assert_eq!( + ufw_scope(&ufw("5470/tcp ALLOW IN Anywhere")).unwrap(), + (5470, false), + "an IPv4 Anywhere allowance names its port and family" + ); + assert_eq!( + ufw_scope(&ufw("5470/tcp (v6) ALLOW IN Anywhere (v6)")).unwrap(), + (5470, true), + "an IPv6 Anywhere allowance names its port and family" + ); + + for body in [ + "5470/tcp ALLOW IN 192.168.1.0/24", + "5470/tcp ALLOW IN Anywhere on eth0", + "5470/tcp DENY IN Anywhere", + ] { + let error = ufw_scope(&ufw(body)).unwrap_err(); + assert!(error.contains("unrecognized/restricted scope"), "{body} => {error}"); + } + + for body in ["5470 ALLOW IN Anywhere", "5470/udp ALLOW IN Anywhere", "0/tcp ALLOW IN Anywhere"] { + let error = ufw_scope(&ufw(body)).unwrap_err(); + assert!(error.contains("not a single TCP port"), "{body} => {error}"); + } + } + + // [unit->REQ-WEB-LAN-BOOTSTRAP-FIREWALL] + #[test] + fn identifier_matches_nfts_unquoted_name_grammar() { + assert!(identifier("filter"), "a plain name needs no quoting"); + assert!(identifier("_private"), "a leading underscore needs no quoting"); + assert!(identifier("chain_2"), "a digit after the first byte needs no quoting"); + assert!(!identifier(""), "an empty name is not an identifier"); + assert!(!identifier("1filter"), "a leading digit requires quoting"); + assert!(!identifier("my chain"), "whitespace requires quoting"); + assert!(!identifier("my-chain"), "a hyphen requires quoting"); + } + + // [unit->REQ-WEB-LAN-BOOTSTRAP-FIREWALL] + #[test] + fn nft_input_requires_exactly_one_unrestricted_inet_filter_input_chain() { + let snapshot = [json!({"table": {"family": "inet", "name": "filter"}}), inet_input_chain()]; + let input = nft_input(&snapshot).unwrap(); + assert_eq!( + (input.table.as_str(), input.name.as_str()), + ("filter", "input"), + "the sole inet/filter input chain is adopted" + ); + + let empty: [Value; 0] = []; + let error = refusal(nft_input(&empty)); + assert!(error.contains("no recognized existing inet input chain"), "{error}"); + + let mut second = inet_input_chain(); + second["chain"]["name"] = json!("input2"); + let error = refusal(nft_input(&[inet_input_chain(), second])); + assert!(error.contains("one unrestricted inet/filter input base chain"), "{error}"); + + let mut ipv4_only = inet_input_chain(); + ipv4_only["chain"]["family"] = json!("ip"); + let mut ipv6_only = inet_input_chain(); + ipv6_only["chain"]["family"] = json!("ip6"); + let mut device = inet_input_chain(); + device["chain"]["dev"] = json!("eth0"); + let mut devices = inet_input_chain(); + devices["chain"]["devices"] = json!(["eth0"]); + let mut nat = inet_input_chain(); + nat["chain"]["type"] = json!("nat"); + for chain in [ipv4_only, ipv6_only, device, devices, nat] { + let error = refusal(nft_input(std::slice::from_ref(&chain))); + assert!(error.contains("one unrestricted inet/filter input base chain"), "{chain} => {error}"); + } + + let mut quoted = inet_input_chain(); + quoted["chain"]["table"] = json!("1filter"); + let error = refusal(nft_input(&[quoted])); + assert!(error.contains("requires unsupported quoting"), "{error}"); + } + + // [unit->REQ-WEB-LAN-BOOTSTRAP-FIREWALL] + #[test] + fn nft_owned_collects_only_exactly_shaped_owned_tcp_accepts() { + let identity = marker(Path::new("/usr/local/bin/spt")); + let unowned = nft_rule("administrator ssh", json!([dport(22), {"accept": null}]), Some(7)); + let mine = nft_rule(&identity, json!([dport(5470), {"accept": null}]), Some(9)); + + let rules = nft_owned(&[unowned, mine]).unwrap(); + assert_eq!(rules.len(), 1, "an unowned comment is skipped, never adopted"); + assert_eq!( + (rules[0].port, rules[0].handle, rules[0].comment.as_str()), + (5470, 9, identity.as_str()), + "the owned rule keeps its port, deletion handle, and identity" + ); + + // nft may render the implicit TCP dependency as an explicit l4proto match. + let l4proto = json!({"match": {"op": "==", "left": {"meta": {"key": "l4proto"}}, "right": "tcp"}}); + let normalised = nft_rule(&identity, json!([l4proto, dport(5470), {"accept": null}]), Some(9)); + assert_eq!( + nft_owned(&[normalised]).unwrap()[0].port, + 5470, + "a leading l4proto match is normalised away, not refused" + ); + + let iifname = json!({"match": {"op": "==", "left": {"meta": {"key": "iifname"}}, "right": "eth0"}}); + for (expr, needle) in [ + (json!([dport(5470), iifname, {"accept": null}]), "unsupported predicates/actions"), + (json!([dport(5470), {"drop": null}]), "unsupported predicates/actions"), + (json!([dport(5470)]), "unsupported predicates/actions"), + (json!([dport(0), {"accept": null}]), "does not name one TCP destination port"), + ] { + let error = refusal(nft_owned(&[nft_rule(&identity, expr, Some(9))])); + assert!(error.contains(needle), "{error}"); + } + + let handleless = nft_rule(&identity, json!([dport(5470), {"accept": null}]), None); + let error = refusal(nft_owned(&[handleless])); + assert!(error.contains("no deletion handle"), "{error}"); + + let stranger = nft_rule( + &format!("{OWNER}not-a-digest"), + json!([dport(5470), {"accept": null}]), + Some(9), + ); + let error = refusal(nft_owned(&[stranger])); + assert!(error.contains("unknown ownership version/identity"), "{error}"); + } + + // [unit->REQ-WEB-LAN-BOOTSTRAP-FIREWALL] + #[test] + fn backend_from_ranks_an_active_manager_above_the_backend_it_writes_into() { + // The whole truth table: an active manager outranks the backend it + // writes into, and nft is the fallback rather than a peer. + for (ufw, firewalld, nft_present, expected) in [ + (true, true, true, "ufw"), + (true, true, false, "ufw"), + (true, false, true, "ufw"), + (true, false, false, "ufw"), + (false, true, true, "firewalld"), + (false, true, false, "firewalld"), + (false, false, true, "nft"), + ] { + assert_eq!( + backend_name(backend_from(ufw, firewalld, nft_present)), + expected, + "ufw={ufw} firewalld={firewalld} nft={nft_present}" + ); + } + let refused = backend_name(backend_from(false, false, false)); + assert!(refused.contains("no readable UFW, firewalld, or nft backend"), "{refused}"); + } + + // [unit->REQ-WEB-LAN-BOOTSTRAP-FIREWALL] + #[test] + fn parse_ufw_rules_reads_a_numbered_listing_and_refuses_every_other_shape() { + let identity = marker(Path::new("/usr/local/bin/spt")); + let listing = format!( + "Status: active\n\ + \n\ + To Action From\n\ + -- ------ ----\n\ + [ 1] 5470/tcp ALLOW IN Anywhere # {identity}\n\ + [ 2] 22/tcp ALLOW IN Anywhere\n\ + [10] 5470/tcp (v6) ALLOW IN Anywhere (v6) # {identity}\n" + ); + let rules = parse_ufw_rules(&listing).unwrap(); + assert_eq!(rules.len(), 3, "header, separator and blank lines are not rules"); + assert_eq!( + rules.iter().map(|rule| rule.number).collect::>(), + [1, 2, 10], + "the bracketed number is the deletion handle and is read as written" + ); + assert_eq!(rules[0].body, "5470/tcp ALLOW IN Anywhere", "column padding is normalised away"); + assert_eq!(rules[0].comment, identity, "the ownership comment survives the split"); + assert_eq!(rules[1].comment, "", "a rule with no comment carries an empty one"); + assert_eq!(rules[2].body, "5470/tcp (v6) ALLOW IN Anywhere (v6)"); + + let error = refusal(parse_ufw_rules("Status: inactive\n")); + assert!(error.contains("UFW is inactive"), "{error}"); + let error = refusal(parse_ufw_rules("")); + assert!(error.contains("UFW is inactive"), "{error}"); + let error = refusal(parse_ufw_rules("Status: active\n5470/tcp ALLOW IN Anywhere\n")); + assert!(error.contains("unrecognized UFW numbered rule output"), "{error}"); + let error = refusal(parse_ufw_rules("Status: active\n[ x] 5470/tcp ALLOW IN Anywhere\n")); + assert!(error.contains("invalid UFW rule number"), "{error}"); + } + + // [unit->REQ-WEB-LAN-BOOTSTRAP-FIREWALL] + #[test] + fn ufw_preflight_never_adopts_a_rule_it_does_not_own() { + let identity = marker(Path::new("/usr/local/bin/spt")); + assert!(ufw_preflight(&[], 5470).is_ok(), "an empty ruleset admits the insertion"); + + let mine = ufw_commented(1, "5470/tcp ALLOW IN Anywhere", &identity); + let elsewhere = ufw_commented(2, "22/tcp ALLOW IN Anywhere", "administrator ssh"); + assert!( + ufw_preflight(&[mine, elsewhere], 5470).is_ok(), + "our own rule and an unrelated port are both fine" + ); + + // UFW deduplicates by scope, not by comment: inserting ours beside an + // administrator's allowance for the same port would silently adopt it. + for body in ["5470/tcp ALLOW IN Anywhere", "5470 ALLOW IN Anywhere"] { + let theirs = ufw_commented(1, body, "administrator web"); + let error = refusal(ufw_preflight(&[theirs], 5470)); + assert!(error.contains("unowned rule for port 5470"), "{body} => {error}"); + } + + let foreign = ufw_commented(1, "5470/tcp ALLOW IN Anywhere", &format!("{OWNER}not-a-digest")); + let error = refusal(ufw_preflight(&[foreign], 5470)); + assert!(error.contains("unrecognized bootstrap ownership comment"), "{error}"); + + let restricted = ufw_commented(1, "5470/tcp ALLOW IN 192.168.1.0/24", &identity); + let error = refusal(ufw_preflight(&[restricted], 5470)); + assert!(error.contains("unrecognized/restricted scope"), "{error}"); + } + + // Hermetic by reachability, not by luck: the zero-port refusal is the FIRST + // statement of `reconcile` and returns before `backend()`, so this cell + // resolves no executable, runs no manager, and reads no host firewall. An + // edit that moves any probe above that refusal turns this cell into a + // host-toucher -- the refusal must stay first. + // [unit->REQ-WEB-LAN-BOOTSTRAP-FIREWALL] + #[test] + fn reconcile_refuses_port_zero_before_touching_any_host_firewall() { + let error = reconcile(Path::new("/usr/local/bin/spt"), 0).unwrap_err(); + assert!(error.contains("nonzero bound TCP port"), "{error}"); + } +} diff --git a/crates/spt-daemon/src/bootstrap_firewall/windows.rs b/crates/spt-daemon/src/bootstrap_firewall/windows.rs index 57e32e52..848a23fe 100644 --- a/crates/spt-daemon/src/bootstrap_firewall/windows.rs +++ b/crates/spt-daemon/src/bootstrap_firewall/windows.rs @@ -29,6 +29,32 @@ const RULE_GROUP: &str = "spt-core bootstrap TCP"; /// and `desired_specs` all derive from these constants instead of repeating them. const RULE_NAMES: &[&str] = &[RULE_NAME_TAILNET, RULE_NAME_LAN]; +/// The one ActiveStore enforcement code that certifies a rule is in force. +/// +/// A NUMBER, NEVER A SPELLING. The host renders this code as a display string, and +/// which string is a property of the host rather than of the rule: measured on +/// HFENDULEAM under the product's own invocation mode, raw element 1 reads back as +/// "Enforced" while the published specification names the same code Full. Comparing +/// the rendered text refused the SUCCESS VALUE ITSELF and no firewall state could +/// satisfy the arm. The code is documentation-sourced; the rendering is not, and this +/// constant deliberately carries no name for it. +// [impl->REQ-BOOTSTRAP-FIREWALL-ENFORCEMENT-CODES] +const ENFORCEMENT_SUCCESS: u16 = 1; + +/// The COMPLETE certified evidence: exactly one element, the success code. +/// +/// STRICTNESS IS THE REQUIREMENT, not an implementation detail. This is compared with +/// `!=` against the whole slice, so `[1, 1]` and `[1, 5]` both refuse. Equality must +/// never become membership: a success code sitting beside another code means the host +/// reported something else as well. What an array of several codes MEANS is not +/// resolved -- the documented values describe individual codes and say nothing about +/// how a multi-element array should be read -- so this policy refuses rather than +/// interpreting. Empty, unknown and additional values all refuse here, and +/// anything the query could not transport as a number never reaches this comparison at +/// all -- it fails deserialization and surfaces as a query error instead. +// [impl->REQ-BOOTSTRAP-FIREWALL-ENFORCEMENT-CODES] +const ENFORCEMENT_CERTIFIED: [u16; 1] = [ENFORCEMENT_SUCCESS]; + // Enumerate then compare the exact immutable name: a failed query must not be // confused with NetSecurity's non-terminating 'named object not found' error. const OWNERSHIP: &str = r#" @@ -41,6 +67,21 @@ function Value($object, $name) { if ($null -eq $property) { throw "NetSecurity omitted required property $name" } $property.Value } +# RAW CIM ELEMENTS, DELIBERATELY NOT THE ADAPTED PROPERTY. `Value` above returns what +# PowerShell's object adapter renders, and for EnforcementStatus that is a DISPLAY +# SPELLING: measured on HFENDULEAM under this very invocation mode (powershell.exe +# 5.1.26100.8875), raw element UInt16 1 reads back as the string "Enforced", while the +# verdict compared against "Full" -- so the success value itself was refused and no host +# state could satisfy the arm. The repair is this ACCESSOR, not a cast: casting the +# adapted value throws on "Enforced". CimInstanceProperties is the unadapted CIM view. +function RawValue($object, $name) { + $property = $object.CimInstanceProperties[$name] + if ($null -eq $property) { throw "ENFORCEMENT_REPRESENTATION_FAULT: NetSecurity omitted required CIM property $name" } + if ([string]$property.CimType -ne 'UInt16Array') { + throw "ENFORCEMENT_REPRESENTATION_FAULT: CIM property $name has type $($property.CimType), expected UInt16Array" + } + $property.Value +} function One-Filter($items, $name, $ruleName) { $items = @($items) if ($items.Count -ne 1) { throw "Ambiguous or missing $name filter for $ruleName" } @@ -83,7 +124,7 @@ function Optional-Empty($object, $name) { if ($null -eq $property) { return $true } Empty $object $name } -function Describe($rule, $active) { +function Describe($rule) { Assert-Owned $rule $name = [string](Value $rule 'Name') $port = One-Filter @($rule | Get-NetFirewallPortFilter -ErrorAction Stop) 'port' $name @@ -123,10 +164,35 @@ function Describe($rule, $active) { $programText = if ($program.Count -eq 1) { [string]$program[0] } else { '' } # Enforcement is distinct from the rule's configuration. Never certify a # rule which ActiveStore reports as ignored or ineffective under host policy. - $enforcement = @() - if ($active) { $enforcement = @(Value $rule 'EnforcementStatus' | ForEach-Object { [string]$_ }) } + # NUMERIC CODES, NEVER DISPLAY SPELLINGS -- see RawValue. + # + # VALIDATE BEFORE CONVERTING. `[int]` is a COERCION, not a check, and it launders + # exactly the evidence this contract exists to reject: [int]$null and [int]$false + # are 0, [int]$true is 1 -- THE SUCCESS CODE -- and [int]'1', [int]1.4 and [int]1.6 + # are 1, 1 and 2. Converting first and judging afterwards would let a boolean, a + # numeric string or a rounded fraction reach the verdict wearing a code the host + # never reported. Each element's TYPE is therefore checked against the CIM element + # type first, and only a genuine UInt16 is converted; the property's own CIM type + # is checked in RawValue for the same reason. Everything else refuses loudly here + # rather than travelling onward as evidence. + # [impl->REQ-BOOTSTRAP-FIREWALL-ENFORCEMENT-CODES] + $enforcementRaw = RawValue $rule 'EnforcementStatus' + if ($null -eq $enforcementRaw) { throw "ENFORCEMENT_REPRESENTATION_FAULT: null EnforcementStatus for $name" } + $enforcement = @($enforcementRaw | ForEach-Object { + if ($null -eq $_) { throw "ENFORCEMENT_REPRESENTATION_FAULT: null EnforcementStatus element for $name" } + if ($_ -isnot [uint16]) { + throw "ENFORCEMENT_REPRESENTATION_FAULT: EnforcementStatus element of type $($_.GetType().FullName) for $name, expected System.UInt16" + } + [int]$_ + }) + # WHERE THE EFFECTIVE RULE CAME FROM. Reported, never judged here: the Rust + # side decides what counts as persistent. 'Local' names the local persistent + # store as this effective rule's source, which is how ONE ActiveStore pass + # certifies both halves of the claim -- see `decide`. + $sourceType = [string](Value $rule 'PolicyStoreSourceType') [pscustomobject]@{ name = $name + sourceType = $sourceType program = $programText ports = @(Value $port 'LocalPort' | ForEach-Object { [string]$_ }) profile = [string](Value $rule 'Profile') @@ -135,9 +201,28 @@ function Describe($rule, $active) { enforcement = $enforcement } } -$persistent = @(Named-Rules 'PersistentStore' | ForEach-Object { Describe $_ $false }) -$active = @(Named-Rules 'ActiveStore' | ForEach-Object { Describe $_ $true }) -[pscustomobject]@{ persistent = $persistent; active = $active } | ConvertTo-Json -Depth 6 -Compress +# ONE STORE PASS, NOT TWO. The old query enumerated PersistentStore and +# ActiveStore and walked every rule in both: two rule enumerations plus 32 filter +# cmdlet calls for a written pair, where the cost is ~85-90% PER CALL (measured +# HFENDULEAM 2026-09-12: a call returning 2 rules ~470 ms, one returning ~1000 +# ~550 ms). The second pass bought PERSISTENCE evidence, and an effective rule +# already carries it: PolicyStoreSourceType 'Local' names the local persistent +# store as its source. Halved to one enumeration plus 16 filter calls. +$active = @(Named-Rules 'ActiveStore' | ForEach-Object { Describe $_ }) +# THE LAN HALF'S SCOPE IS DERIVED FROM THE HOST, so the census it is derived from +# rides THE SAME INVOCATION as the rules it will be compared against. A prefix set +# read by a separate process could describe a different network than the one the +# verdict is taken over -- a laptop that joins a subnet between two invocations +# would make the rules and the expectation disagree for reasons neither carries. +# REPORTED, NOT JUDGED, like every other field here: which addresses count as a +# LAN, and what their prefixes are, is decided in Rust where it can be tested. +$addresses = @(Get-NetIPAddress -AddressFamily IPv4 -PolicyStore ActiveStore -ErrorAction Stop | + ForEach-Object { [pscustomobject]@{ + address = [string](Value $_ 'IPAddress') + prefixLength = [int](Value $_ 'PrefixLength') + addressState = [string](Value $_ 'AddressState') + } }) +[pscustomobject]@{ active = $active; addresses = $addresses } | ConvertTo-Json -Depth 6 -Compress "#; const REMOVE_AND_VERIFY: &str = r#" @@ -169,7 +254,26 @@ struct Rule { /// shape (enabled, inbound, allow, no platform/owner scope, no user or /// machine restriction, no dynamic keyword). Policy is decided in Rust. hygiene: bool, - enforcement: Vec, + /// ActiveStore enforcement as RAW NUMERIC CODES, never the host's display + /// spelling of them. `u16` is the CIM element type, and it is load-bearing: + /// anything the query cannot transport as a number -- a null, a display + /// string, a negative -- fails deserialization and surfaces as a query error + /// rather than arriving as a code the verdict would then judge. + /// + /// NO NAME IS ATTACHED TO ANY CODE, here or anywhere downstream. The captured + /// class on the measuring host exposes ValueMap (0..25) with the Values + /// qualifier ABSENT, so no code-to-name mapping is derivable from the host at + /// all -- including for the success code. Naming one would be documentation + /// smuggled in as an observation. + // [impl->REQ-BOOTSTRAP-FIREWALL-ENFORCEMENT-CODES] + enforcement: Vec, + /// Which store this EFFECTIVE rule came from, as NetSecurity reports it: + /// `Local` for the local persistent store, `GroupPolicy` for domain policy, + /// `Dynamic` for a rule that exists only until reboot. Reported here and + /// judged in [`decide`] -- it is what makes one ActiveStore pass able to + /// certify persistence as well as effectiveness. + #[serde(rename = "sourceType")] + source_type: String, } // ── FOLD-2: scope policy (ruled on arm E, 2026-09-12) ──────────────────────── @@ -181,9 +285,11 @@ struct Rule { /// The tailnet half: CGNAT remotes, on every profile. const DESIRED_TAILNET_PROFILE: &str = "Any"; const DESIRED_TAILNET_REMOTES: &[&str] = &["100.64.0.0/10"]; -/// The LAN half: `LocalSubnet`, on the profiles a LAN actually carries. +/// The LAN half's profiles. Its REMOTES are no longer a constant: FOLD-3 derives +/// them per host from the connected IPv4 prefixes (see [`lan_scope`]), because +/// `LocalSubnet` was MEASURED NOT TO ADMIT on Windows where a literal prefix did. /// -/// TWO RULES, NOT ONE. `LocalSubnet` on an all-profile rule would admit the +/// TWO RULES, NOT ONE. A LAN prefix on an all-profile rule would admit the /// local subnet of a PUBLIC network too, so the halves cannot be merged by /// unioning their remotes; and a single rule carrying only the CGNAT range /// admits no LAN peer at all, which is what this surface exists to do @@ -191,7 +297,6 @@ const DESIRED_TAILNET_REMOTES: &[&str] = &["100.64.0.0/10"]; /// The operator hand-rule covering 192.168.1.0/24 on the development box is why /// the missing half was not noticed from a working fetch. const DESIRED_LAN_PROFILE: &str = "Private,Domain"; -const DESIRED_LAN_REMOTES: &[&str] = &["LocalSubnet"]; /// Whether the rule carries a program (application) filter at all. /// /// `false` BY POLICY, not because program scope fails. It does not fail. @@ -241,6 +346,75 @@ struct RuleSpec { program: Option, } +/// What LAN scope this host can actually carry. +/// +/// `NoneConnected` is a real state, not an error case to fall out of: a box on +/// nothing but a tailnet address, or holding only an APIPA autoconfiguration +/// address, has NO LAN to admit, and an empty remote set would render a rule +/// admitting nothing while reading like a written half. +#[derive(Debug, Clone, PartialEq, Eq)] +enum LanScope { + /// At least one literal prefix, lowest first, deduplicated. + Prefixes(Vec), + NoneConnected, +} + +/// FOLD-3 (releases#304 W2, ruled 2026-09-12): the LAN half's remotes are the +/// LITERAL PREFIXES of the host's connected IPv4 interfaces. +/// +/// WHY NOT `LocalSubnet`, WHICH IS WHAT THIS REPLACES: measured on HFENDULEAM +/// 2026-09-12, a rule whose only difference from an admitting one is +/// `RemoteAddress=LocalSubnet` DROPS where a literal `192.168.1.0/24` ADMITS -- +/// same port, listener, peer and minute, four-point discrimination plus a +/// one-column A/B. The product's own LAN admission did not admit. +/// +/// THE COST OF THE FIX, NAMED: the scope now depends on HOST STATE, so a pair +/// written on one subnet does not verify on another and the next reconcile +/// rewrites it. That rewrite IS the repair -- a rule scoped to a network the box +/// has left admits nobody -- but it is a behaviour change and ADR-0059 +/// Amendment 2 says so. +/// +/// THE EXCLUSIONS ARE THE POLICY, and each is a refusal to widen admission: +/// loopback needs no rule; link-local (APIPA) is the absence of a network, not +/// one; CGNAT is the TAILNET half's scope and duplicating it here would put the +/// tailnet range on a rule carrying the LAN half's profiles; an address the host +/// will not route from (anything but `Preferred`) is not a network we are on; +/// and a prefix length outside 8..=32 is REFUSED RATHER THAN RENDERED, because +/// `/0` would render `0.0.0.0/0` and admit the entire internet to this port. +// [impl->REQ-WEB-LAN-BOOTSTRAP-FIREWALL] +fn lan_scope(addresses: &[Address]) -> LanScope { + let mut prefixes: Vec = addresses + .iter() + .filter(|address| address.address_state == "Preferred") + .filter(|address| (8..=32).contains(&address.prefix_length)) + .filter_map(|address| { + let parsed: std::net::Ipv4Addr = address.address.parse().ok()?; + let octets = u32::from(parsed); + if parsed.is_loopback() || parsed.is_link_local() || parsed.is_unspecified() { + return None; + } + // CGNAT 100.64.0.0/10 is the tailnet half's scope, by the constant + // above rather than by a second spelling of the range. + if octets & 0xffc0_0000 == u32::from(std::net::Ipv4Addr::new(100, 64, 0, 0)) { + return None; + } + let mask = u32::MAX << (32 - u32::from(address.prefix_length)); + let network = std::net::Ipv4Addr::from(octets & mask); + Some(format!("{network}/{}", address.prefix_length)) + }) + .collect(); + prefixes.sort(); + prefixes.dedup(); + if prefixes.is_empty() { LanScope::NoneConnected } else { LanScope::Prefixes(prefixes) } +} + +/// The face of a host that cannot carry the LAN half at all. +// [impl->REQ-WEB-LAN-BOOTSTRAP-FIREWALL] +fn no_lan_scope_message() -> String { + "no connected IPv4 interface: the LAN half of the bootstrap admission pair cannot be scoped, so LAN admission is UNVERIFIED. The tailnet half is unaffected. Elevation cannot repair this; connect the machine to a network and rerun bootstrap." + .to_string() +} + /// Build the desired spec from the binder and THE PORT THAT WAS BOUND. /// /// The bound port is the caller's contract (`verify`/`reconcile` both refuse @@ -251,24 +425,29 @@ struct RuleSpec { /// broken one, and returning both from one place is what stops a caller from /// silently handling only the half it remembered. // [impl->REQ-WEB-LAN-BOOTSTRAP-FIREWALL] -fn desired_specs(binder_normalized: &str, bound_port: u16) -> [RuleSpec; 2] { +fn desired_specs(binder_normalized: &str, bound_port: u16, lan: &LanScope) -> Vec { let program = DESIRED_PROGRAM.then(|| binder_normalized.to_string()); - [ - RuleSpec { - name: RULE_NAME_TAILNET, - port: bound_port, - profile: DESIRED_TAILNET_PROFILE.to_string(), - remotes: DESIRED_TAILNET_REMOTES.iter().map(|s| s.to_string()).collect(), - program: program.clone(), - }, - RuleSpec { + let mut specs = vec![RuleSpec { + name: RULE_NAME_TAILNET, + port: bound_port, + profile: DESIRED_TAILNET_PROFILE.to_string(), + remotes: DESIRED_TAILNET_REMOTES.iter().map(|s| s.to_string()).collect(), + program: program.clone(), + }]; + // THE LAN HALF IS OMITTED, NOT EMPTIED, when the host carries no LAN. A spec + // with an empty remote set would be rendered as a rule admitting nothing and + // would read in every later census like a written half; an absent spec is + // visibly absent, and `no_lan_scope_message` is the face the caller reports. + if let LanScope::Prefixes(prefixes) = lan { + specs.push(RuleSpec { name: RULE_NAME_LAN, port: bound_port, profile: DESIRED_LAN_PROFILE.to_string(), - remotes: DESIRED_LAN_REMOTES.iter().map(|s| s.to_string()).collect(), + remotes: prefixes.clone(), program, - }, - ] + }); + } + specs } /// The `New-NetFirewallRule` calls that write the pair, rendered from the specs. @@ -319,6 +498,152 @@ fn pair_satisfied_by(observed: &[Rule], want: &[RuleSpec]) -> bool { }) } +// ── FOLD-4: representation, not policy (releases#304 W2, field-measured) ───── +// THE DEFECT THESE THREE CLOSE: the field reconcile WROTE the pair and then +// refused its own rules. `decide` returned `Ok(false)` out of `pair_satisfied_by` +// -- at the SPEC MATCH, before the source-store, LAN-scope and enforcement arms +// were ever reached -- because NetSecurity reads a rule back in ITS spelling, +// not ours: an absent application filter renders as the literal `Any`, a profile +// set comes back in NetSecurity's own flag order with a space (`Domain, Private` +// against our `Private,Domain`), and an IPv4 network comes back in mask form +// (`192.168.1.0/255.255.255.0` against our `192.168.1.0/24`). Three string +// comparisons, each independently sufficient to fail, over values that name the +// SAME admission. +// +// THE LIMIT ON THAT ACCOUNT, and it is a limit rather than a hedge: these are +// PREDICTED from the source plus a census taken AFTER the failure (todlando, +// HFENDULEAM 2026-09-12 06:30-06:31Z). The product never logs its own QUERY +// snapshot, so the failure-time matcher inputs are not recoverable from that run +// and no single field can be named as the one that actually decided it. +// +// WHAT THESE MAY NOT DO, which is the dangerous direction and the reason each is +// a narrow token normalizer rather than a permissive parse: a genuinely narrower +// or wider scope must STAY REJECTED, malformed input must answer "does not +// satisfy" rather than parse loosely or panic, and none of this may reach the +// `decide`-level arms -- hygiene, source store and enforcement are decided on +// fields these functions never see. + +/// Whether an observed application filter means NO application filter. +/// +/// NetSecurity renders an unfiltered rule's `Program` as the literal `Any`; it +/// never renders the empty string a `RuleSpec` with `program: None` was written +/// to mean. Both spellings say the same thing -- this rule is not scoped to an +/// executable -- so both satisfy a spec that wants none, and NEITHER satisfies a +/// spec that wants a program (see [`spec_satisfied_by`]): `Any` is the ABSENCE +/// of the filter, never a path that happens to be spelled that way. +// [impl->REQ-BOOTSTRAP-FIREWALL-SPELLING-EQUIVALENCE] +fn program_unrestricted(observed: &str) -> bool { + let observed = observed.trim(); + observed.is_empty() || observed.eq_ignore_ascii_case("Any") +} + +/// A profile field as the SET of flag tokens it names. +/// +/// `Private,Domain` (what `render_writes` emits) and `Domain, Private` (what +/// NetSecurity renders back) are one set in two orders, and admit exactly the +/// same traffic. +/// +/// TOKENS, NOT SEMANTICS: `Any` stays the single token `any` and is deliberately +/// NOT expanded into the three named profiles. Expanding it would let an +/// all-profile rule satisfy a spec that asked for Private and Domain only -- +/// admitting the local subnet of a PUBLIC network is the exact widening the two +/// halves exist to prevent (see `DESIRED_LAN_PROFILE`). +// [impl->REQ-BOOTSTRAP-FIREWALL-SPELLING-EQUIVALENCE] +fn profile_set(rendered: &str) -> Vec { + let mut tokens: Vec = rendered + .split(',') + .map(|token| token.trim().to_ascii_lowercase()) + .filter(|token| !token.is_empty()) + .collect(); + tokens.sort(); + tokens.dedup(); + tokens +} + +/// One IPv4 network in a single canonical spelling, or `None` when the text is +/// not one. +/// +/// Accepts the three spellings that name a network on this path -- prefix form +/// `192.168.1.0/24` (what [`lan_scope`] emits), mask form +/// `192.168.1.0/255.255.255.0` (what NetSecurity renders back), and a bare +/// address, which is the /32 containing only itself -- and canonicalizes all of +/// them to `network/prefix` with the host bits cleared. +/// +/// EVERYTHING ELSE IS `None` ON PURPOSE. A prefix outside 0..=32, a +/// non-contiguous mask, a malformed or truncated address and an empty string are +/// not networks, and the caller compares them as literal text instead -- so they +/// can only ever equal themselves, which is how malformed input is REJECTED +/// rather than parsed loosely. Nothing here can panic or widen: `Any` and +/// `0.0.0.0/0` are simply not the derived prefix, and a single host address +/// inside that prefix canonicalizes to its own /32 and stays a different scope. +// [impl->REQ-BOOTSTRAP-FIREWALL-SPELLING-EQUIVALENCE] +fn ipv4_network(text: &str) -> Option { + let text = text.trim(); + let (address, length) = match text.split_once('/') { + Some((address, length)) => (address, Some(length.trim())), + None => (text, None), + }; + let address: std::net::Ipv4Addr = address.trim().parse().ok()?; + let prefix = match length { + None => 32u32, + Some(length) => match length.parse::() { + Ok(prefix) if prefix <= 32 => prefix, + // A dotted mask is the same length written the other way, but ONLY + // when its ones are contiguous: `255.0.255.0` names no prefix and is + // refused rather than rounded to one. + _ => { + let mask = u32::from(length.parse::().ok()?); + if mask.leading_ones() + mask.trailing_zeros() != 32 { + return None; + } + mask.leading_ones() + } + }, + }; + let mask = if prefix == 0 { 0 } else { u32::MAX << (32 - prefix) }; + let network = std::net::Ipv4Addr::from(u32::from(address) & mask); + Some(format!("{network}/{prefix}")) +} + +/// AN OBSERVED remote-address set reduced to comparable tokens: canonical +/// networks where the text names one, lowercased literals where it does not +/// (`Any` is the case that matters). Order is not significance, so the set is +/// sorted; duplicates are NOT collapsed, because a doubled entry is a rule we +/// did not write. +// [impl->REQ-BOOTSTRAP-FIREWALL-SPELLING-EQUIVALENCE] +fn remote_set(remotes: &[String]) -> Vec { + let mut tokens: Vec = remotes + .iter() + .map(|remote| ipv4_network(remote).unwrap_or_else(|| remote.trim().to_ascii_lowercase())) + .collect(); + tokens.sort(); + tokens +} + +/// THE WANTED remotes as canonical networks, or `None` when ANY of them is not +/// one. +/// +/// THE TWO SIDES ARE NOT SYMMETRIC, and this asymmetry is the rejection boundary +/// rather than an oversight (doyle, 2026-09-12). The observed side falls back to +/// a literal so malformed host output can be COMPARED at all; if the wanted side +/// did the same, a spec whose remotes are unparseable would be satisfied by a +/// rule carrying that identical unparseable text -- two literals agreeing would +/// stand in for a scope NEITHER side can name, and the malformed input would +/// have become acceptable by matching itself. A spec that cannot say what it +/// admits is satisfied by nothing. +/// +/// Unreachable from today's callers -- `lan_scope` emits only rendered prefixes +/// and `DESIRED_TAILNET_REMOTES` is a constant -- and that is exactly why it is +/// stated in code instead of relied on as a property of the callers, which is +/// what the next constant or the next composer would quietly break. +// [impl->REQ-BOOTSTRAP-FIREWALL-SPELLING-EQUIVALENCE] +fn wanted_networks(remotes: &[String]) -> Option> { + let mut networks: Vec = + remotes.iter().map(|remote| ipv4_network(remote)).collect::>()?; + networks.sort(); + Some(networks) +} + /// Whether an OBSERVED rule satisfies the SPEC. Pure, total, and the single /// place the scope policy is enforced. /// @@ -336,27 +661,58 @@ fn spec_satisfied_by(observed: &Rule, want: &RuleSpec) -> bool { // A program-bearing observed rule does NOT satisfy a spec that wants // none: an extra filter is a narrowing, and a narrowing we did not ask // for is the difference between admitting the tailnet and admitting - // nothing (W-0, 2026-09-11). - None => observed.program.is_empty(), - Some(want_path) => crate::firewall::normalize_path(&observed.program) == *want_path, + // nothing (W-0, 2026-09-11). An UNRESTRICTED rule is not program-bearing + // however NetSecurity spells it -- `Any` and the empty string are the + // same fact about the rule (FOLD-4). + None => program_unrestricted(&observed.program), + // And the converse, stated rather than left to the path normalizer: the + // absence of a filter never satisfies a spec that wants one, so `Any` can + // never be read as an executable named `Any`. + Some(want_path) => { + !program_unrestricted(&observed.program) + && crate::firewall::normalize_path(&observed.program) == *want_path + } }; if !program_ok { return false; } - if observed.profile != want.profile { + if profile_set(&observed.profile) != profile_set(&want.profile) { return false; } - let mut seen: Vec = observed.remotes.iter().map(|r| r.to_lowercase()).collect(); - let mut wanted: Vec = want.remotes.iter().map(|r| r.to_lowercase()).collect(); - seen.sort(); - wanted.sort(); - seen == wanted + match wanted_networks(&want.remotes) { + Some(wanted) => remote_set(&observed.remotes) == wanted, + // A SPEC WHOSE OWN REMOTES ARE NOT NETWORKS IS SATISFIED BY NOTHING, + // including a rule carrying the identical unparseable text. + None => false, + } } +/// One IPv4 address the host currently carries, as NetSecurity's sibling module +/// reports it. Reported, never judged here: [`lan_scope`] decides which of these +/// describe a LAN and what prefix each contributes. +#[derive(Deserialize, Debug, Clone, PartialEq, Eq)] +struct Address { + address: String, + #[serde(rename = "prefixLength")] + prefix_length: u8, + /// `Preferred` for an address in service; `Tentative`, `Duplicate`, + /// `Deprecated` and `Invalid` are addresses the host will not route from. + #[serde(rename = "addressState")] + address_state: String, +} + +/// The observed evidence: the EFFECTIVE rules, each naming its source store. +/// +/// ONE STORE, NOT TWO. The query used to read PersistentStore as well, to certify +/// that the admission survives a reboot. An effective rule already carries that: +/// `source_type` `Local` names the persistent store as where it came from. The +/// second pass cost a rule enumeration and a full filter walk per rule and added +/// no fact this one does not (releases#304 W2 rider, doyle ruled 2026-09-12). #[derive(Deserialize)] struct Snapshot { - persistent: Vec, active: Vec, + /// The host's IPv4 census, read in the same invocation as `active`. + addresses: Vec
, } fn script(body: &str) -> String { @@ -380,16 +736,17 @@ fn encoded(script: &str) -> String { base64::engine::general_purpose::STANDARD.encode(bytes) } -fn powershell(script: &str) -> Result { +fn powershell(leg: &str, script: &str) -> Result { let command = encoded(script); super::run( + leg, "powershell.exe", &["-NoLogo", "-NoProfile", "-NonInteractive", "-EncodedCommand", &command], ) } fn snapshot() -> Result { - let output = powershell(&script(QUERY))?; + let output = powershell("verify-query", &script(QUERY))?; serde_json::from_str(output.trim_start_matches('\u{feff}').trim()) .map_err(|error| format!("Cannot decode NetSecurity bootstrap rule evidence: {error}")) } @@ -409,7 +766,13 @@ pub(super) fn verify(binder: &Path, port: u16) -> Result { if port == 0 { return Err("Bootstrap firewall requires the actual bound TCP port, not port zero".into()); } - decide(&snapshot()?, &expected, port) + let state = snapshot()?; + // ONE COMPOSER, ONE CENSUS (doyle's FOLD-3 condition 1): the scope the verdict + // expects is derived by the same function, from the same invocation's census, + // as the scope a reconcile would write. A pair matching an OLDER census is + // not verified -- the next reconcile rewrites it, and that rewrite is the + // repair. + decide(&state, &expected, port) } /// The whole admission decision over ALREADY-READ evidence, split out of @@ -421,25 +784,48 @@ pub(super) fn verify(binder: &Path, port: u16) -> Result { /// unchanged: this is the body `verify` used to inline. // [impl->REQ-WEB-LAN-BOOTSTRAP-FIREWALL] fn decide(state: &Snapshot, expected_program: &str, port: u16) -> Result { - let want = desired_specs(expected_program, port); + let lan = lan_scope(&state.addresses); + let want = desired_specs(expected_program, port, &lan); // AMBIGUITY IS PER NAME, NOT PER STORE. A fully reconciled pair puts TWO // rules in each store, so the old store-wide `len() > 1` guard would refuse // its own happy path the moment the query began enumerating the name set. - for store in [&state.persistent, &state.active] { - for spec in &want { - if store.iter().filter(|rule| rule.name == spec.name).count() > 1 { - return Err(format!("Ambiguous duplicate bootstrap rules named {}", spec.name)); - } + for spec in &want { + if state.active.iter().filter(|rule| rule.name == spec.name).count() > 1 { + return Err(format!("Ambiguous duplicate bootstrap rules named {}", spec.name)); } } - if !pair_satisfied_by(&state.persistent, &want) || !pair_satisfied_by(&state.active, &want) { + if !pair_satisfied_by(&state.active, &want) { return Ok(false); } + // PERSISTENCE IS PART OF THE VERDICT, NOT A SECOND QUERY. An admission that + // admits now but vanishes at reboot is not reconciled: the next boot serves + // strangers on the LAN with no rule and no one to notice. `Dynamic` is + // exactly that rule, and it is INDISTINGUISHABLE from ours by name, port and + // scope -- only the source says so. A non-local source is refused loudly + // rather than reported unreconciled, matching what `reconcile` already does + // with a policy-owned name: creating another local rule cannot repair it. + for rule in &state.active { + if rule.source_type != "Local" { + return Err(format!( + "Bootstrap rule {} is effective but its source store is {}, not Local: admission that does not come from the persistent store does not survive a reboot", + rule.name, rule.source_type + )); + } + } + // THE HOST CANNOT CARRY THE LAN HALF. The tailnet half has been checked by + // here, so this reports the narrower claim rather than a flat refusal: LAN + // admission is UNVERIFIED and says why, instead of reading as a pair that + // merely does not match. + if lan == LanScope::NoneConnected { + return Err(no_lan_scope_message()); + } for rule in &state.active { - if rule.enforcement.as_slice() != ["Full"] { + // [impl->REQ-BOOTSTRAP-FIREWALL-ENFORCEMENT-CODES] + if rule.enforcement.as_slice() != ENFORCEMENT_CERTIFIED { return Err(format!( - "Bootstrap rule {} is configured but ActiveStore enforcement is {:?}, not Full", - rule.name, rule.enforcement + "Bootstrap rule {} is configured but ActiveStore enforcement codes are {:?}, \ + not exactly [{}]", + rule.name, rule.enforcement, ENFORCEMENT_SUCCESS )); } } @@ -452,12 +838,25 @@ pub(super) fn reconcile(binder: &Path, port: u16) -> Result<(), String> { return Err("Bootstrap firewall requires the actual bound TCP port, not port zero".into()); } let binder_data = base64::engine::general_purpose::STANDARD.encode(binder.as_bytes()); + // FOLD-3: THE SCOPE IS READ BEFORE THE WRITES ARE RENDERED, from the same + // query the verdict uses, because the LAN half's remotes are now a fact about + // this host rather than a constant. One extra invocation on the reconcile + // path; it cannot lengthen any single child past its own budget, which is + // where the 3000 ms is enforced. + let observed = snapshot()?; + let lan = lan_scope(&observed.addresses); // THE EMITTED RULES ARE THE SPECS, not a second copy of the policy. When a // spec wants no program filter the `-Program` argument is absent entirely; // passing `Any` would be a different rule that merely reads similar. Both // halves are rendered from the same array the verdict is taken over, so a // spec added there cannot be forgotten here. - let want = desired_specs(&crate::firewall::normalize_path(binder), port); + let want = desired_specs(&crate::firewall::normalize_path(binder), port, &lan); + // A SILENT REWRITE IS THE SAME DEFECT AS A SILENT VERIFY (doyle's FOLD-3 + // condition 1). Moving networks rewrites the LAN rule, and the reason has to + // be readable afterwards or the rewrite looks like churn. + if let Some(line) = scope_change(&observed.active, &want) { + eprintln!("{line}"); + } let writes = render_writes(&want); [shipping-module-diff: exited; cursor=109646]