Skip to content

feat!: Network Filtering - #3

Merged
tomchuk merged 12 commits into
mainfrom
network-filter
Jun 1, 2026
Merged

feat!: Network Filtering#3
tomchuk merged 12 commits into
mainfrom
network-filter

Conversation

@tomchuk

@tomchuk tomchuk commented Jun 1, 2026

Copy link
Copy Markdown
Owner

No description provided.

tomchuk added 12 commits May 31, 2026 15:16
Scaffolds the egress-tunnel work: an MCP server can now run on a
dedicated host-only (`--internal`) container network that npxc creates
before the run and deletes on teardown. No tunnel yet, so the container
has no internet — this validates the create/inspect/delete lifecycle.

- Add `[network]` package config table (`mode`, `allow`); resolve it to a
  new `NetworkPolicy` enum (None | Named | Allowlist) on EffectiveConfig,
  authoritative over the legacy `[runtime] network` string
- Add `runtime::network::ManagedNetwork`: provisions a uniquely-named
  `--internal` network, reads its subnet/gateway from `network inspect`
  JSON, and deletes it (async, after container stop; sync best-effort on Drop)
- Resolve the policy in `run_package` via `ManagedNetwork::provision`,
  hand the network to the session with `Session::attach_network`, and pass
  the resolved `--network` value into `Session::start`
- Print the network policy in `inspect` and `--dry-run`
- Document Phase 0 design decisions and the no-back-compat directive in TUNNEL.md

Tests: `network inspect` JSON parsing (valid/empty/malformed), `provision`
for none/named, and merge resolution for allowlist/open/precedence (82 unit
tests total).
First increment of the userspace egress tunnel. Locks the dependency
decision and lands the foundational WireGuard key material; transport,
datapath, and image/session wiring follow in later increments.

- Add `tunnel` module with `tunnel::keys::WgKeypair`: Curve25519 keypair
  generation from the OS CSPRNG, using boringtun's re-exported x25519 types
  so keys are compatible with `Tunn::new`; exposes secret/public handles plus
  base64 encodings for the guest wg config
- Add deps: boringtun 0.7 (no default features — library use only),
  ipstack 1.0 (async tun2socks-style netstack, chosen over smoltcp),
  base64 0.22, getrandom 0.4
- Document Phase 1 decisions in TUNNEL.md (boringtun+ipstack datapath,
  key re-export, getrandom, both-keypairs-per-session)

Tests: 5 new (87 lib total) covering keygen determinism, distinctness,
and base64 round-trip.
Add `tunnel::wg::WgTunnel`, a wrapper over boringtun's `Tunn` that turns its
borrowed, scratch-buffer API into owned `Outbound::{ToPeer, ToHost}` actions
and hides two boringtun details: the per-call buffer borrow (copied out via a
single reusable 64 KiB scratch) and the post-handshake queue flush (looped
internally in `handle_datagram`).

- `new` (infallible — `Tunn::new` returns a `Tunn` in 0.7.1), `handle_datagram`
  (decrypt + route + flush), `encapsulate_packet` (encrypt host->peer),
  `tick` (drive timers for handshake retries/keepalives)
- Document the transport decisions in TUNNEL.md

Tests: 2 new (89 lib total). An in-memory test wires two WgTunnels together,
drives a real handshake (init -> response -> completion + flush), and asserts
an IPv4 packet round-trips decrypted intact in both directions — validating the
boringtun integration without a container. A second test confirms pre-handshake
garbage yields no host packets.
Per the new networking principle (zero-copy/zero-alloc in the steady-state
datapath), rework `WgTunnel` from the owned-`Vec` `Outbound` design to a
borrow-based callback API: `decapsulate`/`encapsulate`/`tick` hand boringtun's
borrowed scratch slice straight to `to_peer`/`to_host` callbacks, so the hot
path allocates nothing per packet (one 64 KiB scratch buffer per tunnel,
allocated once). `decapsulate_once` returns a bool so the result's borrow never
spans the post-handshake flush loop.

- Document the zero-copy/zero-alloc principle in TUNNEL.md and revise the
  transport decision entry
- Adapt the in-memory handshake/bidirectional test (still allocation-free);
  89 lib tests pass, clippy clean
Wire the WireGuard transport to a real network. `WgDevice` is a zero-alloc
AsyncRead+AsyncWrite packet device: it owns the UDP socket and WgTunnel and
does decapsulate/encapsulate inline against a reused buffer in poll_read/
poll_write (no channels, no Arc<Mutex>, no per-packet allocation). The endpoint
drives ipstack over the device and forwards each guest flow to a real socket —
TCP via copy_bidirectional, UDP via a datagram relay; the forward destination
is the stream's peer_addr (confirmed = dst_addr from ipstack source).

- Add tokio `net` feature, ipstack 1.0 dependency, etherparse (dev)
- Document the datapath decisions in TUNNEL.md

Tests: 90 lib total (+1). A loopback integration test stands up the real
WgDevice + ipstack over two localhost UDP sockets, drives an actual WireGuard
handshake from a bare guest tunnel, sends an etherparse-built IPv4/UDP packet,
and asserts ipstack surfaces a stream addressed to the packet's destination —
validating the whole receive datapath without a container.
config_dir() used directories::ProjectDirs, which returns
~/Library/Application Support/npxc on macOS — contradicting the README and
silently ignoring per-package configs placed in ~/.config/npxc. Switch both
config and data dirs to XDG resolution (absolute $XDG_*_HOME, else ~/.config or
~/.local/share, then /npxc), sharing one pure resolve_xdg_dir helper. G4
persistent storage moves to ~/.local/share/npxc.

Tests: absolute XDG honored, relative ignored, config/data fallbacks.
Wire the egress tunnel into the image and session so an allowlist-mode
container reaches the internet exclusively through npxc, brought up end to
end against a live container.

Integration:
- Dockerfile: a rust:1-bookworm stage builds userspace WireGuard
  (boringtun-cli) — the container VM kernel has no WireGuard module — copied
  into the image alongside wireguard-tools + iproute2. The entrypoint runs as
  root, starts wg0 over TUN via boringtun-cli, configures it (wg set / ip addr
  / ip route replace), then setpriv-drops to the node user before exec'ing.
- LaunchPlan.cap_add -> --cap-add; tunnel mode adds NET_ADMIN (configure wg0)
  plus SETUID/SETGID (privilege drop). Session adds --tmpfs /run for boringtun's
  control socket.
- tunnel::endpoint::establish: generate keypairs, bind the UDP socket, spawn
  the datapath, write a per-session resolv.conf, and return the Tunnel +
  NPXC_WG_* env + resolv.conf.
- run_package: for allowlist mode, establish the tunnel on the network gateway,
  inject env, mount npxc's resolv.conf at /etc/resolv.conf:ro so DNS routes
  through the tunnel regardless of the host's DNS config, add the caps, and hold
  the TunnelSetup for the session.

Live-bringup fixes (each from a real run):
- userspace boringtun instead of `ip link add type wireguard` (no kernel module)
- `ip route replace default` not `add` (the network installs one already)
- SETUID/SETGID so the root entrypoint can drop to node under --cap-drop ALL
- mount resolv.conf rather than write it (/etc is read-only); points at 1.1.1.1
  through the tunnel since apple's gateway resolver is unreachable in host-only

Validated: WireGuard handshake completes; TCP and DNS egress flow through the
tunnel against a live container.
Add a default-deny egress policy to the userspace WireGuard datapath. Each
guest flow is matched against the package's `allow` list before npxc dials
its real destination; unmatched flows are reset.

- tunnel::policy — default-deny Policy matching by destination IP/CIDR
  (via `ipnet`) and by hostname (TLS SNI / HTTP Host), each with an optional
  port. A bare IP becomes a host route; IPv6 with a port uses brackets. DNS
  to npxc's pinned resolver is implicitly allowed so hostname rules can
  resolve. Exhaustively unit-tested.
- tunnel::peek — connection-time hostname extraction: TLS SNI from the
  ClientHello (via `tls-parser`) on 443, HTTP Host on 80. Bounded by an
  8 KiB / 5 s budget so server-speaks-first protocols can't stall; peeked
  bytes are captured and replayed upstream so filtering is byte-for-byte
  transparent. Falls back to IP/port matching on timeout.
- endpoint — thread an Arc<Policy> through establish()/Tunnel::spawn() and
  every forwarder; enforce per flow (deny -> drop = reset), replay the
  peeked prefix on allow. UDP is policed by IP/port alone.
- config — validate `allow` entries at config-resolution time so a typo
  fails before any image build or container launch.

Add a gated e2e (tests/e2e_tunnel.rs, behind --features e2e) that drives the
real host datapath against a real container on a host-only network and
asserts: an allowed IP/SNI reaches the internet, a denied IP/SNI is reset,
and tearing down wg0 yields ENETUNREACH (the unbypassable floor). Confirmed
green on container 0.12.3.

Deps: add ipnet and tls-parser (vetted crates over hand-rolled CIDR/TLS
parsing)
Complete the egress story: filter what the guest can resolve, carry v6,
close the QUIC hole, and make every decision auditable. Validated live
against a real container (gated e2e, container 0.12.3).

- tunnel::dns — in-tunnel DNS pinning. Queries to the pinned resolver are
  answered by npxc: a name covered by a domain rule is forwarded upstream
  and its response relayed opaque (all record types); any other name gets a
  synthesized NXDOMAIN. Closes the "resolve a denied name, connect by bare
  IP" gap. Defense-in-depth, scoped to UDP/53 (TCP/53 still relays, backed
  by connect-time filtering). Uses hickory-proto 0.25 (parse/build only,
  default-features off, pinned under npxc's 1.87 MSRV).
- Policy — add `dns_resolver` and `allows_name` (domain-rule, port-agnostic).
- QUIC — block UDP/443 outright (SNI lives in the encrypted Initial); clients
  fall back to TLS-over-TCP, which we SNI-filter.
- observability — a single `audit()` helper emits one structured event per
  flow under the `npxc::egress` target (allow=info, deny=warn) with proto,
  destination, and peeked SNI/Host; DNS decisions log the queried name.
- IPv6 — the guest routes `::/0` through wg0 (ULA wg0 address + `::/0` in the
  peer's allowed-ips); the datapath was already v6-ready (ipstack v6,
  boringtun WriteToTunnelV6, SocketAddr forwarders), so v6 flows are
  decrypted, policy-checked, and forwarded with no special-casing.

Tests: unit coverage for DNS decide/NXDOMAIN, allows_name, and a v6 packet
through WgDevice+ipstack. The gated e2e (tests/e2e_tunnel.rs) gains
DNS-pinning and host-v6-gated IPv6 scenarios; all five pass live (allowed
IP/SNI through, denied reset, allowlisted name resolves / others NXDOMAIN,
wg0-teardown ENETUNREACH, IPv6 TLS handshake).

Note: alternative resolvers (DoH/DoT/DoQ) can't bypass egress control — they
meet the same SNI/IP filter, and even an allowlisted DoH endpoint only lets
the guest resolve, not reach, non-allowlisted names.
- Teardown now force-removes the container by name (`container rm --force`)
  and retries the per-session network delete. SIGKILLing the `container run`
  client alone left the real container running, so the network stayed in use
  and Ctrl-C failed to clean it up.
- Compile boringtun-cli once into a persisted `npxc/wg-base:<ver>` image that
  package builds `COPY --from`, instead of recompiling every build. The
  builder stays ephemeral (deleted after each build); the base lives in the
  image store. Hidden from `npxc list` and preserved across `clean --all`.
- README: document network egress (modes, allowlist rules, DNS pinning, QUIC
  block, IPv6, capabilities, observability); convert prose to ASCII-only.
Port dns.rs to the 0.26 Message API (public `metadata`/`queries` fields,
`Message::error_msg` for the NXDOMAIN builder). Collapse the nested ifs the
1.88 toolchain now flags (let-chains) across dns, endpoint, peek, config,
and the mcp_probe example.
@tomchuk
tomchuk merged commit 18468e6 into main Jun 1, 2026
4 checks passed
@tomchuk
tomchuk deleted the network-filter branch June 1, 2026 19:25
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant