diff --git a/.github/workflows/main.yaml b/.github/workflows/main.yaml index 77a984ab..f94e488f 100644 --- a/.github/workflows/main.yaml +++ b/.github/workflows/main.yaml @@ -11,6 +11,16 @@ jobs: run: DOCKER_BUILDKIT=1 docker build -t rootlesskit:test-unit --target test-unit . - name: "Unit test" run: docker run --rm --privileged rootlesskit:test-unit + test-unit-iptables-fallback: + name: "Unit test (source-ip-transparent iptables fallback, no nft)" + runs-on: ubuntu-24.04 + steps: + - name: "Check out" + uses: actions/checkout@v7 + - name: "Build unit test image without nft" + run: DOCKER_BUILDKIT=1 docker build -t rootlesskit:test-unit-iptables-fallback --target test-unit --build-arg TEST_UNIT_APT_EXTRA=iptables . + - name: "Unit test" + run: docker run --rm --privileged rootlesskit:test-unit-iptables-fallback test-cross: name: "Cross compilation test" runs-on: ubuntu-24.04 diff --git a/Dockerfile b/Dockerfile index 100ba8a5..b0f3d18f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -26,8 +26,11 @@ COPY --from=cross /go/src/github.com/rootless-containers/rootlesskit/_artifact/* # `go test -race` requires non-Alpine FROM golang:${GO_VERSION} AS test-unit -# iptables: used for source-ip-transparent -RUN apt-get update && apt-get install -y git iproute2 netcat-openbsd iptables +# iptables, nftables: used for source-ip-transparent (nft preferred, iptables as fallback). +# TEST_UNIT_APT_EXTRA can be overridden to "iptables" only, to exercise the +# fallback path in CI when nft isn't available. +ARG TEST_UNIT_APT_EXTRA="iptables nftables" +RUN apt-get update && apt-get install -y git iproute2 netcat-openbsd $TEST_UNIT_APT_EXTRA ADD . /go/src/github.com/rootless-containers/rootlesskit WORKDIR /go/src/github.com/rootless-containers/rootlesskit RUN go mod verify && go vet ./... @@ -65,8 +68,9 @@ FROM ubuntu:${UBUNTU_VERSION} AS test-integration # libcap2-bin and curl: used by the RUN instructions in this Dockerfile. # bind9-dnsutils: for `nslookup` command used by integration-net.sh # systemd and uuid-runtime: for systemd-socket-activate used by integration-systemd-socket.sh -# iptables: for source-ip-transparent. Also for Docker. -RUN apt-get update && apt-get install -y iproute2 liblxc-common lxc-utils iperf3 busybox sudo libcap2-bin curl bind9-dnsutils systemd uuid-runtime iptables +# iptables: for Docker (dockerd-rootless itself still uses iptables). +# nftables: for source-ip-transparent (rootlesskit's own builtin port driver). +RUN apt-get update && apt-get install -y iproute2 liblxc-common lxc-utils iperf3 busybox sudo libcap2-bin curl bind9-dnsutils systemd uuid-runtime iptables nftables COPY --from=idmap /usr/bin/newuidmap /usr/bin/newuidmap COPY --from=idmap /usr/bin/newgidmap /usr/bin/newgidmap RUN /sbin/setcap cap_setuid+eip /usr/bin/newuidmap && \ diff --git a/pkg/port/builtin/child/child.go b/pkg/port/builtin/child/child.go index 09cce0b9..42902507 100644 --- a/pkg/port/builtin/child/child.go +++ b/pkg/port/builtin/child/child.go @@ -207,6 +207,9 @@ fallback: // setupTransparentRouting sets up policy routing so that response packets // destined to transparent-bound source IPs are delivered locally. +// The firewall rules are implemented via nft, falling back to iptables if +// nft isn't available on the host (see setupTransparentRoutingNFT and +// setupTransparentRoutingIPTables). // // Transparent sockets (IP_TRANSPARENT) bind to non-local addresses (the real // client IP). Response packets to these addresses must be routed locally instead @@ -224,17 +227,8 @@ fallback: // SYN-ACK is then routed via the fwmark table (local delivery) instead of // the default route (TAP), allowing it to reach the transparent socket. func (d *childDriver) setupTransparentRouting() bool { - // Check that iptables is available before proceeding. - if _, err := exec.LookPath("iptables"); err != nil { - fmt.Fprintf(d.logWriter, "source IP transparent: iptables not found, disabling: %v\n", err) - return false - } - // Verify the connmark module is usable (kernel module might not be loaded). - if out, err := exec.Command("iptables", "-t", "mangle", "-L", "-n").CombinedOutput(); err != nil { - fmt.Fprintf(d.logWriter, "source IP transparent: iptables mangle table not available, disabling: %v: %s\n", err, out) - return false - } - cmds := [][]string{ + // Common prep, independent of the firewall backend used below. + prepCmds := [][]string{ // Table 100: treat all addresses as local (for delivery to transparent sockets) {"ip", "route", "add", "local", "default", "dev", "lo", "table", "100"}, {"ip", "-6", "route", "add", "local", "default", "dev", "lo", "table", "100"}, @@ -244,6 +238,75 @@ func (d *childDriver) setupTransparentRouting() bool { // Inherit fwmark from SYN to accepted socket (needed for userspace proxies // like docker-proxy, so that SYN-ACK routing uses table 100) {"sysctl", "-w", "net.ipv4.tcp_fwmark_accept=1"}, + } + for _, args := range prepCmds { + if out, err := exec.Command(args[0], args[1:]...).CombinedOutput(); err != nil { + fmt.Fprintf(d.logWriter, "source IP transparent routing setup: %v: %s\n", err, out) + } + } + if d.setupTransparentRoutingNFT() { + return true + } + fmt.Fprintf(d.logWriter, "source IP transparent: nft unavailable, falling back to iptables\n") + return d.setupTransparentRoutingIPTables() +} + +// setupTransparentRoutingNFT implements setupTransparentRouting using nft. +// The "inet" family covers both IPv4 and IPv6 in a single ruleset. +func (d *childDriver) setupTransparentRoutingNFT() bool { + // Check that nft is available before proceeding. + if _, err := exec.LookPath("nft"); err != nil { + fmt.Fprintf(d.logWriter, "source IP transparent (nft): nft not found, disabling: %v\n", err) + return false + } + // Verify nftables is usable (the nf_tables kernel module might not be loaded). + if out, err := exec.Command("nft", "list", "tables").CombinedOutput(); err != nil { + fmt.Fprintf(d.logWriter, "source IP transparent (nft): nft not available, disabling: %v: %s\n", err, out) + return false + } + const nftTable = "rootlesskit_transparent" + cmds := [][]string{ + // Create a single nftables table in the "inet" family, which covers both + // IPv4 and IPv6, replacing the separate iptables/ip6tables tables used below. + {"nft", "add", "table", "inet", nftTable}, + // Hook into OUTPUT and PREROUTING at the "mangle" priority, matching where + // the equivalent rules live in the iptables mangle table. + {"nft", "add", "chain", "inet", nftTable, "output", + "{", "type", "filter", "hook", "output", "priority", "mangle", ";", "}"}, + {"nft", "add", "chain", "inet", nftTable, "prerouting", + "{", "type", "filter", "hook", "prerouting", "priority", "mangle", ";", "}"}, + // In OUTPUT: tag transparent connections (non-local source) with a connection + // mark. Equivalent to iptables: -m addrtype ! --src-type LOCAL -j CONNMARK --set-mark 100 + {"nft", "add", "rule", "inet", nftTable, "output", + "meta", "l4proto", "tcp", "fib", "saddr", "type", "!=", "local", "ct", "mark", "set", "100"}, + // In PREROUTING: restore the connmark to the packet mark for routing. + // Equivalent to iptables: -m connmark --mark 100 -j MARK --set-mark 100 + {"nft", "add", "rule", "inet", nftTable, "prerouting", + "meta", "l4proto", "tcp", "ct", "mark", "100", "meta", "mark", "set", "100"}, + } + for _, args := range cmds { + if out, err := exec.Command(args[0], args[1:]...).CombinedOutput(); err != nil { + fmt.Fprintf(d.logWriter, "source IP transparent (nft) routing setup: %v: %s\n", err, out) + } + } + return true +} + +// setupTransparentRoutingIPTables implements setupTransparentRouting using +// iptables/ip6tables, kept as a fallback for hosts where nft is unavailable +// and for environments that still expect iptables specifically. +func (d *childDriver) setupTransparentRoutingIPTables() bool { + // Check that iptables is available before proceeding. + if _, err := exec.LookPath("iptables"); err != nil { + fmt.Fprintf(d.logWriter, "source IP transparent (iptables): iptables not found, disabling: %v\n", err) + return false + } + // Verify the connmark module is usable (kernel module might not be loaded). + if out, err := exec.Command("iptables", "-t", "mangle", "-L", "-n").CombinedOutput(); err != nil { + fmt.Fprintf(d.logWriter, "source IP transparent (iptables): mangle table not available, disabling: %v: %s\n", err, out) + return false + } + cmds := [][]string{ // In OUTPUT: tag transparent connections (non-local source) with CONNMARK {"iptables", "-t", "mangle", "-A", "OUTPUT", "-p", "tcp", "-m", "addrtype", "!", "--src-type", "LOCAL", "-j", "CONNMARK", "--set-mark", "100"}, {"ip6tables", "-t", "mangle", "-A", "OUTPUT", "-p", "tcp", "-m", "addrtype", "!", "--src-type", "LOCAL", "-j", "CONNMARK", "--set-mark", "100"}, @@ -253,7 +316,7 @@ func (d *childDriver) setupTransparentRouting() bool { } for _, args := range cmds { if out, err := exec.Command(args[0], args[1:]...).CombinedOutput(); err != nil { - fmt.Fprintf(d.logWriter, "source IP transparent routing setup: %v: %s\n", err, out) + fmt.Fprintf(d.logWriter, "source IP transparent (iptables) routing setup: %v: %s\n", err, out) } } return true