[feature] IPv6 support — dual-stack by default (two-socket) #25
Loading…
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
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 neededaccess.rs,config.rs):parse_ip_or_cidr→ipnet::IpNet, client IPs arestd::net::IpAddr, matching isIpNet::contains(handles cross-family correctly).allow_ips/match_local_ipaccept v6 CIDRs today.access.rs:54):CF-Connecting-IP/X-Real-IPparsed asIpAddr.main.rs:159):bindis.parse::<SocketAddr>();"[::]:443"already parses andTcpListener::bindtakes it.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) withIPV6_V6ONLYset true on the v6 socket. This avoids the cross-platform trap: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 thesocket2crate to set the flag beforelisten()(small, standard dep).Work items
main.rs:141,147, currentlySocketAddr::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).socket2(or setIPV6_V6ONLYbefore bind) in the binding-loop setup (main.rs~868TcpListener::bind). Applies to explicit[::]bindings from TOML too.target_hostbracketing:proxy.rs:325(WS:format!("{host}:{port}")) andproxy.rs:455(format!("http://{host}:{port}…")) need[..]bracketing when the host is a v6 literal. Add a small helper; named backends are unaffected.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.X-Forwarded-Forvalue the proxy sets formats a v6 client correctly (bare v6 is valid in XFF).edge-router.example.tomland mention IPv6 support in the README.[::1], assert correct peer attribution + a v6 access rule matches); unit test for the v6-literal bracketing helper.Operational notes (not code)
match_local_ipunder 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.Implemented in
d9e68cd.Two improvements over the original plan:
Shared-zone listeners instead of
http6/https6access names. The v4 and v6 default sockets are exposed under one listener/trust-zone name (http/https), so a service'saccessrulevia = "http"transparently covers both families — no need to grantvia="http"andvia="http6".resolve_listeneralready scopes listeners per parent-binding, so this needed no change to the matching logic. (Verified againstresolve_listener/access_allowed; no name-keyed map to collapse the shared names.)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 old0.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:
0.0.0.0) + v6 ([::]) socket per port; v6 boundIPV6_V6ONLYviasocket2(bind_listener) for deterministic cross-platform behaviour;SO_REUSEADDRon unix only.config::host_portbrackets 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_hostparses the Host header bracket-aware (fixessplit(':')mangling[::1]:443).X-Forwarded-Foralready emitted bare IPv6 viaIpAddr::to_string— confirmed correct, left as-is.bind_listenerintegration test that always exercises v4 and capability-skips the v6 leg where loopback is absent.edge-router.example.toml,CLAUDE.md.Full suite green (192), clippy + fmt clean. Available on
:developonce CI builds; devinfra will pick it up on its next recreate.