Skip to content

[LXC] Enforce the deny-all-except-proxy network policy (model 2) - #798

Merged
Darren Hoehna (dhoehna) merged 74 commits into
mainfrom
user/dahoehna/lxc-network-model2
Aug 14, 2026
Merged

[LXC] Enforce the deny-all-except-proxy network policy (model 2)#798
Darren Hoehna (dhoehna) merged 74 commits into
mainfrom
user/dahoehna/lxc-network-model2

Conversation

@dhoehna

@dhoehnaDarren Hoehna (dhoehna) commented Aug 9, 2026

Copy link
Copy Markdown
Contributor

Refs AB#62830341 — [LXC] Network policy model 2. The GA-blocked LXC network
work is tracked separately under AB#63505947.

Summary

Give LXC a deny-by-default outbound network posture, with exactly one exception:
a cooperative proxy.

Covers Linux roadmap N1 (default-deny outbound), N4 (deny-wins
precedence), N5 (proxy env vars and enforcement), and row 22 (proxy
env-var hygiene) for LXC — for forwarded egress only. See Not covered
below; the roadmap rows stay 🟡 Actionable. The N7 GA schema migration is
untouched.

What it does

  • Default-deny outbound. Under enforcementMode of firewall or both,
    each container gets its own MXC-<slug>-<hash> chain, hooked into FORWARD
    with --physdev-in on the container veth. The physdev match is what makes
    the rules fire at all: the veth is enslaved to lxcbr0, so a plain
    -i <veth> match takes no packets. The default mode, capabilities,
    installs no rules.
  • Fails closed when the hook cannot be scoped. If the veth cannot be
    determined, setup returns Err and names the unenforced chain instead of
    reporting success with a fully populated chain that nothing jumps to.
    Rollback is best-effort: anything a removal command fails to delete stays
    owned, so teardown retries it rather than stranding it. A rule whose insert
    failed is deleted before its claim is released, so a partial failure cannot
    leave behind a rule nothing owns. Bubblewrap has no host-side veth by
    construction, so it declares that and keeps its existing warn-and-skip.
  • Deny wins.blocked_hosts are programmed ahead of allowed_hosts, so an
    address named in both is dropped. The base port-53 allowance and the base
    ESTABLISHED,RELATED accept precede both.
  • Cooperative proxy. Caller-supplied proxy variables are scrubbed
    case-insensitively, so a sandboxed process cannot override or disable the
    proxy. HTTP_PROXY, HTTPS_PROXY, and ALL_PROXY are set to the configured
    URL in both spellings, and NO_PROXY is forced empty so an image-baked value
    exempts nothing. In proxy mode the chain carries the proxy ACCEPTs and its
    closing DROP and nothing else, and IPv6 egress is denied outright.
  • Refuses a host list the proxy would make meaningless.blockedHosts
    combined with a proxy is an error, not a warning: the proxy can fetch a
    blocked destination on the container's behalf, so the block list would read as
    programmed while enforcing nothing. allowedHosts only warns, because the
    proxy is strictly narrower than the allowance being asked for. A proxy
    hostname resolving to more than 16 addresses is trimmed to the first 16, so a
    round-robin answer cannot become an unbounded rule set — and an unbounded
    number of iptables calls — on the container-start path.
  • Pins the proxy so a proxied container can still find it. A proxied chain
    opens no port 53, so the proxy's address is written straight into the
    container's /etc/hosts as a marked line. A failed pin stops the container
    and fails the run instead of executing the script against a proxy it cannot
    reach. The line is removed at the end of the run that wrote it, so a
    container reused across runs never inherits an address a later policy did not
    authorize.
  • Parse-time rejections. An LXC proxy requires an enforcementMode of
    firewall or both: under capabilities no rules are installed, so the
    config would read as deny-all-except-proxy while enforcing neither half.
    Loopback and built-in-test-server proxies are refused as well, since the
    container's loopback is not the host's.
  • Refuses proxy credentials at the runner too. A proxy URL carrying inline
    credentials is rejected, because both backends put it in a child process's
    argument vector — lxc-attach --set-var and bwrap --setenv — where any
    local user can read it out of /proc/<pid>/cmdline. The parser guard only
    covers requests the parser built, and ExecutionRequest is public, so LXC and
    Bubblewrap each carry the same check at their runner boundary as well.
  • Runs in CI..github/workflows/lxc-e2e.yml installs the LXC stack,
    enables br_netfilter, and runs the enforcement suite against real containers
    and real iptables, with MXC_LXC_TESTS_REQUIRE_EXECUTION=1 so a skipped case
    fails instead of passing quietly.

Not covered

Validation

  • cargo test -p wxc_common -p lxc_common -p bwrap_common -p lxc — 1049 tests,
    0 failures.
  • cargo clippy --all-targets -- -D warnings and cargo fmt --check — clean.
  • LXC E2E suite — 21 passed, 0 failed, 0 skipped, run as root against real LXC
    and real iptables.

All three were run on the merge head, after origin/main was merged in.

Microsoft Reviewers: Open in CodeFlow

LXC did not scrub proxy environment variables from caller-supplied env,
so a caller could point a sandboxed process at an egress path the network
policy never authorized, or disable the cooperative proxy outright.
Add `apply_proxy_env` to `wxc_common::proxy_env`, the LXC entry point.
It delegates to `apply_cooperative_proxy_env` so LXC scrubs and sets
exactly the same key set as Bubblewrap and WSLc rather than maintaining a
parallel list that can drift. With the proxy disabled the vars are still
stripped. It returns `true` unconditionally, including for an empty
env: the return value tells the caller to emit `--clear-env`, and an
empty vector must still stop `lxc-attach` inheriting the MXC host
process environment, which carries both proxy vars and credentials.
Add `FTP_PROXY`/`ftp_proxy` to `PROXY_ENV_KEYS`. Both spellings of
every family are now present, and the doc comment records why the
lower-case duplicates are kept.
Tests are black-box integration tests in `tests/proxy_env_spec.rs`,
written against the public API by an author who did not see the
implementation. All 22 pass; 7 of 7 seeded mutants are caught with no
survivors.
This is slice 1 of the work previously attempted in PR 632, re-cut from
main so each slice is reviewable on its own.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 7155573c-8938-4622-abf7-4594fb17eb3d
The test module header described client (a) in the present tense, which
read as though the LXC backend already calls `apply_proxy_env`. It does
not: the helper has no call site yet, and `attach_run` still derives
`--clear-env` solely from `env` being non-empty
(`lxc_bindings.rs:90`).
Record the divergence while it is cheap to see. `apply_proxy_env`
returns `true` even for an empty env so the MXC host environment cannot
leak into the container, whereas current code emits no `--clear-env` in
that case and pins the behavior with a test at `lxc_bindings.rs:743`.
The integration slice has to update both.
Comment only. No assertion changed; the tests validate the helper
contract, which is what they are for.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 7155573c-8938-4622-abf7-4594fb17eb3d
Model 2 needs the sandbox and the firewall to agree on exactly one proxy
endpoint. Otherwise the sandbox re-resolves the hostname itself and,
under round-robin or split-horizon DNS, reaches an address the firewall
never authorized.
PR 632 solved this by rewriting the proxy URL's host to the resolved IP.
Review rejected that (comment 3724788051): an `https://`-scheme proxy
would then be contacted at an IP literal, so SNI and certificate
validation fail unless the proxy certificate carries an IP SAN.
Add `ProxyHostPin` and `ProxyAddress::host_pin` instead. These
express the mapping as a hosts-file pin, so the hostname stays in the
URL and TLS identity is preserved while the endpoint is still forced.
`host_pin` returns `None` when the address is already an IP literal,
because there is then nothing to resolve. `hosts_line` writes the
address bare: a hosts file takes an unbracketed IPv6 literal, unlike a
URL host component.
Also fix `to_url`. It hardcoded `127.0.0.1` whenever no original URL
was recorded, regardless of the actual address. That is reachable:
`unix_proxy_coordinator.rs:234` builds a `ProxyAddress` from the
configured bind address with no original URL, so a proxy bound to a
non-loopback address reported an endpoint it was not listening on -- the
same class of defect as the objection above. Every existing caller
passes `127.0.0.1`, so their output is unchanged.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 7155573c-8938-4622-abf7-4594fb17eb3d
Mutation testing surfaced an equivalent mutant: deleting the
`starts_with('[')` early return from `bracket_if_ipv6` changed no
observable behavior. Verified why, rather than assuming the tests were
weak -- `IpAddr::from_str` rejects brackets, so `[::1]` already fell
through the catch-all arm unchanged and could never be bracketed twice.
The guard was dead code. Remove it and record the reason.
The mirror case is NOT dead, and mutation proves it: replacing
`Self::unbracket(&self.address)` in `host_pin` with the raw field
fails a test. Unbracketing there is what lets a bracketed IPv6 literal
be classified as a literal instead of pinned as though it were a
hostname. Say so in the doc comment, which previously described it as
mere normalization.
Comment and dead-code only. All 566 library tests and 19 spec tests
pass unchanged, and the seeded-mutant suite now runs 9 for 9 with no
survivors.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 7155573c-8938-4622-abf7-4594fb17eb3d
Review on PR 789 pointed out that ProxyHostPin's fields were public
Strings, so a caller could set ip to "[::1]", to the empty string, or to
text containing a newline, and hosts_line() would emit it verbatim. That
is an injection into /etc/hosts: a newline ends the record and starts a
second, unauthorized mapping. The type exists to guarantee the sandbox
and the firewall agree on one endpoint, so a value that denotes two
mappings defeats its whole purpose.
The fields are now private and the address is an IpAddr, so no such value
can be constructed. IpAddr also renders IPv6 bare, which is what a hosts
file requires -- the difference from to_url, which brackets, is now
structural instead of a convention a caller has to remember.
host_pin returns Result<Option<ProxyHostPin>, WxcError>. Ok(None) keeps
its single meaning: the address is an IP literal, so there is nothing to
resolve. An empty or malformed hostname is now Err, not None. Folding
it into None would have told the caller "no hosts entry required", so a
malformed address would silently skip the pin and let the sandbox
re-resolve the name -- failing open, which is the defect review objected
to elsewhere in this work.
Tests are updated in a separate commit by the author who did not write
this implementation.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 7155573c-8938-4622-abf7-4594fb17eb3d
22 black-box tests against the new host_pin contract, written by an
author who has not read models.rs.
The empty address moved from Ok(None) to Err, so the test that covered
it was rewritten to match on all three arms by name. Asserting
is_err() || is_none() would have passed either way, and the whole point
of the change is that those two answers are not interchangeable: Ok(None)
tells the caller no hosts entry is needed, which is how a malformed
address ends up unpinned and the firewall bypassed.
Added coverage for the injection strings review called out -- a hostname
carrying a newline or a space must be Err and must never reach
hosts_line. Dropped the test that stripped brackets from the ip
argument; ip is an IpAddr now, so there is no textual form to strip and
the behavior no longer exists.
Mutation harness: 11 mutants, 11 caught by a failing test, 0 survivors.
Four of them removed the last call to a private helper, which the crate's
deny-warnings turns into a build failure -- real detection, but by the
compiler, which proves nothing about the tests. The harness now
suppresses those lints for the mutated build so the suite has to answer
for itself.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 7155573c-8938-4622-abf7-4594fb17eb3d
install_firewall_rules built the full deny-all chain and then, when no veth
interface was known, logged a warning and returned Ok(()). The chain is only
ever reached from FORWARD via `-i <veth>`, so without that hook nothing
traverses it: the caller was told the network policy was applied while zero
packets were filtered.
That is the worst of the three possible outcomes. Installing the rules
host-wide instead would at least filter, but unscoped they would hit every
container and the host's own traffic. Returning an error loses nothing,
because there was no enforcement to lose.
This path is only reachable when the caller explicitly asked for firewall
enforcement -- apply_firewall_rules returns early unless the mode is Firewall
or Both, and NetworkEnforcementMode defaults to Capabilities. So the change
cannot affect containers that never wanted a firewall.
Rollback and teardown already handle the Err: apply_firewall_rules_inner
converts it into a precise teardown of exactly what was created plus residual
ownership, and lxc_runner destroys the container rather than starting a
workload that believes it is confined.
No existing test pinned the old behavior (115/115 still pass), which is
itself the point: the fail-open was untested. The four Linux E2E scripts that
exercise firewall enforcement already require "FORWARD hook installed" in the
output and fail without it, so veth discovery demonstrably succeeds there and
this change is a no-op for every run that passes today.
Slice 3 of the PR 632 re-cut. Refs AB#62830341.
Six black-box tests for apply_firewall_rules, written against the documented
contract by an author who did not read the implementation, so they describe
the behavior that was intended rather than mirroring whatever the code does.
They pin:
- refusal when the veth interface is unknown, under Firewall and under Both,
separately, so a fix scoped to one enforcement mode cannot pass
- the error names the chain left unenforced, so an operator has something to
search for
- the negative control: the same policy succeeds once an interface is set.
Without it, an apply that always returned Err would pass every other test
- teardown of the chain created before the refusal, asserted as ordering
against the creation command rather than mere presence
- Capabilities-only containers issue no firewall commands at all, which is
what bounds this change's blast radius
Mutation tested: seven seeded defects, all caught by a failing test, no
survivors. The seeds include restoring the old Ok(()) fail-open, dropping the
chain name from the message, applying the check to Firewall but not Both,
inverting the interface check, skipping rollback, and swallowing the error one
layer up in record_apply_outcome. Each mutant compiles with lints silenced, so
a defect detected only by the compiler counts as a harness failure rather than
a pass -- the tests have to answer for themselves.
Attached as a #[path] child module because the fake-firewall seam is
#[cfg(test)] and private, which an integration test -- a separate crate --
cannot reach.
Slice 3 of the PR 632 re-cut. Refs AB#62830341.
…ters
The per-container chain was hooked into FORWARD with `-i <veth>` only. That
matches nothing whenever the veth is enslaved to a bridge, which is the
default LXC topology: the packet is bridged onto `lxcbr0` and then routed off
it, so FORWARD sees the bridge as the input interface and never the veth. The
chain was built correctly, populated correctly, hooked without error, and
traversed by zero packets.
Measured on a live container before this change, with `defaultPolicy: block`
and no allowed hosts: every counter in the chain read 0, the closing DROP
included, and a fetch from inside the container succeeded. Adding a counting
rule on the same traffic in the same FORWARD chain gave 11 packets for
`-i lxcbr0` against 0 for `-i <veth>`.
Install a second hook per family matching `-m physdev --physdev-in <veth>`,
which identifies the bridge port the packet entered on and so stays scoped to
one container -- matching the bridge itself would apply one container's policy
to every container sharing it. The two rules are mutually exclusive for any
given packet, so a directly routed veth is still carried by the `-i` rule and
nothing is counted twice.
Fail closed on the two conditions that would leave the chain unreachable
again, in the same voice as the missing-veth refusal: a bridged veth whose
`bridge-nf-call-{ip,ip6}tables` toggle is absent or 0, and a bridged veth
whose physdev hook will not install. On a directly routed veth the physdev
rule is redundant, so a kernel without the match warns instead of failing.
Teardown removes both forms, built from the same builders used at insertion so
a delete cannot drift from the insert it has to match, and the chain delete now
waits on both hooks because either surviving one still references the chain.
Verified on a live container: `defaultPolicy: block` with no allowed hosts
now blocks, the same policy with `api.github.com` allowed still reaches it,
all five network E2E scripts pass, and teardown leaves no FORWARD reference
and no chain behind.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 7155573c-8938-4622-abf7-4594fb17eb3d
Two kinds of test, because the defect this slice fixes was invisible to both
kinds the repository already had.
The unit specs pin the four seams the hook is built from: the two rule-args
builders, bridge-enslavement detection, and the bridge-netfilter toggle read.
They are written against the documented contract by an author who did not read
the implementation. The guarantees that matter most are that the physdev
builder never collapses into an input-interface match, that it names one
specific bridge port rather than a wildcard, that a delete specification
differs from its insert only by the operation -- iptables deletes by full rule
specification, so a drifted delete silently leaks the hook -- and that an
absent bridge-netfilter toggle reads as inactive, never as safe.
Mutation testing over nine seeded defects, including the exact bug this slice
fixes: 9 caught, 0 survivors.
The E2E script exists because unit tests cannot see the failure at all. Every
existing network script asserts that the FORWARD hook was *installed*, which
is a log line; the hook installed cleanly, named the right chain, and matched
zero packets. So this script asserts the guarantee instead: a destination the
policy does not allow must be unreachable from inside the container, and an
explicitly allowed one must still be reachable. The allow case is not
decoration -- a blocked-only assertion would also pass on a host with no
working network, or on a change that broke egress outright.
Verified in both directions on live containers. Against the fixed
implementation the script passes. Against the implementation from the parent
commit it fails on the deny case with "egress succeeded under a default-block
policy with no allowed hosts", which is the regression it exists to catch.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 7155573c-8938-4622-abf7-4594fb17eb3d
…solvable block
The per-container chain emitted allow-list rules before block-list rules,
and iptables applies first-match-wins within a chain, so a destination
named in both `allowedHosts` and `blockedHosts` was ACCEPTed. A code
comment recorded that as interim behavior owned by AB#62830341. Emit the
block list first so the deny wins. Emission order is the entire
precedence mechanism -- there is no separate resolution pass -- so the
comment now says that outright, because swapping the two iterators back
would reverse the security semantics without failing to compile.
A block entry that resolved to no address programmed no rule and logged
only a warning. Where the chain ends in ACCEPT that is a fail-open: the
unwritten deny rule was the only thing that would have stopped the
traffic, and the apply still reported success.
`build_policy_rules_logged` now returns `Result` and errors in exactly
that case, so the caller rolls back the chains it created rather than
leaving a policy it did not enforce.
The error is conditioned on the default policy rather than raised for
every unresolvable block entry. Where the chain ends in DROP, an entry
that resolves to nothing is redundant rather than missing -- the closing
rule already denies every destination the allow list did not name -- and
erroring there would refuse to start containers whose blocklists name
hosts that do not exist, which is the ordinary case.
`tests/configs/lxc_network_test.json` blocks `evil.example.com` under
`defaultPolicy: block`, and that name is NXDOMAIN.
The two tests that pinned allow-before-block ordering are deleted rather
than inverted. They asserted the contract this change replaces, and the
replacement assertions belong to the `deny_precedence_spec` module, which
is authored separately so that the tests proving this change correct are
not written by its author. The family-split test kept its subject and
gave up only its incidental dependency on rule sequence.
Residual gap, documented in the code rather than papered over: under a
DROP default, a sufficiently broad allow entry can still cover a
destination whose deny rule went unwritten. Detecting that needs the
address the entry failed to resolve to, so no predicate over the policy
text can be complete, and a partial check would imply a guarantee this
code cannot make.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 7155573c-8938-4622-abf7-4594fb17eb3d
…eat a block
The implementation commit changed emission order and made an unresolvable
deny entry fatal under an accepting default. This commit is the evidence
that both hold, written against the documented contract rather than against
the code.
Twelve unit tests in a new spec module, authored from headers only by an
agent that never opened `network_iptables.rs`. The author that wrote the
implementation cannot write its tests: a test derived from the
implementation encodes that implementation's bugs as expected behavior and
will pass forever without catching anything.
The tests assert the contract, not the current output:
- a destination in both lists is dropped, for IPv4, for IPv6, and with
several entries in each list
- every DROP is emitted before every ACCEPT, checked by index rather than
by comparing against a fixed expected vector
- an unresolvable blocked host errors under an accepting default and the
error names the host
- the same unresolvable blocked host does not error under a blocking
default, because the closing DROP already denies it
- an unresolvable allowed host never errors under either default
- an unresolvable entry does not suppress a sibling entry's rule or log line
- v4 and v6 destinations land in their own buckets, asserted by parsing each
destination rather than by matching a known list, so the assertion cannot
be satisfied by an implementation that happens to emit the expected values
Mutation testing supplies the proof that these tests can actually fail.
Nine mutants, each a mistake a person could plausibly make in this function:
restore the old emission order, error on every unresolvable block entry,
error on unresolvable allow entries, never error at all, invert the
default-policy test, swap the jump targets, drop the warning line, leak IPv6
destinations into the IPv4 bucket, and omit the host name from the error.
caught=9 survived=0 harness_bugs=0
source restored byte-identical: True
Mutant 1 is the load-bearing one. Two tests pinning the old
allow-before-block order were deleted in the implementation commit, and a
deletion with no replacement would have dropped coverage silently while the
suite stayed green. Killing mutant 1 proves the replacement exists.
The end-to-end guard runs the real binary against a config whose allowed and
blocked lists both contain `0.0.0.0/0` and `::/0`. Literal CIDRs rather
than a hostname, because a hostname is resolved separately for each list
entry and round-robin DNS could hand back different addresses for the allow
and the deny, making the verdict depend on which address the fetch picked.
The control config is load-bearing. It allows the same destination and
blocks nothing, so it must come back reachable. Without it, a host with no
egress at all would produce the same blocked verdict on the overlap case and
look exactly like a pass.
The guard was verified to discriminate by running it against the previous
commit's binary:
b9946e3 ACCEPT then DROP overlap MXC_NET_ALLOWED guard FAILS, exit 1
447f10f DROP then ACCEPT overlap MXC_NET_BLOCKED guard PASSES
Same script, same host, same configs. The control passed in both runs, so
the difference is the rule ordering and not a host that lost its network.
Gates: 154 unit tests pass, clippy -D warnings clean, fmt clean, all seven
LXC end-to-end scripts pass.
The documentation described a firewall that no longer exists. Slices 3, 4,
and 5 changed what happens on a missing veth, how the chains reach FORWARD,
and which rule wins when the two host lists overlap, and none of it was
written down.
Four claims were false against the code:
- The policy table left precedence unspecified. It is now deny-wins, and the
reason -- first match ends chain evaluation -- belongs in the doc, because
the ordering is the whole mechanism.
- Unresolvable entries were described as always "reported as unresolved and
skipped, leaving the rest of the policy in force". That is now conditional:
under an accepting default an unresolvable blocked host is fatal.
- The FORWARD hook was described as matching the host-side veth as the input
interface. That omits the `--physdev-in` bridge-port rule and the
`br_netfilter` requirement, which is precisely the omission that let a
populated deny-all chain filter nothing.
- "If MXC cannot discover the container veth, it skips the FORWARD hook with a
warning" was flatly wrong. That path returns an error and rolls back.
An independent review caught three further overstatements in the first draft
of this text, all of which were mine and all of which were the comfortable
direction to be wrong in:
- "A deny always wins" is not true. The base chain accepts UDP and TCP port
53 unconditionally and is installed ahead of the policy rules, so DNS to a
blocked destination is accepted before its DROP is reached. Narrowing that
needs to know which resolver addresses are legitimate and no schema field
carries them, so the honest move is to document the exemption rather than
imply a guarantee the chain does not provide.
- A hostname appearing in both lists is resolved once per entry, so round-robin
DNS can return an address for the allow that the deny never saw. The
guarantee holds for addresses, not for names. This was already known -- it
is why the deny-precedence E2E guard uses literal CIDRs -- and it still did
not make it into the prose.
- "Two rules per family" is not unconditional. On a directly routed veth a
missing physdev match warns and continues, because the interface rule is the
one that matches there. Only on a bridged veth is it fatal. The IPv6
bridge toggle is also checked separately and was not mentioned.
## CI
`lxc-e2e.yml` runs the suite on a provisioned Ubuntu runner. Until now no
workflow executed these scripts at all, which is much of how a firewall that
filtered nothing shipped green: the assertions existed and nothing ran them.
The workflow enables `br_netfilter` explicitly. Without it a bridged veth
never reaches FORWARD, every rule installs cleanly, nothing fires, and the
network tests pass against a firewall that filters nothing -- the exact
failure they are supposed to detect.
`MXC_LXC_TESTS_REQUIRE_EXECUTION` turns an honest skip into a failure. A
developer box legitimately lacks ip6tables or LXC and should run what it can,
so a skip stays a warning there. A runner provisioned specifically to execute
this suite is different: a skip means a prerequisite disappeared, and without
this the gate goes green while testing nothing.
Verified by running the suite four ways: normal and strict with prerequisites
present both pass, and strict with the binary removed exits 1 naming the six
skipped tests rather than reporting success.
The first run of this workflow failed three tests, and the three were the
positive controls doing exactly what they exist for. GitHub-hosted runners
ship Docker, and Docker sets the IPv4 FORWARD policy to DROP.
That broke the tests twice over.
Outright: MXC hooks its chain on traffic leaving the container, so an allowed
request is accepted on the way out, but the reply arrives in the opposite
direction, matches no MXC rule, falls through to the policy, and is dropped.
DNS still resolved, because dnsmasq on lxcbr0 is host-local and never
traverses FORWARD, so the symptom was a resolved address that then timed out:
\wget: can't connect to remote host (140.82.116.5)\. IPv4 only, which
matches Docker leaving the IPv6 policy at ACCEPT.
And silently: under a DROP policy a container with no working MXC hook at all
is equally unreachable, so the deny cases would have reported success against
a firewall that filters nothing. That is the exact bug this suite exists to
detect and the reason these tests carry positive controls. Without the
controls this run would have been a green gate over a dead network.
Setting the policy to ACCEPT restores the condition the tests were written
for: the host forwards by default, so the only thing that can block container
traffic is a rule MXC installed, and a missing hook fails the deny case
loudly. A conntrack RELATED,ESTABLISHED rule would have fixed the reply path
while leaving the vacuous pass in place, so it is the wrong fix.
The environment step now prints both FORWARD policies, because a future runner
image that reintroduces DROP would otherwise present as an unexplained
timeout.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 7155573c-8938-4622-abf7-4594fb17eb3d
@dhoehna
Darren Hoehna (dhoehna) requested review from a team and a balanced review from CopilotAugust 9, 2026 21:36
@dhoehna
Darren Hoehna (dhoehna) requested a review from a team as a code ownerAugust 9, 2026 21:36
@azure-pipelines

Copy link
Copy Markdown
Azure Pipelines:
There may be pipelines that require an authorized user to comment /azp run to run.

CopilotAI 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

Strengthens LXC outbound firewall enforcement, deny precedence, proxy handling utilities, and E2E validation.

Changes:

  • Adds veth-scoped FORWARD hooks and fail-closed enforcement.
  • Emits deny rules before allow rules.
  • Adds proxy utilities, tests, documentation, and LXC CI coverage.

Reviewed changes

Copilot reviewed 17 out of 17 changed files in this pull request and generated 4 comments.

Show a summary per file
FileDescription
tests/scripts/run_lxc_network_enforcement_test.shTests effective allow/block enforcement.
tests/scripts/run_lxc_network_deny_precedence_test.shTests deny-wins behavior.
tests/scripts/run_lxc_all_tests.shAdds tests and strict CI mode.
tests/configs/lxc_network_enforcement_deny.jsonDefines default-deny case.
tests/configs/lxc_network_enforcement_allow.jsonDefines explicit-allow case.
tests/configs/lxc_network_deny_precedence_overlap.jsonDefines overlapping rules case.
tests/configs/lxc_network_deny_precedence_control.jsonDefines precedence control case.
src/core/wxc_common/tests/proxy_env_spec.rsTests proxy environment hygiene.
src/core/wxc_common/tests/proxy_address_spec.rsTests proxy URL and host-pin behavior.
src/core/wxc_common/src/proxy_env.rsAdds LXC proxy environment helper.
src/core/wxc_common/src/models.rsAdds proxy host pinning model.
src/backends/lxc/common/src/network_iptables.rsImplements hooks, precedence, and fail-closed behavior.
src/backends/lxc/common/src/network_iptables_veth_spec.rsTests missing-veth handling.
src/backends/lxc/common/src/network_iptables_forward_hook_spec.rsTests FORWARD hook construction.
src/backends/lxc/common/src/network_iptables_deny_precedence_spec.rsTests ordering and resolution failures.
docs/lxc-support/lxc-backend.mdDocuments updated firewall semantics.
.github/workflows/lxc-e2e.ymlAdds LXC E2E workflow.
Suppressed comments (1)

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

  • The IPv6 physdev hook has the same signal window: ip6tables -I can succeed before v6_physdev_hook is recorded and published, leaving the watchdog unable to remove the live hook. Publish pending ownership before the insert and distinguish an absent rule from a failed removal during rollback.
 bridged,
"ip6tables",
logger,
)?;
Self::publish_created(created);

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment threadsrc/core/wxc_common/src/proxy_env.rs Outdated
Comment threadsrc/backends/lxc/common/src/network_iptables.rs Outdated
Comment threadsrc/backends/lxc/common/src/network_iptables.rs
Comment threadsrc/backends/lxc/common/src/network_iptables.rs Outdated
Slice 3 made a missing veth fatal: install_firewall_rules returned Err so a
container could never start believing it was confined by a chain that FORWARD
never reaches. That is right for LXC, which always names a veth once the
container is running, so arriving at rule installation without one means the
lookup lost it.
Bubblewrap has no veth at all. Unprivileged bwrap either shares the host
network namespace or gets a private one, and neither yields a host-side
interface to match on -- bwrap_command.rs says so directly. bwrap_runner
builds a NetworkIptablesManager and never calls set_veth_interface, so every
Bubblewrap sandbox requesting Firewall or Both mode with host rules hit the
new Err and failed to start. On main that path logged a warning and
continued. No test covered it, so all six CI workflows stayed green.
Make the strictness a property the caller declares. The default still fails
closed, so both veth-spec tests and the LXC contract are unchanged.
Bubblewrap calls allow_missing_veth_interface and keeps the pre-existing
warn-and-skip, which leaves its policy unenforced -- a real gap, but a
pre-existing one that belongs to Bubblewrap's own work item rather than to
this LXC change.
Adds three tests: the declared-missing case must succeed under Firewall and
Both, and a manager that never declared it must still fail closed, so the two
behaviors cannot collapse into one.
Found by an independent reviewer auditing whether pre-existing tests needed to
change; the regression was invisible because bwrap_common was never in the
packages this branch had been testing.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 7155573c-8938-4622-abf7-4594fb17eb3d
Mutation M4 -- delete the allow_missing_veth_interface call from bwrap_runner
-- survived the whole suite. That is the same blind spot that let the
regression land: the declaration lived inline in a 300-line execute function
where no test could reach it.
Extract build_firewall_manager so the declaration has a seam, and assert on it
via a new veth_scoping_is_optional accessor rather than by standing up a real
firewall -- lxc_common's fake-firewall seam is cfg(test) and so is invisible
to bwrap_common.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 7155573c-8938-4622-abf7-4594fb17eb3d
Mutation M5 -- make veth_scoping_is_optional always return true -- survived,
so the Bubblewrap suite would have passed on an accessor that could not say
no. Assert a fresh manager reports false.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 7155573c-8938-4622-abf7-4594fb17eb3d
CopilotAI review requested due to automatic review settings August 9, 2026 22:36
Review found the IPv4 and IPv6 hook paths were the same forty lines twice
with tokens substituted, over code that decides whether a container's egress
is filtered. A fix applied there had to be applied by hand in both places
or it silently covered one family. install_family_hooks and
teardown_family_forward now carry both, parameterized by the tool, the
sysctl that governs bridged delivery, and which half of the ownership record
they own.
Ownership was ten flat booleans that every emptiness, rollback, and teardown
path had to enumerate by hand. Nesting them as two FamilyResources is what
makes writing the body once possible at all.
A resolved proxy answer was unbounded, and every address became its own
ACCEPT rule and its own iptables process on the container-start path. It is
capped now. Trimming fails closed and the pin is built from the first
address, so the container still reaches the proxy it was pinned to.
The in-file test named for a warning it never asserted is gone; the spec
covers the same shape and does assert it.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 0bdca3d7-e52b-4354-83ca-84eec7395f18
derive_chain_name was copy-pasted into six scripts, mxc_chains into seven,
and the same extended regular expression appeared six times. Nothing in
bash tied those copies to each other, so a change to the hash width or the
--debug output format had to be made in six places or it silently stopped
matching in the ones that were missed.
They now live in tests/scripts/lib/chain_name.sh, which each script sources.
That removes 253 lines and adds 63, and the helper's derive_chain_name
accepts both extraction patterns the copies had drifted into.
The Rust meta-test that policed the copies shrank with them. Two of its
five tests existed only to prove the copies agreed with each other:
every_script_checks_the_same_documented_shape compared a check that now
appears once, and a_script_that_derives_a_name_also_validates_its_shape
asserted a pairing the helper makes structural. Both are vacuous against a
single definition, and a test that cannot fail is worse than no test because
it still has to be read and maintained.
The three that remain check things a single definition does not:
no_network_script_names_a_specific_chain still catches a hard-coded chain
literal, the shape check now reads MXC_CHAIN_NAME_ERE out of the helper so
the shell and Rust agreement is pinned in one place rather than six, and
every_chain_asserting_script_derives_the_name_it_asserts_on still catches a
script that stopped deriving.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 0bdca3d7-e52b-4354-83ca-84eec7395f18

CopilotAI 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 41 out of 42 changed files in this pull request and generated 1 comment.

Suppressed comments (2)

src/backends/lxc/common/src/lxc_runner.rs:285

  • A reused container with no current hostname proxy never enters this branch, so an #mxc-proxy-pin left by a prior preserved run remains active while the new script executes. The host builds the new firewall policy from fresh DNS, but the container can still resolve through the stale address and bypass a matching deny. Clear any old marker before executing on a reused container when proxy_host_pin() is None, and treat a failed clear as fatal.
 if let Some(pin) = fw_manager.proxy_host_pin() {

docs/lxc-support/lxc-backend.md:264

  • This table says both host lists are simply omitted, but the implementation rejects any proxied policy with blockedHosts (network_iptables.rs:1767-1773) while only allowedHosts is ignored with a warning. Documenting these as the same behavior makes a configuration described as valid fail at startup.
| Programs `allowedHosts` and `blockedHosts` | Programs neither | A block entry is redundant under the closing DROP, and an allow entry naming anything but the proxy contradicts the model |

Comment threadsrc/backends/lxc/common/src/network_iptables.rs
One of them, proxy_env_spec.rs, had lost a newline and was running two
assert! statements together on a single line.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 0bdca3d7-e52b-4354-83ca-84eec7395f18

CopilotAI 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 41 out of 42 changed files in this pull request and generated 2 comments.

Suppressed comments (2)

tests/scripts/run_lxc_network_proxy_hostname_test.sh:185

  • This enables host-wide IPv4 forwarding but never restores its previous value. Running the E2E script locally can therefore leave the machine routing traffic after the test exits or skips. Snapshot the sysctl and restore it from the existing EXIT trap.
    src/core/wxc_common/src/config_parser.rs:464
  • The exact-name check accepts the canonical absolute form localhost. (and the new characterization test explicitly locks that in). The host resolver can then return 127.0.0.1, which LXC pins inside the container; the proxy points at the container’s own loopback and is guaranteed to be unreachable despite passing validation. Normalize a single DNS trailing dot before this check, and update the characterization test to require rejection.
fn host_is_loopback(host: &str) -> bool {
if host.eq_ignore_ascii_case("localhost") {
return true;
}
let candidate = crate::models::unbracket_host(host);

Comment threadtests/scripts/run_lxc_network_proxy_test.sh
Comment threadsrc/backends/lxc/common/src/network_iptables.rs
An `-I` that reports failure may still have landed: a command killed after
the kernel accepted its rule reports failure too. The code then cleared
ownership, which stranded an ESTABLISHED,RELATED ACCEPT on a veth name the
host is free to hand to another container. The chain hooks already closed
this window through install_claimed_hook; the return rules did not.
install_return_rule now takes the builder rather than a built rule, so the
removal it attempts on failure cannot drift from the insert.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 0bdca3d7-e52b-4354-83ca-84eec7395f18

CopilotAI 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 41 out of 42 changed files in this pull request and generated 1 comment.

Suppressed comments (3)

src/backends/lxc/common/src/lxc_runner.rs:285

  • When no pin is produced (no proxy, or an IP-literal proxy), this path never removes a marker left by an earlier interrupted/failed run on a reused container. This contradicts build_hosts_unpin_command's stated requirement that a run which pins nothing clean up stale pins, and lets a mapping outlive the policy that authorized it. Run the unpin operation before executing whenever there is no current hostname pin, treating failure as fail-closed.
 let mut pinned = false;
// A proxied chain opens no port 53, so without this the container has
// no resolver to find its proxy with.
if let Some(pin) = fw_manager.proxy_host_pin() {

src/backends/lxc/common/src/lxc_runner.rs:374

  • Downgrading unpin failure to a warning tears down the firewall while leaving a persistent container running with the old policy-owned hosts mapping. That violates the promised pin lifetime and makes the next run inherit an unauthorized address. For a container that will survive this run, stop it or fail the operation until the pin is removed; do not report ordinary success while removing the corresponding chain.
 // The script has already run, so a failure here cannot change its
// result and must not replace it.
if let Some(reason) = unpin_error {
let _ = writeln!(
logger,
"Warning: failed to clear the proxy host pin: {}",
reason
);
}

src/backends/lxc/common/src/lxc_runner.rs:484

  • This symlink check is vulnerable to the TOCTOU race documented immediately above it: a process retained in a reused container can replace /etc/hosts with a symlink after -h succeeds but before the privileged redirect opens it, causing MXC to truncate an attacker-selected container or writable host-mounted file. The hosts update needs an open-once implementation using openat/O_NOFOLLOW (inside the container mount namespace), rather than a shell check followed by a path reopen.
 "if [ -h /etc/hosts ]; then \
printf 'mxc: refusing to rewrite /etc/hosts: it is a symbolic link\\n' >&2; \
exit 4; \
fi; \

Comment threadsrc/core/wxc_common/src/config_parser.rs
Three files conflicted, and all three were additive on both sides.
tests/scripts/run_lxc_all_tests.sh and docs/lxc-support/lxc-backend.md keep
both sides. The doc intro now states that both halves of network policy,
outbound and inbound, need enforcementMode of firewall or both -- main
described the two halves, this branch described the gate, and each is true
of the other's half.
lxc_runner.rs is the real one: main added the inbound ingress chain and this
branch added the /etc/hosts proxy pin, at the same point in the run. Both
are kept, ingress first, so inbound default-deny is installed before the pin
runs a command inside the container rather than after it.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 0bdca3d7-e52b-4354-83ca-84eec7395f18

CopilotAI 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 41 out of 42 changed files in this pull request and generated 1 comment.

Suppressed comments (2)

.github/workflows/lxc-e2e.yml:83

  • The workflow makes the new allow/proxy paths pass by globally changing FORWARD to ACCEPT, but the implementation documents that neither generated return rule matches replies on the default bridged topology (network_iptables.rs:689-696). On a host where Docker has set FORWARD to DROP, requests leave through the MXC chain and every reply is dropped, so explicitly allowed destinations and the proxy are unusable. Please add a return-path rule that actually matches routed replies (for example, scoped to the container IP) and run this E2E case with the host's DROP posture instead of masking it.
 - name: Let the host forward, so only MXC rules can block
run: |
sudo iptables -P FORWARD ACCEPT
sudo ip6tables -P FORWARD ACCEPT

docs/lxc-support/lxc-backend.md:219

  • The new implementation and E2E test now preserve the egress chains when preservePolicy is true, but the paragraph immediately below still says that preservePolicy does not keep egress chains alive. Update the lifecycle documentation so it matches the behavior introduced here.
If MXC cannot discover the container veth at all, firewall setup **fails** and
the partially created chains are rolled back. An unhooked chain is never
traversed, so reporting success would hand the caller a deny-all chain that
filters nothing — strictly worse than no firewall, because it looks enforced.
Installing the rules host-wide instead is not an option either: unscoped, they
would apply to every container and to the host's own traffic.

Comment threaddocs/schema.md
The parser refuses one for both LXC and Bubblewrap, but the parser only
sees requests it built. ExecutionRequest and ProxyAddress::from_url are
public, to_url returns the URL verbatim, and build_args emits it as a
bwrap --setenv argument -- so a programmatically built request put the
password in /proc/<pid>/cmdline for any local user to read.
LXC already carried this second guard at its runner boundary. This adds
the matching one to Bubblewrap, with the input checks rather than after
the bwrap probe, so a host with no bwrap installed is still told what is
wrong with the request rather than what is wrong with the host.
The two tests were written first and both failed with validate returning
Ok on a URL carrying alice:hunter2@.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 0bdca3d7-e52b-4354-83ca-84eec7395f18

CopilotAI 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 41 out of 42 changed files in this pull request and generated 2 comments.

Suppressed comments (3)

src/backends/lxc/common/src/lxc_runner.rs:253

  • preserve_policy is enabled before setup succeeds. If a later iptables command fails and rollback also fails, record_apply_outcome intentionally retains ownership so Drop retries cleanup, but this flag suppresses that retry and strands the residual chain/hooks. Mirror the ingress path below: set preservation only after the full firewall apply has succeeded.
 // Configure network rules
let mut fw_manager = NetworkIptablesManager::new(&container_name);
fw_manager.set_preserve_policy(!self.cleanup_policy);

src/core/wxc_common/tests/proxy_env_spec.rs:17

  • This newly added contract description is already stale: LXC now calls apply_proxy_env, the function returns (), and the runner always passes force_clear_env=true to attach_run. Describing the integration as planned and a boolean return value makes the test contract contradict the code it tests.
//! (a) LXC backend (PLANNED integration, not yet wired) -- will call
//! `apply_proxy_env` and use the returned bool to decide whether to pass
//! `--clear-env` to `lxc-attach`. Today `attach_run` derives `--clear-env`
//! solely from `env` being non-empty (`lxc_bindings.rs:90`). The empty-env
//! case is where the helper contract and current behavior diverge:
//! `apply_proxy_env` returns `true` even for an empty env so the host
//! environment cannot leak, whereas current code emits no `--clear-env`
//! then. Wiring this in must update `lxc_bindings.rs` and the test at
//! `lxc_bindings.rs:743` that pins the current empty-env rule. These tests
//! validate the helper contract, not existing LXC behavior.

docs/lxc-support/lxc-backend.md:219

  • The following paragraph still says preservePolicy does not retain egress chains, but this PR adds NetworkIptablesManager::set_preserve_policy and an E2E test that requires the chain and FORWARD hook to survive. Update that statement so the backend documentation matches the new lifecycle behavior.
If MXC cannot discover the container veth at all, firewall setup **fails** and
the partially created chains are rolled back. An unhooked chain is never
traversed, so reporting success would hand the caller a deny-all chain that
filters nothing — strictly worse than no firewall, because it looks enforced.
Installing the rules host-wide instead is not an option either: unscoped, they
would apply to every container and to the host's own traffic.

Comment threadsrc/backends/lxc/common/src/lxc_runner.rs
Comment threadsrc/core/wxc_common/src/config_parser.rs Outdated
The loopback guard this PR added covers two backends, and only one of them
should be covered. What makes a loopback proxy address wrong is which side
of the namespace the proxy runs on, not that the literal is 127.0.0.1.
LXC opens egress across the veth and pins the proxy's address into the
container's /etc/hosts, so both assume an address routable from inside the
container -- a loopback there is the container's own loopback, and the
configuration cannot work. WSLc's url form means the opposite: the proxy
runs inside the container, and loopback is the only address that both the
client and a proxy hosted alongside it can name. The already-shipped
tests/configs/wslc_network_proxy.json depends on exactly that, and
run_wslc_proxy_test.ps1 says why -- the proxy is an in-container marker
server. The host-run WSLc forms, localhost and builtinTestServer, are
rejected by the separate guard just above this one and are unaffected.
The regression survived review because the test written alongside it asserted
the wrong contract: proxy_loopback_url_rejected_with_wslc encoded the
rejection as intended behavior, so nothing was left to notice the working
configuration had been broken. That test is replaced by
proxy_loopback_url_accepted_with_wslc, which fails against the old guard with
the rejection message it produced.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 0bdca3d7-e52b-4354-83ca-84eec7395f18
Removing the pin only at the end of a run that wrote one leaves the container
carrying it in two ordinary cases: a run interrupted after pinning, and a run
whose removal failed, since that failure is only warned about. The next run
over the same container clears it only if that run also pins, because pinning
rewrites /etc/hosts from a filtered copy and disposes of the old line on its
way past. A run that configures no proxy has no such side effect, so it
executed its script against a hostname resolving to an address that only some
earlier policy authorized -- which is the case the doc comment on
build_hosts_unpin_command already described, and the call site did not
implement.
A container this run did not create now has the pin cleared before the script
runs. Doing it first is what makes it a guarantee rather than best effort:
the end-of-run removal cannot help a run that never reaches its end. A clear
that fails stops the run and returns an error, mirroring the pin-failure path
directly above, because a failure here still changes what the script would
resolve. Only reused containers pay the extra attach_run.
run_lxc_network_proxy_reuse_test.sh grows a third run covering it: a pin is
written into the surviving container's /etc/hosts by hand, standing in for the
interrupted run, and a config with no proxy then runs against that container.
The script inside reports whether it can see the pin, so the assertion is what
the workload would actually resolve rather than what the host file looks like
afterwards. Against the previous code it reports PIN_PRESENT_DURING_RUN.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 0bdca3d7-e52b-4354-83ca-84eec7395f18

CopilotAI 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 42 out of 43 changed files in this pull request and generated no new comments.

Suppressed comments (4)

src/backends/lxc/common/src/lxc_runner.rs:253

  • preserve_policy is enabled before installation succeeds. If setup fails and rollback leaves residual resources (the explicit retain_residual_ownership path), this early return drops the manager with preservation enabled, so Drop will not retry cleanup and the residual chain/hooks are stranded. Set preservation only in the successful Ok(true) arm, as the ingress manager already does below.
 fw_manager.set_preserve_policy(!self.cleanup_policy);

tests/scripts/run_lxc_network_proxy_hostname_test.sh:185

  • This changes the host-wide net.ipv4.ip_forward setting but cleanup never restores its original value. Running the E2E suite locally on a host where forwarding was disabled leaves routing enabled after success, failure, or skip. Capture the original value before changing it and restore it in the existing trap.
    src/core/wxc_common/tests/proxy_env_spec.rs:17
  • This newly added client description is already stale: LXC is now wired in this PR, apply_proxy_env returns (), and lxc_runner always passes force_clear_env=true. As written, the test module documents an API and integration state that do not exist.
//! (a) LXC backend (PLANNED integration, not yet wired) -- will call
//! `apply_proxy_env` and use the returned bool to decide whether to pass
//! `--clear-env` to `lxc-attach`. Today `attach_run` derives `--clear-env`
//! solely from `env` being non-empty (`lxc_bindings.rs:90`). The empty-env
//! case is where the helper contract and current behavior diverge:
//! `apply_proxy_env` returns `true` even for an empty env so the host
//! environment cannot leak, whereas current code emits no `--clear-env`
//! then. Wiring this in must update `lxc_bindings.rs` and the test at
//! `lxc_bindings.rs:743` that pins the current empty-env rule. These tests
//! validate the helper contract, not existing LXC behavior.

docs/lxc-support/lxc-backend.md:219

  • This section still says at line 219 that preservePolicy does not keep egress chains alive, but this PR now calls NetworkIptablesManager::set_preserve_policy and adds an E2E test requiring the chain and hook to survive. Update that stale lifecycle statement so operators know preserved host firewall state is intentional.
If MXC cannot discover the container veth at all, firewall setup **fails** and
the partially created chains are rolled back. An unhooked chain is never
traversed, so reporting success would hand the caller a deny-all chain that
filters nothing — strictly worse than no firewall, because it looks enforced.

@dhoehna

Darren Hoehna (dhoehna) commented Aug 14, 2026

Copy link
Copy Markdown
ContributorAuthor

Note for anyone re-reviewing: three threads on this PR defer a finding to #869, #870, or #875, and all three now show as closed. They were not dropped -- the backlog was consolidated from 41 issues into 8 area issues, and each original carries a comment naming its new home.

Deferred toNow tracked in
#869 -- FORWARD return path on a DROP-policy host#896
#870 -- INPUT path to the bridge gateway#897
#875 -- rewrite the /etc/hosts pin from Rust, open-once#896

The word carries a meaning it does not need to carry, and nothing outside
this branch used it -- all twenty occurrences arrived with this PR, so there
is no existing usage to stay consistent with.
Renames two tests and their sysfs fixture directories to match. The sysfs
link is still `master`, because that is the kernel's name for it and not
ours to change.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 0bdca3d7-e52b-4354-83ca-84eec7395f18

CopilotAI 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 42 out of 43 changed files in this pull request and generated no new comments.

Suppressed comments (7)

docs/lxc-support/lxc-backend.md:304

  • The implementation caps proxy DNS results at 16 and pins only the first result, so saying every resolved address is opened is incorrect for larger answers. Document the bound and selected pin to avoid promising connectivity the runner intentionally does not provide.
Every address the proxy host resolved to is opened, since they all belong to
that same proxy. If the hosts entry cannot be written, execution **fails**

src/backends/lxc/common/src/lxc_runner.rs:267

  • Preservation is enabled before apply_firewall_rules succeeds. If setup fails after creating state and rollback leaves a residual, record_apply_outcome retains ownership specifically so Drop retries cleanup, but this flag makes Drop skip that retry and strands the chain/hook. Enable preservation only in the successful arm, as the ingress manager already does.
 fw_manager.set_preserve_policy(!self.cleanup_policy);

tests/scripts/run_lxc_network_proxy_hostname_test.sh:185

  • This changes the host-wide IPv4 forwarding setting but cleanup never restores its original value. Running the test locally can therefore leave routing enabled after either success or failure; capture the prior sysctl value and restore it from the EXIT trap, as the bridge-netfilter test does for its global setting.
    docs/lxc-support/lxc-backend.md:219
  • This documentation now contradicts the runner and the new preserve-policy E2E test: set_preserve_policy deliberately keeps successfully installed egress chains when preservePolicy is true. Document the new lifecycle behavior rather than saying there is no opt-out.

This issue also appears on line 303 of the same file.

Egress firewall state is torn down automatically with best-effort removal of the `FORWARD` hooks and both per-container chains; there is no egress network-policy opt-out field, and `preservePolicy` does not currently keep the egress chains alive. Setup failures after partial creation are rolled back before returning an error, so retries do not trip over leftover chains.

src/core/wxc_common/tests/proxy_env_spec.rs:17

  • This newly committed test description is stale: this PR wires LXC to apply_proxy_env, the helper returns (), and lxc-attach now receives explicit clear_env=true. Keeping the old “planned” contract makes the test suite describe behavior that no longer exists.
//! (a) LXC backend (PLANNED integration, not yet wired) -- will call
//! `apply_proxy_env` and use the returned bool to decide whether to pass
//! `--clear-env` to `lxc-attach`. Today `attach_run` derives `--clear-env`
//! solely from `env` being non-empty (`lxc_bindings.rs:90`). The empty-env
//! case is where the helper contract and current behavior diverge:

src/core/wxc_common/tests/proxy_address_spec.rs:37

  • The pin surface does have a caller in this PR: LxcScriptRunner obtains host_pin() and writes/removes its hosts_line(). Update this test contract so future maintainers do not treat the live LXC integration as merely planned.
//! * The pin surface (`host_pin`, `hosts_line`, `ProxyHostPin`) still has no
//! callers. It is planned wiring for the firewall / hosts-file consumer, so
//! the tests below name that consumer as planned, not present. `ProxyHostPin`
//! has no public constructor -- the only way to obtain one is `host_pin` on a
//! hostname -- so the tests build pins that way through the `pin_for` helper.

src/backends/lxc/common/tests/chain_name_script_drift_spec.rs:53

  • run_lxc_network_preserve_policy_test.sh also asserts on the derived chain name, but it is omitted from this supposedly exhaustive list. As a result, the drift test will not fail if that new script later stops deriving the name; include it alongside the other chain-asserting scripts.
 "run_lxc_network_invalid_cidr_test.sh",
"run_lxc_network_ipv6_cidr_test.sh",
];

@MGudginGudge (MGudgin) left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Verified the updated implementation and mapped the prior findings against the current head. The final post-verification commit is terminology-only; targeted Rust tests and all current CI checks, including LXC E2E, pass. Remaining limitations are documented or tracked in follow-up issues.

Sign up for freeto 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.

4 participants

@dhoehna@MGudgin@SohamDas2021