[feature] IPv6 support — dual-stack by default (two-socket) #25

Closed
opened 2026-08-11 10:19:21 +00:00 by james.oates · 1 comment
Owner

Summary

Make EdgeRouter accept IPv6 out of the box, dual-stack by default — matching what every comparable proxy (nginx, Caddy, Traefik, HAProxy) does. This is a product-quality feature, not a need for our own stack (Cloudflare Tunnel already serves v6 at the edge and dials the origin over its own connection), but "accepts IPv6" is close to table stakes for a reverse proxy in 2026 (v6-only backends/networks, per-hour IPv4 billing on AWS, carrier-grade NAT64 clients).

The bar for "we support IPv6" is: dual-stack by default + client IPs attributed correctly + documented — not merely "possible via config" (a [::] binding already works today).

Already IPv6-ready (verified as of fcaf675) — no changes needed

  • Access control (access.rs, config.rs): parse_ip_or_cidr → ipnet::IpNet, client IPs are std::net::IpAddr, matching is IpNet::contains (handles cross-family correctly). allow_ips / match_local_ip accept v6 CIDRs today.
  • Client-IP attribution (access.rs:54): CF-Connecting-IP/X-Real-IP parsed as IpAddr.
  • Explicit bindings (main.rs:159): bind is .parse::<SocketAddr>(); "[::]:443" already parses and TcpListener::bind takes it.
  • TLS/SNI, ConnectInfo<SocketAddr> peer address, and DNS-named upstreams (TcpStream::connect("name:port") resolves AAAA) are address-family-agnostic.

Decision: two-socket dual-stack (reliable across platforms)

Default bindings become two sockets: 0.0.0.0 (v4) and [::] (v6) with IPV6_V6ONLY set true on the v6 socket. This avoids the cross-platform trap:

  • Naively binding 0.0.0.0 + [::] conflicts on Linux ([::] grabs v4-mapped too → EADDRINUSE).
  • [::] alone is v6-only on Windows/OpenBSD but dual on default Linux — non-deterministic.

Explicit V6ONLY=true + two separate sockets gives deterministic dual-stack everywhere, and fits the existing explicit-[[bindings]] model cleanly. Requires the socket2 crate to set the flag before listen() (small, standard dep).

Work items

  1. Default bindings (main.rs:141,147, currently SocketAddr::from(([0,0,0,0], port))): emit both a v4 (0.0.0.0:port) and a v6 ([::]:port, V6ONLY) default binding per port. Suggested names: http/https (v4) + http6/https6 (v6).
  2. V6ONLY socket setup: build the v6 listeners via socket2 (or set IPV6_V6ONLY before bind) in the binding-loop setup (main.rs ~868 TcpListener::bind). Applies to explicit [::] bindings from TOML too.
  3. IPv6-literal target_host bracketing: proxy.rs:325 (WS: format!("{host}:{port}")) and proxy.rs:455 (format!("http://{host}:{port}…")) need [..] bracketing when the host is a v6 literal. Add a small helper; named backends are unaffected.
  4. Host-header port stripping (proxy.rs:122, host.split(':').next()): breaks on a bracketed v6-literal Host ([::1]:443). Low impact (routing is hostname-based) but fix while here.
  5. Verify the X-Forwarded-For value the proxy sets formats a v6 client correctly (bare v6 is valid in XFF).
  6. Docs: show a v6 / dual-stack example in edge-router.example.toml and mention IPv6 support in the README.
  7. Tests: a dual-stack integration test (accept a connection over [::1], assert correct peer attribution + a v6 access rule matches); unit test for the v6-literal bracketing helper.

Operational notes (not code)

  • Docker/OrbStack networks must have IPv6 enabled for a v6 bind to receive traffic — infra, not ER.
  • match_local_ip under v6 carries the same bridge-DNAT caveat as v4 (only works where the process sees the host's real interface address).

Effort / priority

Small and well-bounded — no architectural change, one new small dep (socket2). Roughly half a day incl. tests. Priority: medium — a genuine product feature, not blocking anything.

## Summary Make EdgeRouter accept IPv6 out of the box, dual-stack by default — matching what every comparable proxy (nginx, Caddy, Traefik, HAProxy) does. This is a product-quality feature, not a need for our own stack (Cloudflare Tunnel already serves v6 at the edge and dials the origin over its own connection), but "accepts IPv6" is close to table stakes for a reverse proxy in 2026 (v6-only backends/networks, per-hour IPv4 billing on AWS, carrier-grade NAT64 clients). The bar for "we support IPv6" is: **dual-stack by default + client IPs attributed correctly + documented** — not merely "possible via config" (a `[::]` binding already works today). ## Already IPv6-ready (verified as of `fcaf675`) — no changes needed - **Access control** (`access.rs`, `config.rs`): `parse_ip_or_cidr` → `ipnet::IpNet`, client IPs are `std::net::IpAddr`, matching is `IpNet::contains` (handles cross-family correctly). `allow_ips` / `match_local_ip` accept v6 CIDRs today. - **Client-IP attribution** (`access.rs:54`): `CF-Connecting-IP`/`X-Real-IP` parsed as `IpAddr`. - **Explicit bindings** (`main.rs:159`): `bind` is `.parse::<SocketAddr>()`; `"[::]:443"` already parses and `TcpListener::bind` takes it. - **TLS/SNI**, `ConnectInfo<SocketAddr>` peer address, and **DNS-named upstreams** (`TcpStream::connect("name:port")` resolves AAAA) are address-family-agnostic. ## Decision: two-socket dual-stack (reliable across platforms) Default bindings become **two sockets**: `0.0.0.0` (v4) **and** `[::]` (v6) with **`IPV6_V6ONLY` set true on the v6 socket**. This avoids the cross-platform trap: - Naively binding `0.0.0.0` + `[::]` conflicts on Linux (`[::]` grabs v4-mapped too → `EADDRINUSE`). - `[::]` alone is v6-only on Windows/OpenBSD but dual on default Linux — non-deterministic. Explicit `V6ONLY=true` + two separate sockets gives deterministic dual-stack everywhere, and fits the existing explicit-`[[bindings]]` model cleanly. Requires the **`socket2`** crate to set the flag before `listen()` (small, standard dep). ## Work items 1. **Default bindings** (`main.rs:141,147`, currently `SocketAddr::from(([0,0,0,0], port))`): emit both a v4 (`0.0.0.0:port`) and a v6 (`[::]:port`, V6ONLY) default binding per port. Suggested names: `http`/`https` (v4) + `http6`/`https6` (v6). 2. **V6ONLY socket setup**: build the v6 listeners via `socket2` (or set `IPV6_V6ONLY` before bind) in the binding-loop setup (`main.rs` ~868 `TcpListener::bind`). Applies to explicit `[::]` bindings from TOML too. 3. **IPv6-literal `target_host` bracketing**: `proxy.rs:325` (WS: `format!("{host}:{port}")`) and `proxy.rs:455` (`format!("http://{host}:{port}…")`) need `[..]` bracketing when the host is a v6 literal. Add a small helper; named backends are unaffected. 4. **Host-header port stripping** (`proxy.rs:122`, `host.split(':').next()`): breaks on a bracketed v6-literal Host (`[::1]:443`). Low impact (routing is hostname-based) but fix while here. 5. **Verify** the `X-Forwarded-For` value the proxy sets formats a v6 client correctly (bare v6 is valid in XFF). 6. **Docs**: show a v6 / dual-stack example in `edge-router.example.toml` and mention IPv6 support in the README. 7. **Tests**: a dual-stack integration test (accept a connection over `[::1]`, assert correct peer attribution + a v6 access rule matches); unit test for the v6-literal bracketing helper. ## Operational notes (not code) - Docker/OrbStack networks must have IPv6 enabled for a v6 bind to receive traffic — infra, not ER. - `match_local_ip` under v6 carries the same bridge-DNAT caveat as v4 (only works where the process sees the host's real interface address). ## Effort / priority Small and well-bounded — no architectural change, one new small dep (`socket2`). Roughly half a day incl. tests. Priority: medium — a genuine product feature, not blocking anything.
Author
Owner

Implemented in d9e68cd.

Two improvements over the original plan:

  1. Shared-zone listeners instead of http6/https6 access names. The v4 and v6 default sockets are exposed under one listener/trust-zone name (http/https), so a service's access rule via = "http" transparently covers both families — no need to grant via="http" and via="http6". resolve_listener already scopes listeners per parent-binding, so this needed no change to the matching logic. (Verified against resolve_listener/access_allowed; no name-keyed map to collapse the shared names.)

  2. Synthesized v6 defaults are best-effort (access::Binding.best_effort). A code-review pass caught that unconditionally synthesizing [::] defaults would crash startup on a host with IPv6 disabled (sysctl disable_ipv6, minimal containers) — a regression, since the old 0.0.0.0-only default always came up. Fix: bind_configured() logs-and-skips a best-effort binding that fails to bind, so the process still boots on v4; explicit operator-declared bindings stay hard-fail.

Delivered:

  • Dual-stack default: v4 (0.0.0.0) + v6 ([::]) socket per port; v6 bound IPV6_V6ONLY via socket2 (bind_listener) for deterministic cross-platform behaviour; SO_REUSEADDR on unix only.
  • config::host_port brackets IPv6-literal backends, applied to all five upstream host:port sites (proxy HTTP + WS, health probe URL + TLS connect, L4 tunnel) — the ticket named two; the health-probe one would have silently marked a v6 backend unhealthy.
  • proxy::authority_host parses the Host header bracket-aware (fixes split(':') mangling [::1]:443).
  • X-Forwarded-For already emitted bare IPv6 via IpAddr::to_string — confirmed correct, left as-is.
  • Tests: bracketing, host-strip, both-family CIDR parse/match + cross-family rejection, default dual-stack construction (shared-zone + best-effort assertions), operator-bindings-without-listeners path, best-effort skip-not-fatal, and a bind_listener integration test that always exercises v4 and capability-skips the v6 leg where loopback is absent.
  • Docs: README feature bullet + bindings section, edge-router.example.toml, CLAUDE.md.

Full suite green (192), clippy + fmt clean. Available on :develop once CI builds; devinfra will pick it up on its next recreate.

Implemented in `d9e68cd`. **Two improvements over the original plan:** 1. **Shared-zone listeners instead of `http6`/`https6` access names.** The v4 and v6 default sockets are exposed under **one** listener/trust-zone name (`http`/`https`), so a service's `access` rule `via = "http"` transparently covers both families — no need to grant `via="http"` *and* `via="http6"`. `resolve_listener` already scopes listeners per parent-binding, so this needed no change to the matching logic. (Verified against `resolve_listener`/`access_allowed`; no name-keyed map to collapse the shared names.) 2. **Synthesized v6 defaults are best-effort** (`access::Binding.best_effort`). A code-review pass caught that unconditionally synthesizing `[::]` defaults would **crash startup on a host with IPv6 disabled** (`sysctl disable_ipv6`, minimal containers) — a regression, since the old `0.0.0.0`-only default always came up. Fix: `bind_configured()` logs-and-skips a best-effort binding that fails to bind, so the process still boots on v4; explicit operator-declared bindings stay hard-fail. **Delivered:** - Dual-stack default: v4 (`0.0.0.0`) + v6 (`[::]`) socket per port; v6 bound `IPV6_V6ONLY` via `socket2` (`bind_listener`) for deterministic cross-platform behaviour; `SO_REUSEADDR` on unix only. - `config::host_port` brackets IPv6-literal backends, applied to **all five** upstream host:port sites (proxy HTTP + WS, health probe URL + TLS connect, L4 tunnel) — the ticket named two; the health-probe one would have silently marked a v6 backend unhealthy. - `proxy::authority_host` parses the Host header bracket-aware (fixes `split(':')` mangling `[::1]:443`). - `X-Forwarded-For` already emitted bare IPv6 via `IpAddr::to_string` — confirmed correct, left as-is. - Tests: bracketing, host-strip, both-family CIDR parse/match + cross-family rejection, default dual-stack construction (shared-zone + best-effort assertions), operator-bindings-without-listeners path, best-effort skip-not-fatal, and a `bind_listener` integration test that always exercises v4 and capability-skips the v6 leg where loopback is absent. - Docs: README feature bullet + bindings section, `edge-router.example.toml`, `CLAUDE.md`. Full suite green (192), clippy + fmt clean. Available on `:develop` once CI builds; devinfra will pick it up on its next recreate.
Sign in to join this conversation.
No labels
No milestone
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
IsoHex/edge-router#25
No description provided.