IPv4 to IPv6 multiplexer based on hostnames found in SNI or Host headers.

README.md

ingress46 #

Shares one public IPv4 address between IPv6-reachable hosts behind it. It accepts TLS and HTTP connections over IPv4, reads the hostname the client asks for (the ClientHello's server name, or the Host header), looks up its AAAA record and forwards the connection there over IPv6, without terminating TLS.

The connection to the backend comes from <source-prefix>::<client IPv4>, on the client's own source port, so backends see who connected without PROXY protocol or forwarding headers, and a log line with address, port and time is enough for an abuse report even when the client is behind CGNAT.

client 203.0.113.7:51234 ──IPv4──▶ ingress46 ──IPv6──▶ backend
                                     from [64:ff9b:1::cb00:7107]:51234

The design follows proxy46 and snid. It differs in which backends it allows: there is no hostname allow list. A connection is forwarded only if the address its hostname resolves to

  • lies in a prefix currently assigned to one of the watched interfaces (global or unique local, /64 or shorter), and
  • has one of the configured suffixes as its lower 64 bits.

The prefixes are followed live over netlink, so a renumbered ISP prefix is picked up without a restart or a configuration change.

Plain HTTP #

A listener with protocol = "http" decides per host what to do with plain HTTP: "https-redirect" answers with 301 Moved Permanently to https://<host><path> and never connects anywhere, "forward" forwards the connection by its host like tls does by server name. default is required; hosts lists exceptions, where "example.com" is that host alone and ".example.com" every host below it (not example.com itself), and the most specific entry wins:

[listen.http]
address = "0.0.0.0:80"
protocol = "http"
default = "https-redirect"

[listen.http.hosts]
"legacy.example.com" = "forward"

The host comes from the first request head: from the target in absolute form (GET http://host/path, as RFC 9112 §3.2.2 requires), from Host otherwise. Requests without a usable host, with more than one Host, or in asterisk or authority form are answered with a 400. Later requests on a forwarded connection go to the same backend, whatever their host.

When answering, only the request head is read, never a body, so the connection is closed in stages as RFC 9112 §9.6 asks: half-close after the response, then discard what the client still sends for up to 2 s or 64 KiB. Closing with unread data would reset the connection, and some clients drop the response when that happens.

Running it #

docs/running.md has what a deployment needs: which binary for which architecture, the command line, every configuration key, the process and its signals, privileges, what the host has to provide (the source prefix route, firewall, free ports), and every log message and metric. contrib/ingress46.toml is an annotated example configuration.

Tests #

cargo test covers the parsers, the allow list, the host modes and the configuration. tests/netns.sh runs the real thing in network namespaces: the address and port mapping for TLS and HTTP, binding over a source still in TIME_WAIT, refused backends, redirects and bad requests, renumbering the LAN while running, and losing the source prefix route. It needs no root, only unprivileged user namespaces, unshare and nsenter from util-linux, iproute2, Python and curl:

cargo build && sh tests/netns.sh target/debug/ingress46

Binaries and CI #

ci/build.sh builds static binaries for the targets in ci/targets (see docs/running.md for which runs where), each in its rust-musl-cross image, with podman, into dist/. ci/test-emulated.sh runs tests/netns.sh against each binary under qemu's emulation of its architecture; that is for running locally after changes that could behave differently elsewhere (endianness, word size, atomics), not part of CI.

CI runs on Tangled:

  • .tangled/workflows/test.yml: rustfmt, clippy, cargo-deny, REUSE, the unit tests, the end-to-end test, and a check that the build workflows match ci/targets.
  • .tangled/workflows/build-<target>.yml, one per target, generated by ci/workflows.sh so the targets build in parallel: ci/build.sh for that target, and on v* tags ci/publish.sh, which attaches the binary and its .sha256 to the tag as artifacts. Tags have to be annotated, and the repository needs the ATP_PDS, ATP_IDENTIFIER and ATP_APP_PASSWORD secrets of an account with push access.

libc stays at 0.2.189 for now: 0.2.190 and 0.2.191 give 64-bit MIPS the generic socket option constants, so every setsockopt fails there. deny.toml bans both until a fixed release is out.