Native Gateway
A native node can forward explicitly selected IPv4/TCP destinations through Rings onion
loops, the privacy layer,
over a TUN device. The gateway is configured by the gateway: section of
config.yaml, which rings init writes in full with enabled: false; see
config.yaml for the generated section.
Presence of the section is not consent to start a TUN device:
gateway starts ⟺ section present ∧ (enabled = true ∨ rings run --gateway)
A section that omits enabled is inert, so plain rings run on the generated file creates no
interface. To run the gateway once without editing the file:
rings run --gateway
On a config written before the section existed, --gateway fails and prints the exact section
to append. The generated plan assigns one host address from the RFC 6598 shared address space
(100.64.0.1/32) and captures no destination, so enabling it creates the interface without
steering traffic until you list a prefix in included_routes. A TUN device also needs
CAP_NET_ADMIN on Linux or the foreground helper described below; the configuration grants
neither.
The rest of this page is the operator guide from the rings-gateway crate.
Traffic-selection contract
The operator owns traffic selection. Let C be the normalized set in included_routes:
installed destination capture routes = C
The platform binding deduplicates and normalizes C, but does not add routes derived from a
default route, IPv6, DNS, TURN, ICE, SDP, bootstrap endpoints, or observed peers. A host-wide
0.0.0.0/0 capture, or any set of prefixes whose union covers the entire IPv4 address space, is
rejected. Interface addresses must be IPv4 /32 host addresses so setup cannot create a connected
capture prefix. An empty set is valid when external routing owns packet selection instead.
This follows the same mechanism-versus-policy separation as OpenVPN. OpenVPN’s --route
option selects
individual prefixes, while --redirect-gateway def1 explicitly requests full-tunnel routing and
implements it with two /1 routes. That is a valid OpenVPN policy, but Rings intentionally has no
redirect-gateway equivalent. Capturing every host destination would also capture the ordinary
underlay traffic carrying existing Rings neighbor links; avoiding recursion would require dynamic
bypass state or guaranteed relay. Rings requires neither. A /0 or equivalent covering prefix set
is therefore rejected as an intent stop-line outside the selective gateway contract. Near-cover
sets remain valid, including the entire IPv4 space minus one /32; this rejection is not a proof
against recursion. If the operator explicitly selects an underlay destination, it enters the same
fail-closed path as any other selected destination and Rings does not create a direct bypass.
The gateway’s fail-closed guarantee applies only after traffic enters it. A selected flow either
opens its immutable target through a valid Onion route and compatible TCP exit, or fails without
direct fallback. Traffic outside C continues to use ordinary host routing. Rings bootstrap, SDP
messages, and ICE also keep their ordinary routing behavior. STUN remains an ordinary discovery
facility. TURN may be configured, but gateway and server capability must never depend on it or
force relay-only ICE.
Traffic can enter the shared Onion egress through:
- an exact host route or destination prefix installed by the packet binding;
- an external router or policy-routing rule owned by the operator; or
- an explicit application proxy: the native HTTP CONNECT listener.
The native HTTP CONNECT proxy and packet ingress share Onion target validation, route
construction, TCP stream opening, bidirectional transfer, half-close, and accounting. They differ
in idleness: a CONNECT tunnel ends after the relay’s own idle timeout, while a captured flow’s
idleness belongs to the gateway’s flow_idle_timeout. A future SOCKS5 frontend should add only
ingress parsing and authentication policy.
Pointing several DNS names at one gateway address does not preserve the original generic TCP
target. Optional DNS integration therefore needs a dedicated synthetic prefix and an explicit
synthetic IP -> original target mapping. Rings does not discover or mutate system DNS.
Process model
rings run remains a foreground Unix-style process. This crate does not install, start, stop, or
wrap systemd, launchd, the Windows Service Control Manager, or another supervisor.
On Linux and macOS, privileged interface and route effects are isolated in the separately launched
gateway-config-unix foreground helper. The node keeps one mode-0600 Unix socket connected for the
gateway lease. The helper creates the TUN/utun interface, installs only included_routes, passes
the packet descriptor with SCM_RIGHTS, and owns cleanup. A normal teardown removes owned routes
in reverse order. A node disconnect leaves the interface and selected routes alive so those
selected destinations remain fail-closed; a restarted node may resume the lease with the same
plan. Unrelated host traffic remains unaffected.
The helper never invokes privilege elevation. Operators choose the narrow mechanism:
- Linux can grant only
CAP_NET_ADMINto the helper or run it in an already-privileged namespace. - macOS can launch the helper explicitly as root and use
--socket-owner UIDfor one node user. - Windows uses the in-process Wintun and route binding, so the foreground node needs the relevant host privileges.
Build and run a Unix helper for one node lifecycle:
cargo build --release -p rings-gateway --bin gateway-config-unix
# Linux
sudo setcap cap_net_admin=ep target/release/gateway-config-unix
target/release/gateway-config-unix \
--socket "$HOME/.rings/gateway-helper.sock" \
--ledger "$HOME/.rings/gateway-routes.json" \
--interface rings0
# macOS
sudo install -d -o root -g wheel -m 0755 /var/db/rings-gateway
sudo target/release/gateway-config-unix \
--socket "/var/db/rings-gateway/helper-$(id -u).sock" \
--socket-owner "$(id -u)" \
--ledger "/var/db/rings-gateway/routes-$(id -u).json"
Both direct parents must belong to the helper’s effective UID. Every canonicalized ancestor must belong to that UID or root and reject group/other writes. The socket is mode 0600 and may be assigned to the authorized node UID. Startup probes an existing socket and refuses to unlink it while a helper is listening; only an unreachable stale socket node is removed.
Then run the node separately:
rings run --gateway --config "$HOME/.rings/config.yaml"
Node configuration
The current packet milestone is IPv4/TCP. Only the listed destination prefixes enter the packet gateway. UDP or fragmented IPv4 packets that enter a listed prefix are dropped; IPv6 routes are rejected at configuration time. Unlisted IPv4, all IPv6, and ordinary DNS traffic remain outside gateway policy.
rings init writes a complete gateway: section with enabled: false: the interface-only plan
(GatewayPlan::interface_only, one RFC 6598 host address, no capture routes, the IPv6 minimum
MTU) under the default runtime limits. The section starts nothing by itself; the gateway runs only
under an explicit enabled: true or rings run --gateway, and a section that omits enabled is
inert.
# STUN remains ordinary discovery. TURN is optional and never a gateway prerequisite.
ice_servers: stun://stun.l.google.com:19302
gateway:
enabled: true
plan:
# Host prefixes are required so interface setup cannot create a connected capture subnet.
addresses: ["100.64.0.1/32"]
# Only these destinations are routed to TUN/utun/Wintun.
included_routes: ["1.1.1.1/32"]
mtu: 1280
max_flows: 1024
flow_idle_timeout: 300
tcp_buffer_bytes: 65536
# Must match the foreground helper's --socket path on Linux/macOS.
unix_helper_socket: "/var/db/rings-gateway/helper-501.sock"
# Used directly on Windows; the Unix helper owns its separate --ledger path.
route_ledger_path: "~/.rings/gateway-routes.json"
status_refresh_secs: 2
For externally steered packet operation, included_routes may be empty. The packet interface
still exists, but Rings installs no destination route:
gateway:
enabled: true
plan:
addresses: ["100.64.0.1/32"]
included_routes: []
mtu: 1280
For application-proxy-only operation, leave gateway disabled and configure the existing native
HTTP CONNECT listener. That path creates no packet interface. SOCKS5 remains a future frontend to
the same Onion egress.
Runtime input never expands the capture set. If an operator deliberately selects a destination that Rings itself uses for bootstrap or ICE, the configured route has the same effect as any other operator route; Rings does not silently punch a bypass around it.
At establishment, the native binding reads one route-table snapshot and rejects every exact or more-specific existing route inside a capture prefix because longest-prefix routing would let that sub-range bypass the gateway. Rings deliberately has no route-change subscription. A route added after that snapshot, including while establishment finishes, can therefore shadow a capture prefix and send that sub-range through the host’s new route; operators must prevent or monitor such route-table mutation. The fail-closed guarantee begins only after a packet actually enters the gateway.
Removed route-authority fields such as routing_mode, excluded_routes, dns_policy, and
dns_servers are rejected inside plan instead of being silently ignored. The gateway never
interprets old full-capture configuration as a new selective-capture plan.
Cleanup and status
The durable route ledger contains only explicitly selected capture routes. Its persistent .lock
sidecar is held exclusively for the complete native-controller lifetime, including startup
reconciliation, so a second helper or Windows node using the same ledger fails before it can touch
the first instance’s live routes. The sidecar file may remain after shutdown; the operating system
releases its advisory lock when the process exits. Startup reconciliation removes only stale owned
routes. On macOS it first requires the current exact route to carry the interface identity recorded
in the ledger; an absent route clears the intent, while a foreign or ambiguous owner is preserved
and reported rather than deleted. A failed teardown returns its linear lease so cleanup remains
retryable. On Unix, the helper retains its packet descriptor after transfer. On Windows, the node
retains the Wintun device after a cleanup failure. These behaviors keep affected selected
destinations fail-closed without claiming control over unrelated traffic.
Runtime limits reject more than 16,384 flows, TCP buffers larger than 1 MiB, or a declared four-buffer-per-flow budget above 1 GiB. The Unix client bounds connection and response operations to 10 seconds by default with a hard 30-second maximum.
The loopback-only internal API exposes gateway status when configured:
GET /gateway/status
It reports interface, lifecycle, normalized capture routes, active-flow count, Onion-exit availability with the reason it is not available, and the cause of the most recent flow reset, without granting route mutation authority.
Health and rejection diagnostics
Packet admission lifecycle and exit availability are independent. An active gateway with no usable exit reports degraded health together with its reason; discovery recovery changes the health projection without restarting the packet interface. Inspect the shared status handle for current health.
Every captured-flow reset names its cause in last_flow_reset and in an info-level
rings_gateway::runtime event. A silent client ends a flow as idle-timeout only when its onion
stream was open and healthy; the same silence names what the flow was waiting on otherwise:
handshake-expired, open-expired while the onion stream was still opening, stream-stalled
while a frame of the stream was known missing, and stream-reset once its onion session had failed.
Enable debug tracing for rings_gateway::runtime to also see typed packet-drop and
flow-rejection reasons. These events contain neither packet bodies nor destination addresses. Configuration
is checked at runtime construction before resource allocation; standalone server and TCP stack
construction also reject invalid configuration.