feat!: Network Filtering - #3
Merged
Merged
Conversation
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
No description provided.