Skip to content

Linux egress fence via network namespace — the address-fence Landlock cannot provide (per-host net for confined subprocesses) #276

Description

@hartsock

Summary

Give Linux a real per-host egress fence — a network namespace + veth +
parent-run proxy — so the egress-proxy pattern (ADR 0016) is actual confinement
on Linux, not the honest-but-advisory posture it is today. This is the missing
backend behind Leg 4 (per-host net confinement for confined subprocesses:
shell + stdio MCP).

Context — why this is needed now. Landlock's network rules are port-based,
not address-based
(ADR 0014/0015): apply() can deny all TCP egress
(net: none) but cannot admit only loopback for a net: Only{host…} grant.
So the loopback-fence-plus-proxy design (ADR 0016) has no Linux backend
only macOS Seatbelt and Windows AppContainer can address-fence to loopback.

The companion fix (PR fix/egress-proxy-net-axis-gate — the #257 fail-open)
makes this honest: egress_proxy_plan now engages the proxy only where the
loopback-net fence is enforceable, so on Linux a remote-host net grant is
advisory (not a walk-around-able proxy that falsely reports egress_proxied()).
This issue supplies the enforcing Linux backend that flips it back to
confined.


Approach — netns + veth + proxy in the parent

Rootless, no external binary dependency for the core path:

  1. Spawn the child in a fresh user + net namespace (unshare(CLONE_NEWUSER | CLONE_NEWNET), uid/gid mapped) — so no root is required and the child gets an
    empty network stack.
  2. Parent wires the netns (it holds CAP_NET_ADMIN inside the child's new
    userns
    ): bring lo up; create a veth pair; move one end into the child
    netns; assign a point-to-point subnet (e.g. 10.x.x.1/30 parent /
    10.x.x.2/30 child, or an fd00::/link-local v6 pair); install NO default
    route
    — the child has no path off-box.
  3. The loopback egress proxy runs in the PARENT netns, bound to the
    parent-side veth address. The child's *_PROXY env points at that address.
  4. Result: the child can reach only its own loopback and the proxy address;
    every off-box connection MUST traverse the proxy, which enforces the host
    allow-list (+ the existing SSRF-pivot guard, net_proxy / bridle-netmon hardening (post-hoc review): netmon UTF-8 crash, SSRF pivot, CONNECT byte-drop, audit coverage #138). A rogue child cannot
    walk around it
    — there is no route off the veth. This is the address-fence
    Seatbelt provides, now on Linux.
  5. Compose with Landlock: Landlock keeps confining fs/exec inside the netns
    child; netns confines net. Landlock (fs/exec) ∘ netns (net).

Implementation surface

  • SandboxKind: a net-fenced Linux variant (e.g. LandlockNetns) so
    effective_sandbox_kind, egress_proxy_plan_for, and loopback_net_enforceable
    (the seam the Expose the loopback egress proxy for external + long-lived confined callers (enables newt Leg 4) #257 fix introduced) recognize it → loopback_net_enforceable(LandlockNetns) == true.
  • NetnsEgressFence: creates the userns+netns child, sets up veth/addr via
    netlink (rtnetlink/neli, not shelling out to ip), points the proxy,
    applies alongside Landlock. Wired into ConfinedCommand::spawn/spawn_tokio
    under a general remote-host grant on a netns-capable host.
  • Feature linux-netns (inert off Linux) + capability probe
    netns_egress_is_supported() — kernel supports unprivileged userns + veth
    creation. Hardened hosts (sysctl kernel.unprivileged_userns_clone=0, AppArmor
    userns restrictions) → honest advisory fallback (the Expose the loopback egress proxy for external + long-lived confined callers (enables newt Leg 4) #257 gate already handles
    this: no capability ⇒ no proxy ⇒ advisory).

Acceptance criteria (with the reality-check bar)

  1. Real-kernel integration test (Linux 6.x, #[ignore] integration tier): a
    spawned curl (exec-scoped, no shell re-exec) under net: Only{api.example.com}
    reaches an allow-listed host through the proxy; a direct dial to a
    non-allow-listed host (child ignoring *_PROXY) fails at the kernel (no
    route off the veth); an off-list CONNECT through the proxy is 403'd and
    recorded in refused_hosts(). Proves non-bypassability, not just the proxy.
  2. loopback_net_enforceable(LandlockNetns) == true;
    egress_proxy_plan_for(LandlockNetns, remote_net_grant).is_some().
  3. Rootless: works without root / CAP_SYS_ADMIN via the userns path; honest
    advisory fallback where unprivileged userns is disabled.
  4. Compose: Landlock fs/exec confinement stays in force in the netns child
    (an out-of-scope write is still EACCES; an un-granted exec still refused).
  5. No heavy new runtime deps beyond a netlink crate; no hard dependency on an
    external ip/slirp4netns binary for the core path (slirp/pasta may be an
    optional connectivity backend for environments without veth).
  6. Weekly combinatorial CI covers linux-netns on/off (the
    --feature-powerset sweep, per the standing "exhaustive weekly jobs" rule).

Alternatives considered

  • nftables per-netns — still needs a netns to scope rules; the veth+proxy
    design already gives per-host at the proxy, so nftables adds little.
  • slirp4netns / pasta — userspace TCP/IP stack (what rootless podman uses);
    robust but an external binary dependency. Keep as an optional connectivity
    backend, not the core.
  • seccomp connect() filtering — cannot express hostnames, brittle against
    IP-literal dials; rejected.

Netns + veth + a parent-run proxy is the standard, robust, rootless-capable
choice — the same shape rootless containers use, aimed here at a single confined
subprocess.

Refs

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions