[LXC] Filter IPv6 destinations and CIDR ranges in firewall mode (AB#62830559) - #724
[LXC] Filter IPv6 destinations and CIDR ranges in firewall mode (AB#62830559)#724Darren Hoehna (dhoehna) wants to merge 21 commits into
Conversation
…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
There was a problem hiding this comment.
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. |
| logger.log_line(&format!( | ||
| "Firewall setup failed: {}. Cleaning up partial iptables state.", | ||
| e | ||
| )); | ||
| self.teardown_chains(logger); |
There was a problem hiding this comment.
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.
| 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)); |
There was a problem hiding this comment.
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.
| 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.", |
There was a problem hiding this comment.
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.
| # 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 |
There was a problem hiding this comment.
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.
| if ! echo "$OUTPUT" | grep -q "Default network policy: DROP"; then | ||
| fail "default-deny policy was not applied." | ||
| fi |
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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
ip6tablesis 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 fordefaultPolicy: blockpolicies 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
-Nfails because this chain already exists. In that case this manager created nothing, butteardown_chainsflushes 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
wgetcannot 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 uses0.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), solxc-execrejects this fixture before any boundary CIDRs are programmed. Use a currently supported schema version.
"version": "0.4.0-alpha",
| 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" |
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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
-Nfails 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 -Sprobe 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 whenip6tablesis 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
wgetreachability 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 emittedFirewall 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
There was a problem hiding this comment.
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
-Nfailed, 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
ip6tablesbinary is absent. This also omits the terminal IPv6 DROP fordefaultPolicy: block, even whenpolicy_rules.ipv6is 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_straccepts a leading+, so10.0.0.0/+24is 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
ip6tablesunconditionally even when the availability probe skipped creation of the IPv6 chain. On a host where the binary exists but IPv6 is disabled, these-F/-Xcalls 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
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
There was a problem hiding this comment.
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_argsat 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 sameResolvedDestinationsfor 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
ip6tablescommand as equivalent. If the kernel still has IPv6 enabled but the binary is missing, evendefaultPolicy: blockgets 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
-Nfailed 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-execto exit zero, but the workload depends on an external API. On an offline runner,wgetfails and the boundary test reports a prefix failure even though firewall programming succeeded (lxc-execpropagates 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.shalready requires UID 0, so invokingsudohere is unnecessary and makes this cleanup assertion silently pass whensudois 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.shalready requires UID 0, so invokingsudohere is unnecessary and makes this cleanup assertion silently pass whensudois 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.shalready requires UID 0, so invokingsudohere is unnecessary and makes this cleanup assertion silently pass whensudois 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.shalready requires UID 0, so invokingsudohere is unnecessary and makes this cleanup assertion silently pass whensudois 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); |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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
-Nfailed because this chain already existed. In that case this invocation created nothing, butteardown_chainsstill 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
ip6tablesbinary 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, sodefaultPolicy: blockand 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
-oto-ichange 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-owould 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,
)?;
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>
There was a problem hiding this comment.
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) whenPASSED==0 && FAILED==0so 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
| 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(()) | ||
| } |
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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) fromUnusableButIpv6Active(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 butip6tablesis 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 +/-emutates 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 anif bash \"$script\"; then status=0; else status=$?; fipattern, which is compatible withset -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
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
There was a problem hiding this comment.
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\"whenPASSED==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
ip6tablesreduces where the test can run (e.g., IPv4-only hosts or minimal environments). Consider makingip6tablesoptional here (and only checkingip6tables -S \"$CHAIN_NAME\"cleanup whenip6tablesis 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."
There was a problem hiding this comment.
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
errexitoff and then unconditionally turns it on withset -e, which can change the runner’s behavior if-ewasn’t enabled before enteringrun_test. To avoid leaking shell option state, capture whether-ewas 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., exportSKIP_EXITfrom the runner, or source a sharedtests/scripts/common.shthat definesSKIP_EXITandskip()).
# 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 RustIpv6Addr::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 normalizingaaaato a canonical form (e.g., viapython3 -c 'import ipaddress; print(ipaddress.ip_address(...).compressed)') before callingassert_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
There was a problem hiding this comment.
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_iptablestests callforce_cleanuporremove_firewall_rules, which reachteardown_createdand publish throughset_active_created; if one runs while this test has registeredctr-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
getentis unavailable, this branch only printsSKIPand 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
| Self::run_iptables(&["-N", &self.chain_name], logger)?; | ||
| created.v4_chain = true; | ||
| Self::publish_created(created); |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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_iptablestests call teardown/apply paths that invokeset_active_created; while this test hasname = Some(...), those tests can overwriteslot.createdand 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
| NetworkIptablesManager::force_cleanup( | ||
| "racer-that-won", | ||
| Some("mxcv-winner"), | ||
| CreatedResources::for_test(true, false, false, false), | ||
| &mut noisy, |
Linked work item: AB#62830559 — LXC IPv6 + CIDR destination filtering in firewall mode
Summary
In firewall mode (
enforcementMode: "firewall"), the LXC backend resolvedallowedHosts/blockedHoststo 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 toiptables/ip6tables, closing roadmap item 19 (IPv6 + CIDR parsing). Bubblewrap picks this up too, because it delegates to the same manager.iptables, IPv6 rules toip6tables, each with its own per-container chain andFORWARDhook.140.82.112.0/20,2606:50c0::/32) are forwarded verbatim; the prefix must be ASCII digits within family range (<=32v4,<=128v6). Invalid CIDR is skipped with a warning rather than treated as fatal. Onmaina/-containing entry failed IP parse, then failed DNS, and produced no rule at all, so these entries take effect for the first time here.":0"lookup that would otherwise program the host's own interface addresses.ip6tablesis 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 butip6tablesis unusable, setup now fails closed; onmainthat 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.
remove_firewall_rulesandDropretry it instead of reporting success over a leaked chain.FORWARDhook is still installed.-Fsucceeds 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-Xwould 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.apply_firewall_rulesreplace 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.iptablescommand at all, which matters because chain names truncate to 20 characters and can collide with a different live container.Merge-order constraints
network_iptables.rsand conflicts. Whoever merges second must resolve keeping [LXC] Address network policy gaps - model 2 (deny-all-except-proxy) #632's deny-before-allow ordering; resolving toward this PR regresses deny-wins precedence.teardown_chainhere is deliberately byte-identical to the one [LXC] Address network policy gaps - model 2 (deny-all-except-proxy) #632 landed, so that conflict resolves to a no-op. [LXC] State-aware lifecycle management #633 reaches the same outcome by re-readingFORWARDinstead of trusting the delete's exit code; either resolution is correct, and neither may be resolved by takingmain's unconditional flush.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
mainand is preserved, not introduced, here. GA deny-precedence is owned by AB#62830341 (net-model-2, PR #632) and is documented in aNOTEonbuild_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/blockedHostslists are flat host strings with nowhere to attach a port, and the GAegress.allow[]/deny[]schema is not inmain. 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.lxc_network_ipv6_cidr,lxc_network_invalid_cidr,lxc_network_dualstack_hostname,lxc_network_cidr_boundary) are wired intorun_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_commandlogs 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-Xsucceeding. Nothing here was exercised against liveiptables,ip6tables, or real signal delivery.AB#62830559
Microsoft Reviewers: Open in CodeFlow