- Rust 77%
- Shell 19%
- Python 4%
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
suffixesas 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 matchci/targets..tangled/workflows/build-<target>.yml, one per target, generated byci/workflows.shso the targets build in parallel:ci/build.shfor that target, and onv*tagsci/publish.sh, which attaches the binary and its.sha256to the tag as artifacts. Tags have to be annotated, and the repository needs theATP_PDS,ATP_IDENTIFIERandATP_APP_PASSWORDsecrets 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.