Skip to content

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

Open
dhoehna wants to merge 6 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
dhoehna wants to merge 6 commits into
microsoft:mainfrom
dhoehna:user/dahoehna/lxc-net-ipv6-cidr

Conversation

@dhoehna

@dhoehna dhoehna commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

What

Firewall mode (enforcementMode: "firewall") resolved allowedHosts / blockedHosts to IPv4 addresses only. Two consequences on the LXC backend:

  • Dual-stack bypass. Under defaultPolicy: allow, a blockedHosts entry emitted a v4 DROP but no v6 rule, so traffic to the same destination over IPv6 hit the default ACCEPT and left the sandbox unfiltered. Under defaultPolicy: block the same gap showed up as breakage instead: an allowed host reachable only over IPv6 was unreachable.
  • CIDR entries were silently dropped, in both families. A string containing / failed IpAddr::parse, then failed DNS resolution, and was discarded with a warning. 140.82.112.0/20 never became a rule.

This PR fixes both for the LXC backend.

How

All production changes are confined to src/backends/lxc/common/src/network_iptables.rs.

Change Detail
resolve_host returns split families Returns ResolvedDestinations { ipv4, ipv6 }. Hostnames resolve to both A and AAAA records; bare literals and validated CIDRs pass through in their own family.
destination_family validates CIDRs Address must parse and the prefix must be in range (<=32 v4, <=128 v6). Host bits are not required to be zero — iptables applies the mask itself — so the CIDR is forwarded verbatim.
CIDR prefix must be ASCII digits u8::from_str accepts a leading +, so 10.0.0.0/+24 would validate and be forwarded. iptables silently canonicalises it to 10.0.0.0/24, applying a policy typo instead of reporting it. Also subsumes the embedded-slash case.
Empty entries resolve to nothing An empty or whitespace-only entry returns no destinations. Otherwise the DNS branch formats ":0", which Winsock resolves to every local interface address, emitting rules for the host's own addresses. config_parser assigns host lists verbatim, so an empty string does reach this path from a policy file.
Malformed entries are skipped, not fatal An out-of-range or non-numeric prefix is reported as an unresolved host and omitted. Handing it to iptables would make the command fail and abort setup for the entire policy.
Parallel v4/v6 programming IPv4 rules go to iptables, IPv6 rules to ip6tables, each with its own per-container chain and FORWARD hook.
ip6tables probed once If the binary is absent or IPv6 is disabled in the kernel, the IPv4 chain is still applied and the count of unapplied IPv6 rules is logged. A hard dependency would break IPv4-only hosts that worked before dual-stack.
Rollback and teardown A failure after partial chain creation is rolled back before returning the error; teardown removes both families' hooks and chains.

Scope

This covers the IPv6 + CIDR item of AB#62830559 only.

Port and protocol filtering are deliberately not included. Enforcing them requires structured egress rules (destination + port + protocol) in the config schema. network.allowedHosts / blockedHosts are flat lists of host strings with nowhere to attach a port, and wire::Network carries #[serde(deny_unknown_fields)], so there is no way for a user to express a port today. That schema work is AB#62830582 (allowedHosts -> egress.allow[]/deny[]), and the GA wire schema is currently not in main — it landed in #676 and was reverted in #707.

Rather than carry enforcement code that nothing can reach, this PR ships only what is reachable from the current config schema. Port/protocol filtering will follow once AB#62830582 lands, in a change that can be reviewed against the real schema shape.

This PR replaces #631, which mixed the reachable IPv6/CIDR work with an internal egress-rule model that no parser populated.

Known gap

Allow-list rules are emitted before block-list rules and iptables is first-match-wins, so a destination present in both lists is ACCEPTed. GA specifies deny-wins. Reconciling that ordering is owned by net-model-2 (AB#62830341) and is called out in a NOTE on build_policy_rule_args.

Testing

cargo test -p lxc_common — 83 passing on Linux, 79 on Windows (the delta is cfg(unix)-gated tests), none ignored. wxc_common is unchanged. cargo fmt --check and cargo clippy -- -D warnings are clean on both platforms.

The 29 tests across the three *_spec_tests.rs files 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. Two of the defects fixed above were found that way — the empty-entry hole reproduces only on Windows, where getaddrinfo treats an empty node name as a request for every local address.

  • Resolution — family routing for literals and CIDRs; CIDRs forwarded verbatim; host bits not required to be zero; prefix bounds at both ends (/0 and /32, /0 and /128); out-of-range, malformed and non-digit prefixes rejected; IPv4-mapped IPv6 literals stay in the v6 family; hostnames populate both families; empty input resolves to nothing.
  • Rule generation — v6 destinations never leak into the v4 bucket; mixed-family lists split correctly; ACCEPT/DROP mapping; allow-before-block ordering asserted independently in each family; base chain rules carry no address-family-specific token (a -p icmp there would break the whole v6 chain); chain-name prefix and 24-character cap; an unresolvable host contributes no rules, so a typo cannot silently widen policy.
  • Lifecycle — a new manager reports no rules applied; a non-firewall enforcement mode is a successful no-op; the enforcement-mode gate is not inverted, which matters most because an inverted gate would skip all filtering while reporting success. That test enumerates the enum exhaustively, so a newly added variant fails to compile rather than silently defaulting to the wrong side.

Effectiveness was measured rather than assumed. A cargo-mutants run over network_iptables.rs, plus targeted mutations, confirm the parsing and rule-generation logic is pinned: removing the empty-entry guard, reverting the digits-only prefix check, hardcoding rules_applied, flipping the skip-path result, inverting the enforcement gate, routing v4 CIDRs to the v6 bucket, widening either family's prefix bound, swapping ACCEPT/DROP, reordering allow and block rules, dropping all IPv6 destination rules, pushing AAAA results into the IPv4 bucket, and widening the chain-name cap are all caught. The surviving mutants are in the command-execution layer (run_iptables, teardown_chains, force_cleanup, Drop), which shells out to real binaries and is exercised by the integration scripts below rather than by unit tests.

One earlier false negative is worth recording, because the guard that caused it is a common pattern: the AAAA-into-IPv4 mutation, precisely the bypass this PR fixes, originally left the suite green. The only hostname test used localhost, which resolves to 127.0.0.1 alone on many hosts, so the v6 branch never executed. A family-purity invariant that holds regardless of what DNS returns now covers it.

Integration configs and scripts, all wired into run_lxc_all_tests.sh:

  • lxc_network_ipv6_cidr.json — v4 CIDR, v6 CIDR and v6 literal in both allow and block lists. Asserts every entry resolved, no rule rejected, default-deny applied and the v6 half not skipped, plus a config-drift guard.
  • lxc_network_invalid_cidr.json — malformed prefixes. Asserts each produces an unresolved-host warning and that setup still succeeds.
  • lxc_network_dualstack_hostname.json — hostnames with both A and AAAA records (dns.google, one.one.one.one) alongside v4/v6 literals and CIDRs. This is the bypass the work item exists to fix. Offline runners skip the external-hostname assertions rather than report a false failure, but still assert chain creation.
  • lxc_network_cidr_boundary.json/0, /32, /128, non-zero host bits, and the defaultPolicy: allow path, which no other LXC network test covered.

All four were executed as root under WSL against real containers: each creates a container, programs real v4 and v6 chains, and is asserted to leave no MXC-* chain behind afterwards. Assertion liveness was verified by flipping defaultPolicy in a config and confirming the script exits 1 with FAIL: default-deny policy was not applied.

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 review requested due to automatic review settings July 31, 2026 21:13
@dhoehna
dhoehna requested a review from a team as a code owner July 31, 2026 21:13

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);
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));
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.",
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
Comment on lines +52 to +54
if ! echo "$OUTPUT" | grep -q "Default network policy: DROP"; then
fail "default-deny policy was not applied."
fi
…#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"
…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

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);
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants