Skip to content

[LXC] Filter IPv6 destinations and CIDR ranges in firewall mode (AB#62830559) - #724

Open
Darren Hoehna (dhoehna) wants to merge 21 commits into
microsoft:mainfrom
dhoehna:user/dahoehna/lxc-net-ipv6-cidr
Open

[LXC] Filter IPv6 destinations and CIDR ranges in firewall mode (AB#62830559)#724
Darren Hoehna (dhoehna) wants to merge 21 commits into
microsoft:mainfrom
dhoehna:user/dahoehna/lxc-net-ipv6-cidr

Conversation

@dhoehna

@dhoehna Darren Hoehna (dhoehna) commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

Linked work item: AB#62830559 — LXC IPv6 + CIDR destination filtering in firewall mode

Summary

In firewall mode (enforcementMode: "firewall"), the LXC backend resolved allowedHosts / blockedHosts to IPv4 only, so IPv6 destinations were silently unfiltered and any CIDR entry (a string containing /) was discarded without becoming a rule. This PR resolves and programs both address families and forwards validated CIDRs verbatim to iptables / ip6tables, closing roadmap item 19 (IPv6 + CIDR parsing). Bubblewrap picks this up too, because it delegates to the same manager.

  • Hostnames now resolve to both A and AAAA records; IPv4 rules go to iptables, IPv6 rules to ip6tables, each with its own per-container chain and FORWARD hook.
  • Validated CIDRs (140.82.112.0/20, 2606:50c0::/32) are forwarded verbatim; the prefix must be ASCII digits within family range (<=32 v4, <=128 v6). Invalid CIDR is skipped with a warning rather than treated as fatal. On main a /-containing entry failed IP parse, then failed DNS, and produced no rule at all, so these entries take effect for the first time here.
  • Empty or whitespace-only entries resolve to nothing, avoiding a Winsock ":0" lookup that would otherwise program the host's own interface addresses.
  • ip6tables is probed once. If the binary is missing or IPv6 is disabled and the host has no active IPv6, the IPv4 chain still applies and the count of unapplied IPv6 rules is logged. If the host has live IPv6 but ip6tables is unusable, setup now fails closed; on main that combination silently succeeded with IPv4-only rules while IPv6 egress went unfiltered.

Ownership of installed rules

Adding a second address family doubles the objects a partly-failed setup can leave behind, so the manager now records exactly which chains and hooks it created and removes only those.

  • Each chain and hook is recorded the moment its command succeeds, not after the whole apply does, so a failure or a signal mid-apply rolls back precisely what exists.
  • A teardown returns the objects whose removal command itself failed. Ownership is retained exactly when something survived, so remove_firewall_rules and Drop retry it instead of reporting success over a leaked chain.
  • A chain is never flushed while its FORWARD hook is still installed. -F succeeds regardless of who references the chain, and an emptied user chain returns to its caller instead of reaching its own closing DROP, so flushing a still-hooked chain unfilters a container that may still be running. The -X would have failed anyway, since iptables refuses to delete a referenced chain, so the flush bought nothing and cost the container its filtering. The whole step is gated per address family on that family's hook delete having succeeded, and the chain stays published so a later pass retries. Leaving it costs a leaked chain that still filters correctly.
  • A second apply is refused while the manager still owns resources. Both arms of apply_firewall_rules replace the record with the current attempt's set, so re-applying would drop the earlier one and strand what it named. Every caller builds a manager immediately before its single apply, so refusing costs nothing and makes the hazard unreachable rather than leaving it to callers to avoid.
  • Signal-time cleanup acts on the same record. A signal arriving before anything was created runs no iptables command at all, which matters because chain names truncate to 20 characters and can collide with a different live container.

Merge-order constraints

Interim rule ordering

Allow-list rules are emitted before block-list rules, and iptables/ip6tables are first-match-wins, so a destination present in both lists is ACCEPTed. This allow-wins behavior already exists on main and is preserved, not introduced, here. GA deny-precedence is owned by AB#62830341 (net-model-2, PR #632) and is documented in a NOTE on build_policy_rules_logged. Callers must not assume deny-precedence.

Scope

IPv6 and CIDR filtering, plus the rule-ownership work that partial failure in two address families makes necessary. Port and protocol filtering are out of scope: the shipping allowedHosts / blockedHosts lists are flat host strings with nowhere to attach a port, and the GA egress.allow[]/deny[] schema is not in main. DNS hostnames are still accepted, and the GA schema migration is untouched, so this PR does not close roadmap item 15. It does not modify the roadmap document.

Two known limits are documented rather than fixed: a mapped-range CIDR with a prefix below 96 stays classified IPv6, and shared CIDR/address-family handling is still duplicated across backends (#766).

Validation

Re-verified on this branch (f8c1cd2) on the Windows dev box:

  • cargo test -p lxc_common --lib — 113 passed, 0 failed, 0 ignored.
  • cargo clippy -p lxc_common --all-targets -- -D warnings — clean.
  • cargo fmt --all -- --check — clean.
  • The three ownership repairs are mutation-tested: reintroducing any of them fails the corresponding test rather than passing quietly. Each carries a negative control, so an implementation that simply never flushes, or retains ownership unconditionally, fails too.
  • Four integration configs and scripts (lxc_network_ipv6_cidr, lxc_network_invalid_cidr, lxc_network_dualstack_hostname, lxc_network_cidr_boundary) are wired into run_lxc_all_tests.sh. They need a root LXC host and were not executed in this pass.

One limit of testing on Windows is worth stating precisely, because it bounds what the flush gate's tests prove. Every firewall command fails at spawn here, which is before run_firewall_command logs anything — so the logger observes nothing and the returned residual is identical whether the gate fires or not. The gate is therefore asserted through a pure seam (teardown_chain), which pins the decision exhaustively: hooked, unhooked, and never-created. What no test on this host can reach is the wiring one line above it, or a real -X succeeding. Nothing here was exercised against live iptables, ip6tables, or real signal delivery.

AB#62830559

Microsoft Reviewers: Open in CodeFlow

…2830559)

Firewall mode resolved `allowedHosts` / `blockedHosts` to IPv4 only. On a
dual-stack host, traffic to the same destination over IPv6 bypassed the
firewall entirely, and any CIDR entry (v4 or v6) failed to parse as an
address, then failed DNS resolution, and was dropped.

Changes, all confined to the LXC backend:

- `resolve_host` returns IPv4 and IPv6 destinations separately. Hostnames
  resolve to both A and AAAA records; bare literals and validated CIDR
  blocks pass through in their own family.
- `destination_family` validates CIDR syntax and prefix length (<=32 for
  IPv4, <=128 for IPv6). Malformed entries are reported as unresolved and
  skipped rather than handed to iptables, which would reject them at apply
  time and abort setup for the whole policy.
- IPv4 rules go to `iptables`, IPv6 rules to `ip6tables`, with parallel
  per-container chains and FORWARD hooks.
- `ip6tables` is probed once. When it is missing or IPv6 is disabled in the
  kernel, the IPv4 chain is still applied and the number of unapplied IPv6
  rules is logged, instead of failing a policy that worked before
  dual-stack support.
- Setup failures after partial chain creation are rolled back, and teardown
  removes both families' hooks and chains.

Scope: this covers the IPv6 + CIDR item of AB#62830559 only. Port and
protocol filtering are not included -- they require structured egress
rules in the config schema (AB#62830582), which is not in main.

Tests: 8 new unit tests for family routing, CIDR pass-through, prefix and
syntax rejection, and allow/block ordering; 2 integration configs and
scripts wired into run_lxc_all_tests.sh.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: ea24fae3-d643-4f19-a701-9e31b9f950fd

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds dual-stack IPv4/IPv6 and CIDR firewall filtering for LXC.

Changes:

  • Resolves and validates IPv4/IPv6 destinations and CIDRs.
  • Programs and tears down parallel iptables/ip6tables chains.
  • Adds documentation, unit coverage, and LXC integration tests.

Reviewed changes

Copilot reviewed 7 out of 7 changed files in this pull request and generated 5 comments.

Show a summary per file
File Description
src/backends/lxc/common/src/network_iptables.rs Implements dual-stack firewall handling and rollback.
docs/lxc-support/lxc-backend.md Documents dual-stack behavior and limitations.
tests/configs/lxc_network_ipv6_cidr.json Adds valid IPv6/CIDR coverage.
tests/configs/lxc_network_invalid_cidr.json Adds malformed CIDR coverage.
tests/scripts/run_lxc_network_ipv6_cidr_test.sh Tests IPv6/CIDR firewall setup.
tests/scripts/run_lxc_network_invalid_cidr_test.sh Tests malformed CIDR handling.
tests/scripts/run_lxc_all_tests.sh Registers the new integration tests.

Comment on lines +419 to +423
logger.log_line(&format!(
"Firewall setup failed: {}. Cleaning up partial iptables state.",
e
));
self.teardown_chains(logger);

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is now guarded by the CreatedResources record. install_firewall_rules sets created.v4_chain/v6_chain only after the corresponding -N returns success (network_iptables.rs:849-855), and teardown_created runs -F/-X only for a family whose flag is set (network_iptables.rs:988-1001). A failed -N therefore leaves the flag false, so rollback never deletes a chain this attempt did not create, and a 20-character name collision with another container's chain is left untouched.

Comment on lines 458 to 464
for host in policy
.allowed_hosts
.iter()
.chain(policy.blocked_hosts.iter())
{
if Self::resolve_host(host).is_empty() {
logger.log_line(&format!("Warning: could not resolve host '{}'", host));

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The production apply path now resolves each entry exactly once. build_policy_rules_logged (network_iptables.rs:493-529) calls resolve_host a single time per host and reuses that result for both the unresolved-host warning and the rule args, so the rule that gets installed is the one that was logged. The old two-pass build_policy_rule_args is now #[cfg(test)]-only and is never on the apply path.

Comment on lines +470 to +475
if ipv6_enabled {
Self::run_ip6tables_rule_args(&policy_rules.ipv6, logger)?;
} else if !policy_rules.ipv6.is_empty() {
logger.log_line(&format!(
"Warning: {} IPv6 firewall rule(s) not applied because ip6tables \
is unavailable; IPv6 egress is unfiltered on this host.",

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed by the three-way Ip6tablesStatus classification. When the ip6tables probe fails but the host has active IPv6, classify_ip6tables_status returns UnusableButIpv6Active (network_iptables.rs:551-557) and install_firewall_rules returns an error rather than marking the policy applied (network_iptables.rs:838-844). Only a kernel with IPv6 inactive (KernelIpv6Disabled) is treated as safe to skip. This path is unit-tested but has not been run against a live host without ip6tables, since I am on a Windows box.

Comment on lines +97 to +101
# The v6 half is the point of the test: if ip6tables is unusable the v6 rules
# are skipped with a warning, which would make this a v4-only run.
if echo "$OUTPUT" | grep -q "IPv6 firewall rule(s) not applied"; then
fail "IPv6 rules were skipped; ip6tables is unusable on this host."
fi

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The script now fails on a skipped hook. run_lxc_network_ipv6_cidr_test.sh:149-153 fails if the output contains "Skipping FORWARD hook" and also requires the positive "FORWARD hook installed" confirmation before PASS, so a run where veth discovery failed and the chain was never hooked no longer passes. I have not run this against a live LXC host; this is a Windows workstation.

Comment on lines +52 to +54
if ! echo "$OUTPUT" | grep -q "Default network policy: DROP"; then
fail "default-deny policy was not applied."
fi

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same fix as the IPv6/CIDR script. run_lxc_network_invalid_cidr_test.sh:78-82 now fails on the "Skipping FORWARD hook" warning and requires "FORWARD hook installed", after the default-policy check, so the invalid-entry run cannot report success with an unhooked chain. Not yet run against live iptables/LXC on this Windows box.

…#62830559)

Tests were written black-box from roadmap item 19, AB#62830559 and the public doc comments, without reading network_iptables.rs, so they pin the specified contract rather than the current implementation.

Unit tests (24 new, in two child modules of network_iptables): resolution/CIDR contract - family routing, CIDR passthrough, host bits not required to be zero, prefix bounds at 0/32 and 0/128, malformed syntax, IPv4-mapped IPv6, dual-stack hostname resolution; and rule generation - per-family bucketing, ACCEPT/DROP mapping, allow-before-block ordering in both families, family-agnostic base rules, chain-name cap.

E2E: lxc_network_dualstack_hostname covers hostnames with both A and AAAA records (the bypass this work item fixes) alongside mixed-family literals and CIDRs; lxc_network_cidr_boundary covers /0, /32, /128, non-zero host bits and the previously untested defaultPolicy=allow path. Both wired into run_lxc_all_tests.sh.

No change to wire.rs, models.rs, config_parser.rs, schemas/ or sdk/.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot-Session: ea24fae3-d643-4f19-a701-9e31b9f950fd
Copilot AI review requested due to automatic review settings July 31, 2026 22:32

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 13 out of 13 changed files in this pull request and generated 1 comment.

Suppressed comments (8)

src/backends/lxc/common/src/network_iptables.rs:475

  • If ip6tables is missing or its probe fails while the host still has IPv6 enabled, this path returns success and leaves IPv6 completely outside the firewall. That preserves the dual-stack bypass this PR is intended to close (including for defaultPolicy: block policies with no explicit IPv6 destinations). Please continue only after positively establishing that IPv6 is disabled; otherwise fail policy setup when the IPv6 chain cannot be installed.
        } else if !policy_rules.ipv6.is_empty() {
            logger.log_line(&format!(
                "Warning: {} IPv6 firewall rule(s) not applied because ip6tables \
                 is unavailable; IPv6 egress is unfiltered on this host.",

src/backends/lxc/common/src/network_iptables.rs:423

  • Rollback is also entered when the first -N fails because this chain already exists. In that case this manager created nothing, but teardown_chains flushes and deletes the pre-existing chain and hook. Since chain names use only the first 20 sanitized container-name characters, collisions or concurrent runs can therefore remove another active sandbox's firewall. Track which family chains/hooks were successfully created and roll back only those resources.
                logger.log_line(&format!(
                    "Firewall setup failed: {}. Cleaning up partial iptables state.",
                    e
                ));
                self.teardown_chains(logger);

src/backends/lxc/common/src/network_iptables.rs:464

  • Each hostname is resolved here for warning output and then resolved again inside build_policy_rule_args. DNS can change or fail between calls, so a blocked host can pass the first lookup but yield no rule on the second without any warning; this is also unnecessary duplicate DNS work. Refactor rule construction to resolve each entry once and use that same result for both logging and rule generation.
            if Self::resolve_host(host).is_empty() {
                logger.log_line(&format!("Warning: could not resolve host '{}'", host));

tests/scripts/run_lxc_network_cidr_boundary_test.sh:117

  • This test claims to validate rule programming rather than reachability, but it fails whenever the container's wget cannot reach GitHub. That makes the newly wired all-tests suite depend on external network availability even when firewall setup is correct. Ignore the workload exit here and let the subsequent firewall-log assertions determine success, as the other new network scripts do.
if [ "$STATUS" -ne 0 ]; then
    fail "lxc-exec exited with status $STATUS for boundary-valid prefixes."

tests/configs/lxc_network_ipv6_cidr.json:2

  • This fixture is rejected before reaching LXC because the parser's supported range starts at 0.6 (config_parser.rs:297-332); the existing LXC network fixture already uses 0.6.0-alpha. As written, the new integration test can never exercise IPv6/CIDR rule setup. Use a currently supported schema version.
  "version": "0.4.0-alpha",

tests/configs/lxc_network_invalid_cidr.json:2

  • This fixture is rejected before reaching LXC because the parser's supported range starts at 0.6 (config_parser.rs:297-332). Consequently, the script sees a schema-version error rather than the expected unresolved-CIDR warnings. Use a currently supported schema version.
  "version": "0.4.0-alpha",

tests/configs/lxc_network_dualstack_hostname.json:2

  • Schema version 0.4 is below the parser's supported range (config_parser.rs:297-332), so this fixture fails during config loading and never tests dual-stack hostname resolution. Use the same supported version as the existing LXC network fixture.
  "version": "0.4.0-alpha",

tests/configs/lxc_network_cidr_boundary.json:2

  • Schema version 0.4 is below the parser's supported range (config_parser.rs:297-332), so lxc-exec rejects this fixture before any boundary CIDRs are programmed. Use a currently supported schema version.
  "version": "0.4.0-alpha",

Comment on lines +49 to +52
run_test "LXC Network IPv6+CIDR" "$SCRIPT_DIR/run_lxc_network_ipv6_cidr_test.sh"
run_test "LXC Network Invalid CIDR" "$SCRIPT_DIR/run_lxc_network_invalid_cidr_test.sh"
run_test "LXC Network Dual-Stack Hostname" "$SCRIPT_DIR/run_lxc_network_dualstack_test.sh"
run_test "LXC Network CIDR Boundary" "$SCRIPT_DIR/run_lxc_network_cidr_boundary_test.sh"

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All four network integration scripts now assert the FORWARD hook before PASS: run_lxc_network_ipv6_cidr_test.sh:149-153, run_lxc_network_invalid_cidr_test.sh:78-82, run_lxc_network_dualstack_test.sh:220-224, and run_lxc_network_cidr_boundary_test.sh:199-203. Each fails on "Skipping FORWARD hook" and requires the "FORWARD hook installed" line, so an unhooked chain fails the test. These assertions have not been executed on a live LXC host.

…30559)

Mutation testing showed that inverting the DNS branch so AAAA records are pushed into the IPv4 bucket - the exact dual-stack bypass this work item fixes - left the suite green. The only hostname test used localhost, which resolves to 127.0.0.1 only on many hosts, so the v6 arm of the DNS path was never executed.

Adds a family-purity invariant asserting every destination in a bucket belongs to that bucket's family, exercised over well-known dual-stack names. It now kills that mutation. All 9 mutations tried against the module are caught.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot-Session: ea24fae3-d643-4f19-a701-9e31b9f950fd
Copilot AI review requested due to automatic review settings July 31, 2026 22:46

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 13 out of 13 changed files in this pull request and generated no new comments.

Suppressed comments (4)

src/backends/lxc/common/src/network_iptables.rs:423

  • Rollback must not tear down chains that this invocation did not create. If the first -N fails because another active container owns the same chain (chain names truncate container IDs to 20 characters), this unconditional teardown flushes that existing chain; its FORWARD hook can then point at an empty chain, disabling the other container's policy. Track successful chain/hook creation per family and roll back only those resources; handle stale/pre-existing chains through an ownership-safe cleanup path.
                logger.log_line(&format!(
                    "Firewall setup failed: {}. Cleaning up partial iptables state.",
                    e
                ));
                self.teardown_chains(logger);

src/backends/lxc/common/src/network_iptables.rs:444

  • Treating every failed ip6tables -S probe as an IPv4-only host makes firewall enforcement fail open. A dual-stack host can have IPv6 enabled while the binary is missing, permissions are wrong, or the probe fails transiently; this path still returns success and leaves IPv6 completely unfiltered, preserving the bypass this PR is intended to close. Skip the v6 chain only after confirming IPv6 is disabled; otherwise fail setup when ip6tables is unusable.
        // Probe ip6tables once. On IPv4-only hosts (binary absent or IPv6
        // disabled in the kernel) enforce the v4 policy and skip the v6 chain
        // rather than failing setup for a policy that worked before dual-stack.
        let ipv6_enabled = Self::ip6tables_available(logger);

tests/scripts/run_lxc_network_cidr_boundary_test.sh:117

  • This makes the boundary test depend on successful external wget reachability even though the test explicitly says it validates rule programming, not reachability. On an offline runner, valid firewall setup still produces a nonzero command status and fails here. Capture the output while tolerating the workload exit, as the other new firewall tests do; the subsequent required log assertions still catch setup/config failures.
if [ "$STATUS" -ne 0 ]; then
    fail "lxc-exec exited with status $STATUS for boundary-valid prefixes."
fi

tests/scripts/run_lxc_network_dualstack_test.sh:149

  • These assertions can still pass when setup fails after the default rules are appended—for example, if inserting either FORWARD hook fails. The script discards lxc-exec's status and never checks the emitted Firewall setup failed:/iptables error, so it can report the dual-stack bypass closed even though no chain is hooked. Reject firewall setup errors before declaring success.
if ! grep -Fq "Creating iptables/ip6tables chain:" <<<"$OUTPUT"; then
    fail "iptables/ip6tables chain creation was not logged."
fi

… (AB#62830559)

A spec-derived test asserting that '10.0.0.0/+24' is rejected was failing. It was rewritten to assert the current behaviour instead of being left as a finding, which is the wrong resolution: whether MXC should accept a permissive prefix spelling in a security policy file is a design decision, not something to settle by editing the test.

The original assertion is restored verbatim and marked #[ignore] so the finding stays visible in test output pending a decision. The separate assertion that a leading '+' cannot smuggle an out-of-range prefix past the family bound check is kept as a passing test, since prefix bounds are unambiguous.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot-Session: ea24fae3-d643-4f19-a701-9e31b9f950fd
Copilot AI review requested due to automatic review settings July 31, 2026 22:56

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 13 out of 13 changed files in this pull request and generated no new comments.

Suppressed comments (7)

src/backends/lxc/common/src/network_iptables.rs:423

  • Rollback is unconditional even when the first -N failed, so it can delete firewall state owned by another active manager. Chain names are truncated to 20 sanitized characters, and concurrent runs for the same container necessarily share a name; the second run's creation failure reaches this cleanup and removes the first run's FORWARD hook/chain. Track which chains and hooks this invocation successfully created, and roll back only those resources.
                logger.log_line(&format!(
                    "Firewall setup failed: {}. Cleaning up partial iptables state.",
                    e
                ));
                self.teardown_chains(logger);

src/backends/lxc/common/src/network_iptables.rs:464

  • Each hostname is resolved here for the warning and then resolved again while building policy_rules. DNS results can change or the second lookup can transiently fail; under default-allow, a blocked hostname can therefore pass the first lookup (no warning) but emit no DROP rule on the second lookup. Resolve each entry once and reuse that exact result for both diagnostics and rule generation.
            if Self::resolve_host(host).is_empty() {
                logger.log_line(&format!("Warning: could not resolve host '{}'", host));

src/backends/lxc/common/src/network_iptables.rs:476

  • Returning success here leaves IPv6 completely unfiltered when the kernel supports IPv6 but the ip6tables binary is absent. This also omits the terminal IPv6 DROP for defaultPolicy: block, even when policy_rules.ipv6 is empty, so the dual-stack bypass remains open. Only skip safely after proving IPv6 is disabled; otherwise fail closed or disable IPv6 for the sandbox.
        } else if !policy_rules.ipv6.is_empty() {
            logger.log_line(&format!(
                "Warning: {} IPv6 firewall rule(s) not applied because ip6tables \
                 is unavailable; IPv6 egress is unfiltered on this host.",
                policy_rules.ipv6.len()

src/backends/lxc/common/src/network_iptables.rs:183

  • u8::from_str accepts a leading +, so 10.0.0.0/+24 is treated as valid even though the documented contract rejects malformed/non-digit prefixes and the corresponding test is quarantined. Require ASCII digits before parsing so this typo follows the unresolved-host path.
            let prefix = prefix.parse::<u8>().ok()?;

src/backends/lxc/common/src/network_iptables.rs:543

  • Teardown invokes ip6tables unconditionally even when the availability probe skipped creation of the IPv6 chain. On a host where the binary exists but IPv6 is disabled, these -F/-X calls log failures during otherwise successful cleanup; the new invalid-CIDR integration test treats those messages as setup failure. Persist whether the IPv6 chain was created and only clean it up in that case.
        let _ = Self::run_iptables(&["-F", &self.chain_name], logger);
        let _ = Self::run_iptables(&["-X", &self.chain_name], logger);
        let _ = Self::run_ip6tables(&["-F", &self.chain_name], logger);
        let _ = Self::run_ip6tables(&["-X", &self.chain_name], logger);

src/backends/lxc/common/src/network_iptables_resolution_spec_tests.rs:216

  • This test still passes when no AAAA record is available, so an offline CI run never exercises the DNS branch that routes AAAA results to IPv6—the central regression this PR fixes. The integration test likewise skips external-hostname assertions when DNS is unavailable. Add an injectable/mock resolver or deterministic local dual-stack resolver so misfiling AAAA records fails on every run.
    if !saw_v6 {
        eprintln!(
            "WARNING: no AAAA record resolved for any of {hosts:?}; the IPv6 DNS \
             arm of resolve_host was not exercised by this run."
        );

tests/scripts/run_lxc_network_dualstack_test.sh:149

  • The script can pass when firewall setup fails while inserting either FORWARD hook: chain creation and the default policy are logged before hook insertion, and the nonzero executor status is discarded. Check the setup-failure diagnostics before declaring the dual-stack policy programmed.
if ! grep -Fq "Creating iptables/ip6tables chain:" <<<"$OUTPUT"; then
    fail "iptables/ip6tables chain creation was not logged."
fi

Darren Hoehna (dhoehna) and others added 2 commits July 31, 2026 16:40
Two defects found by a coverage audit of this branch, both caught by
spec-derived tests written black-box against the roadmap contract.

resolve_host("") fell through to DNS resolution, where format!("{}:0", host)
produces ":0". Winsock resolves that to every local interface address, so an
empty allowedHosts entry emitted rules for the host's own LAN and link-local
addresses. glibc rejects it, so this reproduced only on Windows -- it turned
CI red on windows/x64 and windows/arm64. config_parser assigns host lists
verbatim, so an empty string does reach resolve_host from a policy file.

destination_family validated the CIDR prefix with u8::from_str, which accepts
a leading '+'. 10.0.0.0/+24 was forwarded to iptables, which silently
canonicalizes it to 10.0.0.0/24, so a policy typo was applied instead of being
reported by the unresolved-host warning that run_lxc_network_invalid_cidr_test.sh
exists to guarantee. The prefix must now be ASCII digits, which also subsumes
the embedded-slash case. The test for this was previously quarantined pending
a bad-code/bad-test ruling; the ruling is bad code, so it is now un-ignored.

Also adds lifecycle tests pinning three behaviours a cargo-mutants run proved
were unpinned: a new manager reports no rules applied, a non-firewall
enforcement mode is a successful no-op, and the enforcement-mode gate is not
inverted. The last matters most -- an inverted gate would silently skip all
filtering while reporting success.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: ea24fae3-d643-4f19-a701-9e31b9f950fd
None of these four scripts had ever executed a single firewall assertion
since they were added. Two independent causes:

  - every config declared "version": "0.4.0-alpha", but the parser accepts
    >=0.6 <=0.8, so each run died at config parse
  - lxc-exec buffers diagnostics unless --debug is passed, so the log lines
    the scripts assert on were never emitted even after the version bump

Bumps the configs to 0.6.0-alpha, matching the sibling LXC configs, passes
--debug, and adds post-run iptables/ip6tables assertions that the
per-container chain is torn down rather than leaked.

Verified by running all four as root under WSL: each creates a real container,
programs real v4/v6 chains, and cleans up. Assertion liveness was confirmed by
flipping defaultPolicy in a config and observing exit 1 with
"FAIL: default-deny policy was not applied."

Also normalizes lxc_network_ipv6_cidr.json to LF; it was the only one of the
four committed with CRLF.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: ea24fae3-d643-4f19-a701-9e31b9f950fd
Copilot AI review requested due to automatic review settings July 31, 2026 23:40

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 14 out of 14 changed files in this pull request and generated 1 comment.

Suppressed comments (8)

src/backends/lxc/common/src/network_iptables.rs:480

  • Each hostname is resolved here for the warning and then resolved again by build_policy_rule_args at line 485. DNS can change or fail between calls, so a blocked hostname may resolve successfully here but produce no DROP rule on the second call, with no warning; under default-allow that silently permits the destination. Resolve each host once and use the same ResolvedDestinations for both diagnostics and rule generation.
            if Self::resolve_host(host).is_empty() {

src/backends/lxc/common/src/network_iptables.rs:461

  • This treats a disabled IPv6 stack and an unavailable/failing ip6tables command as equivalent. If the kernel still has IPv6 enabled but the binary is missing, even defaultPolicy: block gets only an IPv4 DROP chain and all IPv6 egress remains unfiltered. Distinguish a genuinely disabled IPv6 stack; when IPv6 is active, fail setup or provide equivalent IPv6 enforcement instead of failing open.
        // Probe ip6tables once. On IPv4-only hosts (binary absent or IPv6
        // disabled in the kernel) enforce the v4 policy and skip the v6 chain
        // rather than failing setup for a policy that worked before dual-stack.
        let ipv6_enabled = Self::ip6tables_available(logger);

src/backends/lxc/common/src/network_iptables.rs:440

  • Rollback also runs when the first -N failed because this chain already belonged to another manager. Since chain names truncate container IDs to 20 characters (lines 70–77), distinct containers can collide; this teardown then flushes the existing chain and may remove its hook, silently disabling that container's firewall. Track which chains/hooks this attempt successfully created and roll back only those resources—never flush a chain whose creation failed.
                logger.log_line(&format!(
                    "Firewall setup failed: {}. Cleaning up partial iptables state.",
                    e
                ));
                self.teardown_chains(logger);

tests/configs/lxc_network_cidr_boundary.json:6

  • This fixture's script requires lxc-exec to exit zero, but the workload depends on an external API. On an offline runner, wget fails and the boundary test reports a prefix failure even though firewall programming succeeded (lxc-exec propagates the workload exit code). Use a local success command because this test explicitly does not verify reachability.
    "commandLine": "wget -qO- https://api.github.com/zen"

tests/scripts/run_lxc_network_ipv6_cidr_test.sh:50

  • run_lxc_all_tests.sh already requires UID 0, so invoking sudo here is unnecessary and makes this cleanup assertion silently pass when sudo is not installed: command-not-found is interpreted exactly like “chain absent.” Query both tables directly so the new teardown coverage remains effective on minimal root test hosts.
    if sudo -n iptables -S "$CHAIN_NAME" >/dev/null 2>&1; then
        fail "iptables chain '$CHAIN_NAME' was left behind after lxc-exec completed."
    fi
    if sudo -n ip6tables -S "$CHAIN_NAME" >/dev/null 2>&1; then
        fail "ip6tables chain '$CHAIN_NAME' was left behind after lxc-exec completed."

tests/scripts/run_lxc_network_invalid_cidr_test.sh:40

  • run_lxc_all_tests.sh already requires UID 0, so invoking sudo here is unnecessary and makes this cleanup assertion silently pass when sudo is not installed: command-not-found is interpreted exactly like “chain absent.” Query both tables directly so the new teardown coverage remains effective on minimal root test hosts.
    if sudo -n iptables -S "$CHAIN_NAME" >/dev/null 2>&1; then
        fail "iptables chain '$CHAIN_NAME' was left behind after lxc-exec completed."
    fi
    if sudo -n ip6tables -S "$CHAIN_NAME" >/dev/null 2>&1; then
        fail "ip6tables chain '$CHAIN_NAME' was left behind after lxc-exec completed."

tests/scripts/run_lxc_network_dualstack_test.sh:61

  • run_lxc_all_tests.sh already requires UID 0, so invoking sudo here is unnecessary and makes this cleanup assertion silently pass when sudo is not installed: command-not-found is interpreted exactly like “chain absent.” Query both tables directly so the new teardown coverage remains effective on minimal root test hosts.
    if sudo -n iptables -S "$CHAIN_NAME" >/dev/null 2>&1; then
        fail "iptables chain '$CHAIN_NAME' was left behind after lxc-exec completed."
    fi
    if sudo -n ip6tables -S "$CHAIN_NAME" >/dev/null 2>&1; then
        fail "ip6tables chain '$CHAIN_NAME' was left behind after lxc-exec completed."

tests/scripts/run_lxc_network_cidr_boundary_test.sh:57

  • run_lxc_all_tests.sh already requires UID 0, so invoking sudo here is unnecessary and makes this cleanup assertion silently pass when sudo is not installed: command-not-found is interpreted exactly like “chain absent.” Query both tables directly so the new teardown coverage remains effective on minimal root test hosts.
    if sudo -n iptables -S "$CHAIN_NAME" >/dev/null 2>&1; then
        fail "iptables chain '$CHAIN_NAME' was left behind after lxc-exec completed."
    fi
    if sudo -n ip6tables -S "$CHAIN_NAME" >/dev/null 2>&1; then
        fail "ip6tables chain '$CHAIN_NAME' was left behind after lxc-exec completed."

let policy = policy_with_enforcement_mode(mode.clone());
let mut logger = Logger::new(Mode::Buffer);

let _ = manager.apply_firewall_rules(&policy, &mut logger);

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The lifecycle spec file was removed and no unit test invokes the host firewall anymore. The enforcement-mode check is now the pure predicate enforcement_mode_uses_firewall, tested by every_enforcement_mode_takes_the_contractual_firewall_gate (network_iptables.rs:2072-2080), and the only apply_firewall_rules test uses the non-firewall Capabilities mode, which returns before touching iptables (network_iptables.rs:2051-2067). Nothing under #[cfg(test)] shells out to iptables/ip6tables for the Firewall or Both cases.

Copilot AI review requested due to automatic review settings August 4, 2026 19:47

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 14 out of 14 changed files in this pull request and generated no new comments.

Suppressed comments (6)

src/backends/lxc/common/src/network_iptables.rs:440

  • Rollback runs even when the first -N failed because this chain already existed. In that case this invocation created nothing, but teardown_chains still deletes the matching FORWARD hook and flushes/deletes the pre-existing v4/v6 chains, which can disable another live container's firewall (chain names are truncated to 20 container-name characters). Track which chains/hooks this apply actually created and roll back only those operations.
                logger.log_line(&format!(
                    "Firewall setup failed: {}. Cleaning up partial iptables state.",
                    e
                ));
                self.teardown_chains(logger);

src/backends/lxc/common/src/network_iptables.rs:461

  • An unavailable ip6tables binary does not prove that IPv6 is disabled. On a host with an active IPv6 stack but no binary, this path returns success without installing either the IPv6 destination rules or the terminal default policy, so defaultPolicy: block and IPv6 block-list entries remain bypassable. Only preserve the IPv4-only fallback after positively establishing that IPv6 traffic is unavailable; otherwise fail closed.
        // Probe ip6tables once. On IPv4-only hosts (binary absent or IPv6
        // disabled in the kernel) enforce the v4 policy and skip the v6 chain
        // rather than failing setup for a policy that worked before dual-stack.
        let ipv6_enabled = Self::ip6tables_available(logger);

src/backends/lxc/common/src/network_iptables.rs:480

  • Each hostname is resolved here for warning generation and then resolved again in build_policy_rule_args. DNS can change or transiently fail between those calls, so a successful first lookup followed by a failed second lookup silently emits no rule and no warning—widening a default-allow block policy. Resolve each entry once and use that same result for both logging and rule generation.
        for host in policy
            .allowed_hosts
            .iter()
            .chain(policy.blocked_hosts.iter())
        {
            if Self::resolve_host(host).is_empty() {

tests/scripts/run_lxc_network_dualstack_test.sh:159

  • The command status is intentionally discarded, but this script never checks for Firewall setup failed. A failure while adding the FORWARD hook occurs after both the chain-creation and default-policy messages, then rollback makes the cleanup assertion pass, so this test can report PASS without any firewall being installed. Reject firewall command/setup errors as the other new integration scripts do.
if ! grep -Fq "Creating iptables/ip6tables chain:" <<<"$OUTPUT"; then
    fail "iptables/ip6tables chain creation was not logged."
fi

src/backends/lxc/common/src/network_iptables_resolution_spec_tests.rs:215

  • This test still passes when no AAAA answer is available, so an offline CI run never exercises the DNS-to-IPv6 branch named by the test. The end-to-end script also skips external-hostname assertions when DNS is unavailable, allowing an AAAA-to-IPv4 regression to pass the full suite. Use an injected resolver or deterministic local dual-stack fixture and require a known IPv6 answer.
    if !saw_v6 {
        eprintln!(
            "WARNING: no AAAA record resolved for any of {hosts:?}; the IPv6 DNS \
             arm of resolve_host was not exercised by this run."
        );

src/backends/lxc/common/src/network_iptables.rs:518

  • The -o to -i change is what makes this chain govern container egress, but none of the new tests verifies the installed FORWARD rule: both spellings are accepted by iptables, and the scripts only inspect logs after teardown. A regression back to -o would therefore leave all destination rules ineffective while the suite passes. Add a command-executor assertion for the exact hook arguments or an in-flight firewall/reachability assertion.
        if let Some(ref iface) = self.veth_interface {
            Self::run_iptables(
                &["-I", "FORWARD", "-i", iface, "-j", &self.chain_name],
                logger,
            )?;

Darren Hoehna (dhoehna) and others added 3 commits August 4, 2026 13:10
Address four PR microsoft#724 review threads that are an interwoven refactor of
the same enforcement path in network_iptables.rs:

- Resolve each allow/block destination exactly once. The apply path
  previously resolved a host for the unresolved-host warning and then a
  second time inside rule construction; two lookups of the same name can
  disagree under DNS round-robin or a TTL expiry, so the installed rule
  need not match the logged one. build_policy_rules_logged now resolves
  once and reuses that result for both. The pure builders that resolve
  are gated to test-only.

- Extract enforcement_mode_uses_firewall as a pure predicate and test it
  directly, instead of the lifecycle test invoking apply_firewall_rules
  (which shells out to the host firewall) for the Firewall and Both cases.

- Fail closed when IPv6 is active but ip6tables is unusable. The old
  boolean probe skipped IPv6 for every ip6tables failure, marking the
  policy applied while IPv6 egress went unfiltered. classify_ip6tables_status
  now distinguishes a kernel with no active IPv6 (safe to skip) from an
  IPv6-capable host whose ip6tables is missing or broken (setup fails).
  host_has_active_ipv6 reads /proc/net/if_inet6, which the kernel
  populates only when the IPv6 stack is loaded and addresses exist.

- Track which per-family chains and FORWARD hooks each attempt created
  and roll back only those. Rollback previously tore down chains
  unconditionally, and since chain names truncate at 20 characters a
  partial-failure rollback could delete a chain belonging to a different
  container. teardown_created acts on the recorded CreatedResources.

Also log a positive confirmation when a FORWARD hook is installed, so the
E2E scripts can assert on it.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The four LXC network E2E scripts probed for a leftover chain with
`sudo -n iptables -S`. Under `sudo -n`, a host without passwordless sudo
fails the probe for a reason unrelated to whether the chain exists, so
the cleanup assertion could pass without ever having checked. The LXC
suite already requires root (run_lxc_all_tests.sh), so query iptables and
ip6tables directly instead.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
All four LXC network E2E scripts could report PASS while the per-container
chain was never hooked into FORWARD: the code emits a skipped-hook warning
that nothing checked, so an undiscovered veth silently enforced nothing.
Each script now fails on the "Skipping FORWARD hook" warning and requires
the positive "FORWARD hook installed" confirmation before reporting PASS.
This pairs with the confirmation log line added to the enforcement backend.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@microsoft-github-policy-service microsoft-github-policy-service Bot added Needs-Attention Issue needs attention from Microsoft and removed Needs-Author-Feedback Issue needs attention from issue or PR author labels Aug 6, 2026

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 11 out of 12 changed files in this pull request and generated 1 comment.

Suppressed comments (1)

tests/scripts/run_lxc_all_tests.sh:81

  • When every test is skipped, the runner still exits 0, which can make CI/pipelines appear successful even though nothing actually executed. If the intent is “must not look green” in automation (not just in logs), consider exiting with a distinct non-zero status (or SKIP_EXIT=77) when PASSED==0 && FAILED==0 so infrastructure can detect ‘no coverage ran’ runs.
if [ "$PASSED" -eq 0 ] && [ "$FAILED" -eq 0 ]; then
    echo "WARNING: no tests actually executed; every test was skipped."
fi

Comment on lines 1003 to 1023
pub fn remove_firewall_rules(&mut self, logger: &mut Logger) -> Result<(), String> {
if !self.rules_applied {
return Ok(());
}

logger.log_line(&format!("Removing iptables chain: {}", self.chain_name));

// Remove from FORWARD (only if we had a veth interface and hooked it)
if let Some(ref iface) = self.veth_interface {
let _ = Self::run_iptables(
&["-D", "FORWARD", "-o", iface, "-j", &self.chain_name],
logger,
);
}
logger.log_line(&format!(
"Removing iptables/ip6tables chain: {}",
self.chain_name
));

// Flush and delete the chain
let _ = Self::run_iptables(&["-F", &self.chain_name], logger);
let _ = Self::run_iptables(&["-X", &self.chain_name], logger);
let residual = Self::teardown_created(
&self.chain_name,
self.veth_interface.as_deref(),
&self.created,
logger,
);

self.rules_applied = false;
self.created = residual;
Ok(())
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You're right, and it's fixed in b974fb1. This is the same defect I had just fixed on the failed-apply rollback, still present on the removal path -- remove_firewall_rules cleared rules_applied even when teardown_created reported a non-empty residual, and since Drop is gated on that same flag, a teardown whose commands failed reported itself done and threw away the last retry.

Both paths now share one retain_residual_ownership helper that keeps the gate open exactly when something survived. Having one path get this right and the other not was the underlying problem, so I merged them rather than patching the second copy.

I kept the return type as Ok(()) rather than erroring on a non-empty residual: callers treat cleanup failure as non-fatal today, and making it fatal would change behavior beyond this fix. Retaining ownership is what actually recovers the resource.

Mutation-tested: clearing the flag unconditionally again fails the new test, which drives the second removal -- the call Drop makes -- and observes the log. Not exercised against live iptables.

apply_firewall_rules_inner rolled back what it created, but discarded the
result of that rollback with `let _ =`.  A removal command can itself fail, so
the outer error arm then left self.created empty and rules_applied false while
a chain or FORWARD hook was still installed.  rules_applied gates both
remove_firewall_rules and Drop, so nothing afterward knew the survivors were
ours and the leak was permanent for the life of the process.

The inner call now returns the residual alongside the error, and the outer arm
adopts it: ownership is retained exactly when something survived, and the two
cases log differently so the distinction is visible in a failure report.  The
signal path was already covered, because teardown_created publishes the
residual before returning; this closes the ordinary Drop and remove path.

Mutation-tested: discarding the residual again makes the new test fail on the
retention assertion rather than passing quietly.  The test drives
remove_firewall_rules and observes the log rather than asserting rules_applied,
and carries a negative control so it cannot pass by retaining unconditionally.

Not verified: no live iptables.  On this Windows host every firewall command
fails, so the residual path is exercised through the ownership record and the
logger rather than against real rule state.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 52fb9e8c-4a48-435c-9964-8ec0fee653c6
The previous commit fixed this for the failed-apply rollback but left the same
defect on the ordinary removal path, and the reviewer caught it there.
remove_firewall_rules cleared rules_applied unconditionally, even when
teardown_created reported a non-empty residual.  Since Drop is gated on that
same flag, a teardown whose commands failed reported itself done and threw away
the last retry, leaving the chain installed for the life of the process.

Both paths now share retain_residual_ownership, which keeps the gate open
exactly when something survived.  They have the same obligation, so having one
of them get it right and the other not was the underlying problem.

Mutation-tested: clearing the flag unconditionally again makes the new test
fail.  The test drives a second removal -- the call Drop makes -- and observes
the log, because a closed gate short-circuits before the teardown announces the
chain.

Not verified: no live iptables.  On this Windows host every firewall command
fails, which is what makes the non-empty residual reachable in a test at all.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 52fb9e8c-4a48-435c-9964-8ec0fee653c6

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 11 out of 12 changed files in this pull request and generated no new comments.

Suppressed comments (2)

tests/scripts/run_lxc_network_ipv6_cidr_test.sh:144

  • Given the production behavior explicitly distinguishes KernelIpv6Disabled (safe skip) from UnusableButIpv6Active (fail-closed), this test will fail on IPv4-only hosts even when the skip is the intended/acceptable outcome. Consider treating the 'Kernel IPv6 is not active; skipping IPv6 firewall rules' log as a SKIP (exit 77) rather than a FAIL, and only failing when the output indicates IPv6 is active but ip6tables is unusable.
# The v6 half is the point of the test: if ip6tables is unusable the v6 rules
# are skipped with a warning, which would make this a v4-only run.
if echo "$OUTPUT" | grep -q "IPv6 firewall rule(s) not applied"; then
    fail "IPv6 rules were skipped; ip6tables is unusable on this host."
fi

tests/scripts/run_lxc_all_tests.sh:44

  • set +/-e mutates the shell's global errexit state (not function-local), which can unintentionally change runner behavior depending on the file's initial settings. You can capture the child's exit code without toggling errexit by running the command in an if bash \"$script\"; then status=0; else status=$?; fi pattern, which is compatible with set -e.
    # Do not let a nonzero exit abort the runner; classify it instead.
    set +e
    bash "$script"
    local status=$?
    set -e
    if [ "$status" -eq 0 ]; then

Copilot AI review requested due to automatic review settings August 6, 2026 23:44
An independent review of the previous two commits found that both arms of
`apply_firewall_rules` replace `self.created` with the current attempt's set.
A manager that already owned resources and was asked to apply again would
therefore drop the earlier record.  If the second attempt then failed before
creating anything, `retain_residual_ownership` would overwrite the record with
an empty set and clear `rules_applied`, so `Drop` skipped cleanup and whatever
the first attempt left behind was stranded permanently.  The same empty record
was published to the signal registry, so the watchdog lost it too.

Every production caller builds a manager immediately before its single apply,
so this is not reachable today.  That is exactly why it is worth closing now:
the invariant is currently held by convention at four call sites rather than by
the type, and nothing tells the next caller.  Refusing the second apply makes
it unreachable by construction instead.

The guard keys on live ownership rather than on having ever applied, so a
manager whose removal succeeded can still be reused.  The negative control test
covers that: a fresh manager must reach its commands rather than trip the gate.

Mutation-verified.  Weakening the guard to `self.rules_applied && false` fails
`a_second_apply_is_refused_while_the_first_still_owns_resources` rather than
passing quietly.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 52fb9e8c-4a48-435c-9964-8ec0fee653c6

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 11 out of 12 changed files in this pull request and generated no new comments.

Suppressed comments (2)

tests/scripts/run_lxc_all_tests.sh:84

  • The runner warns on an all-skipped run but still exits 0, which still looks like a successful test run to CI and contradicts the script comment (‘must not look green’). Consider exiting with a distinct non-zero status for the all-skipped case (e.g., exit \"$SKIP_EXIT\" when PASSED==0 && FAILED==0), so pipelines can reliably detect ‘nothing ran’.
echo "Results: $PASSED passed, $FAILED failed, $SKIPPED skipped"
if [ "$SKIPPED" -gt 0 ]; then
    echo -e "Skipped (prerequisite missing, not run):$SKIPS"
fi
# A suite that ran nothing must not look green. Make an all-skip (or empty) run
# visibly distinct from a real pass.
if [ "$PASSED" -eq 0 ] && [ "$FAILED" -eq 0 ]; then
    echo "WARNING: no tests actually executed; every test was skipped."
fi
if [ $FAILED -gt 0 ]; then
    echo -e "Failures:$FAILURES"
    exit 1

tests/scripts/run_lxc_network_invalid_cidr_test.sh:27

  • This test only asserts that invalid CIDRs produce unresolved-host warnings and do not make firewall setup fail; it doesn’t assert any IPv6 rule programming. Hard-requiring ip6tables reduces where the test can run (e.g., IPv4-only hosts or minimal environments). Consider making ip6tables optional here (and only checking ip6tables -S \"$CHAIN_NAME\" cleanup when ip6tables is present / when IPv6 rules were attempted), so the invalid-CIDR behavior can be validated more broadly.
[ "$(id -u)" -eq 0 ] || skip "requires root for iptables/ip6tables and LXC."
command -v iptables >/dev/null 2>&1 || skip "iptables is not installed."
command -v ip6tables >/dev/null 2>&1 || skip "ip6tables is not installed."
command -v lxc-create >/dev/null 2>&1 || skip "LXC (lxc-create) is not installed."

Copilot AI review requested due to automatic review settings August 7, 2026 00:01

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 11 out of 12 changed files in this pull request and generated no new comments.

Suppressed comments (3)

tests/scripts/run_lxc_all_tests.sh:43

  • The function flips errexit off and then unconditionally turns it on with set -e, which can change the runner’s behavior if -e wasn’t enabled before entering run_test. To avoid leaking shell option state, capture whether -e was set on entry (e.g., via $-) and restore it to the prior setting after running the child script.
    # Do not let a nonzero exit abort the runner; classify it instead.
    set +e
    bash "$script"
    local status=$?
    set -e

tests/scripts/run_lxc_network_ipv6_cidr_test.sh:25

  • The skip exit code (77) is duplicated across multiple test scripts and must stay in sync with run_lxc_all_tests.sh. To reduce drift risk, consider centralizing this in one place (e.g., export SKIP_EXIT from the runner, or source a shared tests/scripts/common.sh that defines SKIP_EXIT and skip()).
# An honest skip for a missing prerequisite: exit 77 so run_lxc_all_tests.sh
# records SKIPPED rather than PASS. A suite that could not run must not look green.
SKIP_EXIT=77

tests/scripts/run_lxc_network_dualstack_test.sh:198

  • This assertion compares the getent-printed IPv6 string to the Rust Ipv6Addr::to_string() formatting used in the debug log. Equivalent IPv6 addresses can be rendered differently (compression/leading zeros), which can make the test flaky on some platforms/glibc builds. Consider normalizing aaaa to a canonical form (e.g., via python3 -c 'import ipaddress; print(ipaddress.ip_address(...).compressed)') before calling assert_programmed_rule, so the string format matches what the Rust code logs.
    aaaa=$(getent ahostsv6 dns.google 2>/dev/null | awk 'NF {print $1; exit}')
    if [ -n "${aaaa:-}" ]; then
        assert_programmed_rule ip6tables "$aaaa" ACCEPT
    else
        echo "SKIP: could not obtain an AAAA for dns.google; hostname-derived IPv6 rule not asserted."
    fi

The existing test for residual ownership starts from a residual that has
already been retained: it calls retain_residual_ownership itself and then
checks teardown runs.  That covers the mechanism but not the decision.  The
failure arm of apply_firewall_rules could discard the residual it was handed
and the test would still pass, because it never goes through that arm.

That branch is also the one least likely to be reached by accident.  On any
host without iptables the inner apply fails on its first command, so it rolls
back nothing and reports an empty residual -- the interesting case needs a
rollback whose own removal command failed, which no unit test can produce by
running the real thing.

Extracted the recording step as record_apply_outcome so a test can hand it
exactly that outcome, and added a test that drives it and then asserts the
downstream teardown still names the chain.  A negative control covers the
clean-failure side, so the assertion cannot be satisfied by retaining
unconditionally.

Mutation-verified, and the mutation is what makes the point: making the
failure arm drop the residual kills the new test and leaves the old one
green.

112 tests pass.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 52fb9e8c-4a48-435c-9964-8ec0fee653c6
Copilot AI review requested due to automatic review settings August 7, 2026 00:32

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 11 out of 12 changed files in this pull request and generated 1 comment.

Suppressed comments (2)

src/backends/lxc/common/src/signal_cleanup.rs:194

  • This does not isolate the process-global slot from other parallel tests. Several network_iptables tests call force_cleanup or remove_firewall_rules, which reach teardown_created and publish through set_active_created; if one runs while this test has registered ctr-a/ctr-b, it can overwrite the expected record and make the test flaky. Use a shared test lock for every test path that publishes, or inject a non-global registry for these tests.
    /// `ACTIVE_CONTAINER` is process-global and the test binary runs tests in
    /// parallel, so the whole publication contract is asserted in one test.
    /// Splitting it would let two tests race on the same slot.
    #[test]

tests/scripts/run_lxc_network_dualstack_test.sh:165

  • When external DNS or getent is unavailable, this branch only prints SKIP and continues, so the script ultimately exits 0 and the suite records PASS even though the hostname-to-AAAA behavior was never exercised. Since the other integration tests already cover literal/CIDR rules, return the suite's exit-77 skip here instead of reporting a successful dual-stack-hostname test.
else
    EXTERNAL_DUALSTACK=0
    echo "SKIP: external dual-stack DNS unavailable; skipping external hostname resolution assertions."
fi

Comment on lines +910 to +912
Self::run_iptables(&["-N", &self.chain_name], logger)?;
created.v4_chain = true;
Self::publish_created(created);

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the same window raised on #632 (:809), and my answer is the same: real, understood, and deliberately left in place because every fix I can reach from here is worse than the gap.

iptables -N can succeed and the process can be signalled before ownership is published, stranding a chain nobody records as theirs.

Publishing before the -N does not fix it, it inverts it into something worse. Two starts race for the same chain name; the loser publishes ownership of a chain it did not create, and its cleanup then deletes the winner's chain and leaves the winner's container running unfiltered. That trades a leak for a fail-open.

The leak is the benign side of that trade. A stranded chain is not hooked into FORWARD by anything -- -N only creates it -- so it filters nothing and blocks nothing. It costs a chain name until the next teardown for that container reclaims it. Nothing escapes containment.

Closing it properly needs the create and the publish to be one atomic step against host state shared across processes, which iptables does not offer. The available answer is a lock file or a reconciler that sweeps unowned MXC_-prefixed chains at startup, and both are more machinery than the leak justifies right now.

I have not been able to exercise it either: it needs Linux plus real signal delivery, and this branch's suite runs on Windows where every iptables command fails at spawn. Noting it as a known gap in the PR description rather than claiming a fix I cannot demonstrate.

teardown_created already computes, per family, whether the FORWARD hook
delete succeeded -- but the flush and delete that follow ignored it.

-F succeeds regardless of who references the chain, and an emptied user
chain returns to its caller instead of reaching its own closing DROP, so
flushing a still-hooked chain unfilters a container that may still be
running.  The -X would have failed anyway, since iptables refuses to
delete a referenced chain, so the flush bought nothing and cost the
container its filtering.

Gate the whole step -- flush included -- on that family's hook being
confirmed gone, and keep the chain published so a later pass retries.
The gate is per family because the two chains live in different tables
and are referenced independently.

This is inherited from main, which flushes unconditionally, but this
branch rewrote the block and doubled the exposure by adding a second
address family.  microsoft#632 and microsoft#633 fix the same fail-open in the same file;
teardown_chain is deliberately identical to the one microsoft#632 landed, so
whichever merges second resolves to a no-op.
Copilot AI review requested due to automatic review settings August 7, 2026 01:24

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 11 out of 12 changed files in this pull request and generated 1 comment.

Suppressed comments (2)

src/backends/lxc/common/src/signal_cleanup.rs:195

  • This test is not isolated merely because all slot assertions are in one function. The parallel network_iptables tests call teardown/apply paths that invoke set_active_created; while this test has name = Some(...), those tests can overwrite slot.created and make these assertions flaky. Serialize every test that publishes to this registry with one shared lock, or inject the publisher.
    /// `ACTIVE_CONTAINER` is process-global and the test binary runs tests in
    /// parallel, so the whole publication contract is asserted in one test.
    /// Splitting it would let two tests race on the same slot.
    #[test]
    fn the_watchdogs_view_of_a_container_is_built_and_reset_as_a_single_unit() {

tests/scripts/run_lxc_all_tests.sh:81

  • The all-skipped branch still falls through with exit status 0, so automation reports the suite as successful even though the comment says it must not look green. Return the established skip status after printing the warning.
if [ "$PASSED" -eq 0 ] && [ "$FAILED" -eq 0 ]; then
    echo "WARNING: no tests actually executed; every test was skipped."
fi

Comment on lines +1224 to +1228
NetworkIptablesManager::force_cleanup(
"racer-that-won",
Some("mxcv-winner"),
CreatedResources::for_test(true, false, false, false),
&mut noisy,
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Needs-Attention Issue needs attention from Microsoft

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants