Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .github/workflows/main.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
12 changes: 8 additions & 4 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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 ./...
Expand Down Expand Up @@ -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 && \
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,7 @@ OPTIONS:
--port-driver value port driver for non-host network. [none, implicit (for pasta), builtin, slirp4netns, gvisor-tap-vsock(experimental)] (default: "none")
--publish value, -p value [ --publish value, -p value ] publish ports. e.g. "127.0.0.1:8080:80/tcp"
--source-ip-transparent preserve real client source IP using IP_TRANSPARENT (builtin port driver, TCP only) (default: true)
--source-ip-transparent-backend value firewall backend for --source-ip-transparent (builtin port driver) [auto, nft, iptables] (default: "auto")

Process:
--pidns create a PID namespace (default: false)
Expand Down
14 changes: 13 additions & 1 deletion cmd/rootlesskit/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,11 @@ See https://rootlesscontaine.rs/getting-started/common/ .
Usage: "preserve real client source IP using IP_TRANSPARENT (builtin port driver, TCP only)",
Value: true,
}, CategoryPort),
Categorize(&cli.StringFlag{
Name: "source-ip-transparent-backend",
Usage: "firewall backend for --source-ip-transparent (builtin port driver) [auto, nft, iptables]",
Value: "auto",
}, CategoryPort),
Categorize(&cli.BoolFlag{
Name: "pidns",
Usage: "create a PID namespace",
Expand Down Expand Up @@ -625,7 +630,14 @@ func createParentOpt(clicontext *cli.Context) (parent.Opt, error) {
if opt.NetworkDriver == nil {
return opt, errors.New("port driver requires non-host network")
}
opt.PortDriver, err = builtin.NewParentDriver(&logrusDebugWriter{label: "port/builtin"}, opt.StateDir, clicontext.Bool("source-ip-transparent"))
sourceIPTransparentBackend := clicontext.String("source-ip-transparent-backend")
switch sourceIPTransparentBackend {
case "auto", "nft", "iptables":
// OK
default:
return opt, fmt.Errorf("unknown source-ip-transparent-backend: %s", sourceIPTransparentBackend)
}
opt.PortDriver, err = builtin.NewParentDriver(&logrusDebugWriter{label: "port/builtin"}, opt.StateDir, clicontext.Bool("source-ip-transparent"), sourceIPTransparentBackend)
if err != nil {
return opt, err
}
Expand Down
2 changes: 1 addition & 1 deletion docs/port.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ The default value is `none` (do not expose ports).
| `--port-driver` | Throughput | Source IP | Notes
|----------------------|-------------|----------|-------
| `slirp4netns` | 8.03 Gbps | Propagated |
| `builtin` | 29.9 Gbps | Propagated for TCP (since v3.0) | Source IP propagation (`--source-ip-transparent`) applies to TCP only; UDP is not propagated. In the case of Rootless Docker, userland-proxy has to be disabled for propagating the source IP.
| `builtin` | 29.9 Gbps | Propagated for TCP (since v3.0) | Source IP propagation (`--source-ip-transparent`) applies to TCP only; UDP is not propagated. In the case of Rootless Docker, userland-proxy has to be disabled for propagating the source IP. The underlying firewall rules use `nft`, falling back to `iptables` if `nft` is unavailable; `--source-ip-transparent-backend` can be used to force one or the other.
| `implicit` | 37.6 Gbps | Propagated | Requires `pasta` network
| `gvisor-tap-vsock` (Experimental) | 3.83 Gbps | Not propagated | Throughput is currently limited; see issue link below for improvement ideas.

Expand Down
9 changes: 5 additions & 4 deletions pkg/api/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import "net"
const (
// Version of the REST API, not implementation version.
// See openapi.yaml for the definition.
Version = "1.1.2"
Version = "1.1.3"
)

// Info is the structure returned by `GET /info`
Expand All @@ -29,7 +29,8 @@ type NetworkDriverInfo struct {

// PortDriverInfo in Info
type PortDriverInfo struct {
Driver string `json:"driver"`
Protos []string `json:"protos"`
DisallowLoopbackChildIP bool `json:"disallowLoopbackChildIP,omitempty"` // since API v1.1.1
Driver string `json:"driver"`
Protos []string `json:"protos"`
DisallowLoopbackChildIP bool `json:"disallowLoopbackChildIP,omitempty"` // since API v1.1.1
Extra map[string]string `json:"extra,omitempty"` // since API v1.1.3, driver-specific details, e.g. {"sourceIPTransparentBackend": "nft"} for the builtin driver
}
7 changes: 6 additions & 1 deletion pkg/api/openapi.yaml
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# When you made a change to this YAML, please validate with https://editor.swagger.io
openapi: 3.0.3
info:
version: 1.1.2
version: 1.1.3
title: RootlessKit API
servers:
- url: 'http://rootlesskit/v1'
Expand Down Expand Up @@ -172,3 +172,8 @@ components:
disallowLoopbackChildIP:
type: boolean
description: "If this field is set to true, loopback IP such as 127.0.0.1 cannot be specified as a child IP"
extra:
type: object
description: "Driver-specific details, e.g. {\"sourceIPTransparentBackend\": \"nft\"} for the builtin driver"
additionalProperties:
type: string
4 changes: 2 additions & 2 deletions pkg/port/builtin/builtin.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,8 @@ import (
)

var (
NewParentDriver func(logWriter io.Writer, stateDir string, sourceIPTransparent bool) (port.ParentDriver, error) = parent.NewDriver
NewChildDriver func(logWriter io.Writer) port.ChildDriver = child.NewDriver
NewParentDriver func(logWriter io.Writer, stateDir string, sourceIPTransparent bool, sourceIPTransparentBackend string) (port.ParentDriver, error) = parent.NewDriver
NewChildDriver func(logWriter io.Writer) port.ChildDriver = child.NewDriver
)

// Available indicates whether this port driver is compiled in (used for generating help text)
Expand Down
41 changes: 40 additions & 1 deletion pkg/port/builtin/builtin_test.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
package builtin

import (
"context"
"os"
"os/exec"
"testing"

"github.com/rootless-containers/rootlesskit/v3/pkg/port"
Expand All @@ -21,7 +23,7 @@ func TestBuiltIn(t *testing.T) {
t.Fatal(err)
}
defer os.RemoveAll(tmpDir)
d, err := NewParentDriver(os.Stderr, tmpDir, true)
d, err := NewParentDriver(os.Stderr, tmpDir, true, "auto")
if err != nil {
t.Fatal(err)
}
Expand All @@ -32,3 +34,40 @@ func TestBuiltIn(t *testing.T) {
testsuite.RunTCPTransparent(t, pf)
testsuite.RunUDPTransparent(t, pf)
}

// TestSourceIPTransparentBackend exercises an explicit
// --source-ip-transparent-backend selection end to end, and checks that the
// choice is reported back via PortDriverInfo.Extra.
func TestSourceIPTransparentBackend(t *testing.T) {
for _, backend := range []string{"nft", "iptables"} {
t.Run(backend, func(t *testing.T) {
if backend == "nft" {
ensureNFT(t)
}
tmpDir, err := os.MkdirTemp("", "test-builtin-backend")
if err != nil {
t.Fatal(err)
}
defer os.RemoveAll(tmpDir)
d, err := NewParentDriver(os.Stderr, tmpDir, true, backend)
if err != nil {
t.Fatal(err)
}
info, err := d.Info(context.Background())
if err != nil {
t.Fatal(err)
}
if got := info.Extra["sourceIPTransparentBackend"]; got != backend {
t.Fatalf("expected PortDriverInfo.Extra[sourceIPTransparentBackend]=%q, got %q", backend, got)
}
testsuite.RunTCPTransparent(t, func() port.ParentDriver { return d })
})
}
}

func ensureNFT(t *testing.T) {
t.Helper()
if _, err := exec.LookPath("nft"); err != nil {
t.Skipf("nft not found: %v", err)
}
}
113 changes: 96 additions & 17 deletions pkg/port/builtin/child/child.go

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's keep iptables mode too and test the both in CI

@Akshitguptaa Akshitguptaa Jul 24, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

kept iptables as a fallback.
CI runs test with both nft and iptables, and a separate one with only iptables (no nft) to cover the fallback path.

Original file line number Diff line number Diff line change
Expand Up @@ -29,15 +29,20 @@ func NewDriver(logWriter io.Writer) port.ChildDriver {
}

type childDriver struct {
logWriter io.Writer
sourceIPTransparent bool
routingSetup sync.Once
routingReady bool
routingWarn sync.Once
logWriter io.Writer
sourceIPTransparent bool
sourceIPTransparentBackend string // "auto" (default), "nft", or "iptables"
routingSetup sync.Once
routingReady bool
routingWarn sync.Once
}

func (d *childDriver) RunChildDriver(opaque map[string]string, quit <-chan struct{}, detachedNetNSPath string) error {
d.sourceIPTransparent = opaque[opaquepkg.SourceIPTransparent] == "true"
d.sourceIPTransparentBackend = opaque[opaquepkg.SourceIPTransparentBackend]
if d.sourceIPTransparentBackend == "" {
d.sourceIPTransparentBackend = "auto"
}
socketPath := opaque[opaquepkg.SocketPath]
if socketPath == "" {
return errors.New("socket path not set")
Expand Down Expand Up @@ -207,6 +212,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).
Comment thread
Akshitguptaa marked this conversation as resolved.
//
// Transparent sockets (IP_TRANSPARENT) bind to non-local addresses (the real
// client IP). Response packets to these addresses must be routed locally instead
Expand All @@ -224,17 +232,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"},
Expand All @@ -244,6 +243,86 @@ 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)
}
}
switch d.sourceIPTransparentBackend {
case "nft":
if d.setupTransparentRoutingNFT() {
return true
}
fmt.Fprintf(d.logWriter, "source IP transparent: nft backend was explicitly requested but is unavailable\n")
return false
case "iptables":
return d.setupTransparentRoutingIPTables()
default: // "auto"
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"},
Expand All @@ -253,7 +332,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
Expand Down
7 changes: 4 additions & 3 deletions pkg/port/builtin/opaque/opaque.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
package opaque

const (
SocketPath = "builtin.socketpath"
ChildReadyPipePath = "builtin.readypipepath"
SourceIPTransparent = "builtin.source-ip-transparent"
SocketPath = "builtin.socketpath"
ChildReadyPipePath = "builtin.readypipepath"
SourceIPTransparent = "builtin.source-ip-transparent"
SourceIPTransparentBackend = "builtin.source-ip-transparent-backend"
)
Loading