Skip to content

Add a native TUN gateway for transparent TCP over Rings onion circuits #684

Description

@rings-auto-reviewer

Summary

Add an OS-level transparent IPv4/TCP gateway to native Rings nodes. Selected host TCP flows are captured by a virtual network interface, reconstructed by one shared userspace TCP data plane, mapped to the existing Rings Onion TCP stream abstraction, and connected to the public target by an advertised TCP exit.

This issue is broader than #665. That issue covers a controlled WebView/HTTP gateway; this issue owns OS-level packet capture, transparent TCP routing, route ownership, underlay exclusion, cleanup, and gateway health.

Current product decisions

These decisions supersede the earlier daemon-service proposal and closed PR #685:

  • rings run remains the foreground, composable process primitive.
  • Rings does not wrap or generate systemd, launchd, Windows Service Control Manager, or other service-manager configuration.
  • No rings daemon ... lifecycle commands are added by this issue. Users may choose any external supervisor.
  • All gateway core code, platform bindings, Unix configuration, and cleanup leases live in one crates/gateway crate.
  • Linux, macOS, and Windows native targets provide client + server + gateway capability.
  • WASM remains client-only and must not compile, link, or export gateway/server code.
  • iOS NetworkExtension and Android VpnService integration are out of scope for this iteration.
  • Swift and Kotlin/JNA desktop FFI examples are in scope and align with the existing Python C FFI example. They do not imply iOS/Android VPN support.

Existing foundation

  • The native HTTP CONNECT ingress already resolves one Onion route per target and calls NativeOnionCircuitHandle::open_tcp_stream.
  • The Onion TCP runtime already implements target opening, ordered bidirectional byte transfer, half-close handling, exit policy, and resource accounting.
  • Public target admission already rejects private, loopback, link-local, and other ineligible targets.
  • Native configuration already exposes Onion HTTP proxy, route-hop, short-path, and connection-limit settings.
  • StopSource / StopToken provide cooperative native shutdown.
  • The C ABI and Python example already cover provider creation, listening, JSON-RPC requests, signer callbacks, returned-string ownership, and destruction.

Architecture

One native gateway crate

The implementation is intentionally one shared data plane with small platform effect boundaries. The current source layout is:

crates/gateway/
├── Cargo.toml
├── README.md
├── BENCHMARKS.md
├── bin/gateway-config-unix.rs
├── examples/gateway-bench.rs
└── src/
    ├── config.rs                 GatewayConfig / GatewayPlan
    ├── error.rs
    ├── packet.rs                 PacketIo
    ├── flow.rs                   per-flow state model
    ├── flow_table.rs             bounded flow ownership
    ├── tcp/{mod.rs,device.rs}    shared smoltcp IPv4/TCP data plane
    ├── bridge.rs                 bounded bidirectional byte bridge
    ├── stream.rs                 OnionStreamConnector boundary
    ├── runtime.rs                packet/flow/stream orchestration
    ├── server.rs                 gateway lifecycle
    ├── status.rs                 process-independent health
    └── bindings/
        ├── mod.rs                TunnelControl / UnderlayPolicy
        ├── native.rs             shared native device + cleanup lease
        ├── route.rs
        ├── routes.rs             platform-neutral route plan
        ├── unix/
        │   ├── client.rs
        │   ├── config.rs
        │   ├── helper.rs
        │   ├── lease.rs
        │   ├── linux.rs
        │   ├── macos.rs
        │   └── transport.rs      peer credentials + SCM_RIGHTS
        └── windows/{route.rs}

Platform code is an effect boundary. The shared server, flow, TCP, admission, backpressure, and Onion mapping logic contains no Linux/macOS/Windows system API calls.

Core contracts

  • PacketIo: receive and inject complete IP packets. It is packet-semantic rather than fd-specific.
  • GatewayPlan: declarative addresses, MTU, include/exclude routes, routing mode, and explicit DNS policy.
  • TunnelControl: reconcile stale state, install exclusions, establish the packet device and capture routes, then consume a linear cleanup lease on teardown.
  • UnderlayPolicy: keep Rings overlay, bootstrap, ICE, and control traffic outside capture routes before packet admission.
  • OnionStreamConnector: bind one admitted immutable target to one Onion TCP stream without exposing Rings internals to the gateway crate.

The Onion byte pump accepts a generic AsyncRead + AsyncWrite stream rather than only tokio::net::TcpStream, so the userspace TCP stack reuses the existing Onion transfer, half-close, timeout, and accounting logic.

Underlay ordering for signaling and ICE

Native connectPeerViaHttp bootstrap uses the same underlay policy as ICE instead of a second routing mechanism. Before the first JSON-RPC request, Rings parses and resolves the HTTP endpoint, admits every IPv4 result, and pins the request client to exactly those addresses. Redirects are rejected because their destination has not crossed the admission boundary. Named endpoints require explicit DNS bypass; literal IPv4 endpoints remain usable with DNS block.

The transport handshake, not a periodic poll, owns first-use candidate admission:

  1. Parse every literal remote ICE candidate IP from the offer or answer SDP before pair nomination.
  2. Await the installed UnderlayCandidateAdmission policy.
  3. Apply set_remote_description only after all required host-route exclusions succeed.
  4. Publish the candidate projection only after the remote description succeeds.

Policy registration takes an asynchronous write lock while each remote-description application holds a read lock. Therefore, once gateway registration returns, all older handshakes have either published their candidates or failed, and every newer handshake observes the gateway policy.

The gateway serializes route mutations through one gate and keeps every admitted signaling or ICE target monotonic for the lifetime of one tunnel lease. A lagging topology snapshot may add candidates but cannot delete a candidate between route admission and ICE nomination. The gate is cleared only after successful tunnel teardown; if cleanup fails, the fail-safe policy remains installed with the failed lease.

mDNS hostnames are not claimed to be literal candidate-IP evidence. The IPv4 milestone relies on numeric server-reflexive/relay candidates plus fixed ICE-server and explicitly configured bypass targets; named/private host-candidate behavior must not be described as leak-free full-VPN support.

Gateway lifecycle

Stopped -> Starting -> Active -> Stopping -> Stopped
                       |   ^
                       v   |
                    Degraded
                       |
                       v
                     Failed -> Stopping -> Stopped

Packet admission is allowed only in Active, after underlay exclusions and capture resources are installed. Gateway health is observable independently from process health.

Flow lifecycle

Captured -> TargetBound -> RouteBuilding -> Opening -> Established
                                                        |
                                                        v
                                                   HalfClosed
                                                        |
                                                        v
                                                Closed | Failed

The original destination is part of the flow identity. It cannot change after capture or target admission. Terminal flows release their bounded-flow capacity exactly once.

Safety invariants

  1. A captured flow never opens a direct public connection from the client node.
  2. If no valid route or compatible exit exists, the flow fails closed with no silent direct fallback.
  3. A flow target is immutable for the lifetime of its Onion stream.
  4. Underlay exclusions are established before captured packets are admitted; an HTTP signaling endpoint is admitted before its first request, and a newly received literal ICE IP is admitted before WebRTC applies its remote SDP.
  5. Gateway failure remains observable independently from the rings run process.
  6. Stop, restart by an external supervisor, startup failure, and stale-state reconciliation are idempotent and leave routes/DNS recoverable.
  7. UDP, DNS, IPv4, and IPv6 behavior is explicit; an IPv4/TCP milestone is never described as a leak-free full VPN.
  8. Existing exit target policy remains authoritative at both route admission and exit connection.
  9. If runtime processing and shutdown both fail, diagnostics retain both failures; shutdown still attempts every flow, packet flush, and final lifecycle transition.

Platform bindings

Unix privilege boundary

  • gateway-config-unix is a separately launched foreground resource helper; it never invokes privilege elevation or a service manager.
  • The helper canonicalizes the socket and durable-ledger parents before mutation. Each direct parent must belong to the helper effective UID; every ancestor must belong to that UID or root and must reject group/other writes.
  • The socket is mode 0600 and may be chowned to one node UID. Kernel peer credentials must match that UID before any request is processed.
  • One authenticated connection owns establishment, descriptor transfer, bypass reconciliation, and teardown. Disconnect or malformed control traffic triggers cleanup.

Linux

  • Use /dev/net/tun for the IPv4/TCP gateway.
  • Keep CAP_NET_ADMIN out of the network-facing Rings process.
  • Use the narrow foreground helper and an SCM_RIGHTS packet fd for interface/routes/lease operations only.
  • Install overlay/bootstrap/ICE/control bypass routes before capture routes.
  • Reconcile stale route-ledger state on startup.
  • Do not install, generate, or manage systemd services.

macOS

  • Use the built-in utun packet interface for the CLI/native gateway.
  • Keep route configuration and cleanup in crates/gateway/src/bindings/unix.
  • Preserve foreground rings run semantics.
  • Do not install or manage launchd agents.
  • Signed NetworkExtension/App Store packaging is not part of this iteration.

Windows

  • Use Wintun for a packet interface with the same PacketIo semantics.
  • Keep route/interface mutation and cleanup behind a recoverable Windows lease.
  • Discover the DLL from an explicit config/env path, the executable directory, or the normal loader path and report failure explicitly.
  • Prove behavior with a Windows runtime smoke test, not cross-compilation alone.

Implementation roadmap and status

Status snapshot: 2026-08-29, isolated sibling worktree on branch codex/tun-gateway-684, rebased onto origin/master at d6775941 after PR #696 moved the workspace to v0.19 and removed Subring/SNARK. The implementation is published for review in PR #697 at current head 72a2ef0e; checked items mean implemented and validated, not merged. The rebase retained the upstream removals and reapplied only the gateway changes.

  • Freeze requirements and acceptance evidence
    • Gateway-first scope, foreground process model, desktop targets, WASM boundary, mobile non-goals, and FFI examples are recorded here.
  • Create rings-gateway and its model
    • Typed config/errors, packet/tunnel/underlay traits, lifecycle models, bounded flow ownership, status, and deterministic tests.
  • Generalize Onion TCP duplex IO
    • Generic async duplex pump with preserved half-close, timeout, ordering, and accounting behavior.
  • Implement the shared IPv4/TCP data plane
    • One smoltcp runtime covers SYN/data/FIN/RST, retransmission, out-of-order data, timeout, MTU, backpressure, and bounded concurrency.
  • Implement declarative tunnel state and cleanup leases
    • OpenVPN-style def1 capture, more-specific exclusions, write-ahead durable ledger, reverse cleanup, stale reconciliation.
  • Make DNS policy deterministic without system resolver APIs
    • Both policies require an explicit IPv4 resolver list. block installs more-specific capture routes and drops captured UDP plus TCP/53; bypass installs baseline-gateway host routes. Exact capture/bypass conflicts fail validation, and omitted resolvers are explicitly outside the guarantee.
  • Implement Linux TUN integration and helper protocol
    • Current-head Linux CI passes the privileged TUN/route smoke, real helper-process/SCM_RIGHTS boundary, captured TCP over a two-hop Onion public exit, and kernel TCP through TUN plus the two-hop Onion exit.
  • Implement macOS utun integration and helper protocol
    • Current-head macOS CI passes the privileged utun smoke and the real helper-process/SCM_RIGHTS normal-teardown and disconnect-cleanup test.
  • Implement Windows Wintun integration
    • Current-head Windows CI downloads and verifies the signed Wintun runtime, then passes the privileged Wintun capture, bypass, cleanup, and stale-ledger reconciliation test.
  • Integrate with rings-node
    • Optional foreground rings run task, cooperative stop, typed config, status endpoint, Onion connector, pre-request HTTP signaling admission/pinning, and synchronous ICE admission gate.
  • Enforce WASM client-only output
    • Target cfg, dependency-tree rejection, strict wasm32 clippy/build jobs, and no gateway/server export.
  • Add Swift and Kotlin desktop FFI examples
    • Swift dynamic C ABI wrapper and Kotlin/JNA wrapper align with Python ownership, callbacks, provider lifecycle, and two-provider flow. Deterministic fake-ABI tests cover wrapper semantics; both loaders also resolve and call the built Rust dynamic library without duplicating wallet cryptography.
  • Close native runtime evidence and remote CI
    • Current-head native jobs pass on Linux TUN, macOS utun, and Windows Wintun, including normal cleanup and stale/disconnect recovery.
    • Linux also passes captured TCP and kernel TCP through a two-hop Rings Onion public exit.
    • Python, Swift, and Kotlin/JNA FFI examples pass against the real Rust library; WASM remains client-only.
    • Terminal evidence: QACI run 33193595235 and CodeQL run 33193591164.

Validation evidence currently available

Local non-privileged evidence from the worktree:

  • rings-gateway: 61 library tests passed, 1 privileged controller test ignored by the ordinary suite; gateway-config-unix: 1 test passed. A regression forces runtime and shutdown packet-I/O failures together and verifies both errors are preserved while local flow/TCP ownership and lifecycle still reach inactive. The ignored Unix helper process integration was then executed explicitly with privileges and passed.
  • rings-transport native WebRTC: 98 tests passed, including a real offer test proving policy rejection precedes remote-description application.
  • rings-core: 489 tests passed, 1 pre-existing performance probe ignored. The lower count is expected after upstream PR Remove unused Subring and Nova SNARK features for v0.19.0 #696 removed Subring.
  • rings-node: 292 tests passed, 1 public three-node/TUN integration ignored by the ordinary suite; 2 CLI tests passed. One preceding full run observed a non-deterministic existing E2E frame-sequence failure; the exact test and the complete suite both passed on rerun. The ignored Linux integration was executed explicitly with privileges and passed.
  • Strict native Clippy passed for gateway/transport/core/node; dummy transport compilation and Clippy passed.
  • wasm32 strict Clippy passed for core and node browser builds after the signaling change; CI rejects rings-gateway, route_manager, or tun-rs in the browser dependency graph.
  • Linux gateway strict Clippy and Linux node test-target cargo zigbuild passed.
  • Windows gateway all-target strict Clippy passed on the installed MSVC target; iOS and Android cross-checks preserve the explicit unsupported-gateway boundary. A full Windows node cross-check from macOS is not runtime evidence and is locally limited by the absence of Windows SDK C headers; the Windows runner job is authoritative.
  • Python FFI: 15 tests passed against the real Rust library, including provider/offer/E2E integration. Swift passed deterministic callback/lifetime tests plus real Rust dylib load, all-symbol resolution, and an exported call. Kotlin/JNA passed the same two layers with task rerun forced and an architecture-matched Rust dylib/JVM.
  • Nightly rustfmt, Taplo, Cargo deny, and git diff --check passed for the rebased snapshot. actionlint passes the modified qaci.yml; the full-repository invocation remains red only on pre-existing shellcheck findings in unchanged auto-release.yml.
  • The dated in-memory data-plane benchmark is recorded in crates/gateway/BENCHMARKS.md with explicit omissions; it does not measure TUN, WebRTC, Onion cryptography, or public throughput.

The privileged platform smoke captures 1.1.1.0/24, installs a baseline-gateway /32 bypass for 1.1.1.1, proves actual TCP/HTTP reachability to the bypass target, confirms another address in the captured prefix arrives on TUN/utun/Wintun, then verifies normal teardown and stale-ledger reconciliation. It passed on privileged aarch64 Linux in an isolated OrbStack environment. That run exposed and fixed Linux ENXIO cleanup after the kernel auto-removes interface-bound routes. The stronger Linux kernel-TCP test also passed through TUN, the shared smoltcp data plane, a two-hop Rings Onion route, and the public TCP exit; it exposed and fixed explicit dropping of native IPv6 control frames in the IPv4-only milestone. A later DNS audit closed a split-route leak: both policies now require explicit IPv4 resolvers, block installs capture /32 routes, bypass installs baseline-gateway /32 routes, and exact capture/bypass conflicts fail validation. The updated privileged Linux smoke and kernel-TCP chain both passed again. A separate privileged process test then launched the real gateway-config-unix binary and passed kernel peer authentication, SCM_RIGHTS TUN transfer, live bypass replacement, explicit teardown, and client-disconnect cleanup. Native HTTP seed/signaling bootstrap now resolves and admits its target before the first request, pins DNS to the admitted IPv4 set, and rejects redirects to unadmitted destinations; focused resolver, admission-policy, pinned-request, and redirect tests pass. After the shutdown-error aggregation change, the ignored in-memory captured-TCP/two-hop-Onion/public-exit test was rerun and passed. Current-head remote CI now passes the Linux TUN/helper/kernel-TCP chain, macOS utun/helper chain, Windows Wintun runtime smoke, Python/Swift/Kotlin FFI jobs, WASM client-only jobs, dependency policy, Miri, strict Clippy/rustfmt, release builds, sanitizers, and CodeQL.

Acceptance criteria

  • A real captured IPv4 TCP client flow reaches a public test server only through a multi-hop Rings Onion route and TCP exit.
  • Removing the exit or breaking route construction fails the captured flow without direct egress.
  • Rings overlay/bootstrap/ICE/control traffic remains reachable while capture routes are owned.
  • TCP data, EOF, half-close, reset/error, backpressure, retransmission, timeout, MTU, and connection limits are covered.
  • Linux TUN, macOS utun, and Windows Wintun each pass a native runtime integration smoke test.
  • Stop and crash reconciliation do not leave a route that indefinitely blackholes the host.
  • RPC/inspection distinguishes process health from interface state, routing mode, exit availability, flow count, and active/degraded/failed gateway health.
  • Existing CLI shapes and non-gateway behavior remain compatible.
  • WASM builds remain client-only and contain no gateway/server dependency or exports.
  • Python, Swift, and Kotlin examples exercise equivalent C ABI ownership and provider lifecycle behavior.
  • Documentation and benchmarks do not present partial interception as a complete VPN.

Explicit non-goals for this iteration

  • iOS NetworkExtension integration.
  • Android VpnService integration.
  • UDP over Onion circuits.
  • IPv6 capture or egress.
  • Private/LAN target access through public exits.
  • Raw source-IP or end-to-end TCP packet identity preservation.
  • systemd, launchd, Windows SCM, or other service-manager wrappers.

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions