Skip to content

Commit 7769112

Browse files
committed
feat: support --port-driver=pesto for IPv4
Signed-off-by: Hayato Kiwata <dev@haytok.jp>
1 parent e98a3a3 commit 7769112

11 files changed

Lines changed: 421 additions & 16 deletions

File tree

.github/workflows/main.yaml

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,14 @@ jobs:
181181
run: |
182182
docker run --rm --security-opt seccomp=unconfined --security-opt apparmor=unconfined --device /dev/net/tun \
183183
rootlesskit:test-integration ./benchmark-iperf3-port.sh implicit --net=pasta --detach-netns
184+
- name: "Benchmark: TCP Ports (network driver=pasta, port driver=pesto)"
185+
run: |
186+
docker run --rm --security-opt seccomp=unconfined --security-opt apparmor=unconfined --device /dev/net/tun \
187+
rootlesskit:test-integration ./benchmark-iperf3-port.sh pesto --net=pasta
188+
- name: "Benchmark: TCP Ports (network driver=pasta, port driver=pesto) with detach-netns"
189+
run: |
190+
docker run --rm --security-opt seccomp=unconfined --security-opt apparmor=unconfined --device /dev/net/tun \
191+
rootlesskit:test-integration ./benchmark-iperf3-port.sh pesto --net=pasta --detach-netns
184192
- name: "Benchmark: TCP Ports (network driver=gvisor-tap-vsock, port driver=gvisor-tap-vsock)"
185193
run: |
186194
docker run --rm --security-opt seccomp=unconfined --security-opt apparmor=unconfined --device /dev/net/tun \
@@ -206,6 +214,14 @@ jobs:
206214
run: |
207215
docker run --rm --security-opt seccomp=unconfined --security-opt apparmor=unconfined --device /dev/net/tun \
208216
rootlesskit:test-integration ./benchmark-iperf3-port-udp.sh implicit --net=pasta --detach-netns
217+
- name: "Benchmark: UDP Ports (network driver=pasta, port driver=pesto)"
218+
run: |
219+
docker run --rm --security-opt seccomp=unconfined --security-opt apparmor=unconfined --device /dev/net/tun \
220+
rootlesskit:test-integration ./benchmark-iperf3-port-udp.sh pesto --net=pasta
221+
- name: "Benchmark: UDP Ports (network driver=pasta, port driver=pesto) with detach-netns"
222+
run: |
223+
docker run --rm --security-opt seccomp=unconfined --security-opt apparmor=unconfined --device /dev/net/tun \
224+
rootlesskit:test-integration ./benchmark-iperf3-port-udp.sh pesto --net=pasta --detach-netns
209225
- name: "Benchmark: UDP Ports (port driver=builtin)"
210226
run: |
211227
docker run --rm --security-opt seccomp=unconfined --security-opt apparmor=unconfined --device /dev/net/tun \
@@ -282,6 +298,13 @@ jobs:
282298
docker exec test docker info
283299
docker exec test ./integration-docker.sh
284300
docker rm -f test
301+
- name: "Docker Integration test: net=pasta, port-driver=pesto"
302+
run: |
303+
docker run -d --name test --network custom --privileged -e DOCKERD_ROOTLESS_ROOTLESSKIT_NET=pasta -e DOCKERD_ROOTLESS_ROOTLESSKIT_PORT_DRIVER=pesto rootlesskit:test-integration-docker
304+
sleep 5
305+
docker exec test docker info
306+
docker exec test ./integration-docker.sh
307+
docker rm -f test
285308
- name: "Docker Integration test: net=gvisor-tap-vsock, port-driver=builtin"
286309
run: |
287310
docker run -d --name test --network custom --privileged -e DOCKERD_ROOTLESS_ROOTLESSKIT_NET=gvisor-tap-vsock -e DOCKERD_ROOTLESS_ROOTLESSKIT_PORT_DRIVER=builtin rootlesskit:test-integration-docker

Dockerfile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ ARG UBUNTU_VERSION=24.04
33
ARG SHADOW_VERSION=4.17.4
44
ARG SLIRP4NETNS_VERSION=v1.3.4
55
ARG VPNKIT_VERSION=0.6.0
6-
ARG PASST_VERSION=2026_01_20.386b5f5
6+
ARG PASST_VERSION=2026_07_28.f8df3f1
77
ARG DOCKER_VERSION=29.5.2
88
ARG DOCKER_CHANNEL=stable
99

README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -204,6 +204,7 @@ OPTIONS:
204204
205205
Network [pasta]:
206206
--pasta-binary value path of pasta binary for --net=pasta (default: "pasta")
207+
--pesto-binary value path of pesto binary for --port-driver=pesto (default: "pesto")
207208
208209
Network [slirp4netns]:
209210
--slirp4netns-binary value path of slirp4netns binary for --net=slirp4netns (default: "slirp4netns")
@@ -214,7 +215,7 @@ OPTIONS:
214215
--vpnkit-binary value path of VPNKit binary for --net=vpnkit (default: "vpnkit")
215216
216217
Port:
217-
--port-driver value port driver for non-host network. [none, implicit (for pasta), builtin, slirp4netns, gvisor-tap-vsock(experimental)] (default: "none")
218+
--port-driver value port driver for non-host network. [none, implicit (for pasta), builtin, slirp4netns, gvisor-tap-vsock(experimental), pesto(experimental)] (default: "none")
218219
--publish value, -p value [ --publish value, -p value ] publish ports. e.g. "127.0.0.1:8080:80/tcp"
219220
--source-ip-transparent preserve real client source IP using IP_TRANSPARENT (builtin port driver, TCP only) (default: true)
220221

cmd/rootlesskit/main.go

Lines changed: 35 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import (
2828
"github.com/rootless-containers/rootlesskit/v3/pkg/parent"
2929
"github.com/rootless-containers/rootlesskit/v3/pkg/port/builtin"
3030
gvisortapvsock_port "github.com/rootless-containers/rootlesskit/v3/pkg/port/gvisortapvsock"
31+
pasta_port "github.com/rootless-containers/rootlesskit/v3/pkg/port/pasta"
3132
"github.com/rootless-containers/rootlesskit/v3/pkg/port/portutil"
3233
slirp4netns_port "github.com/rootless-containers/rootlesskit/v3/pkg/port/slirp4netns"
3334
"github.com/rootless-containers/rootlesskit/v3/pkg/systemd/activation"
@@ -108,6 +109,9 @@ See https://rootlesscontaine.rs/getting-started/common/ .
108109
if gvisortapvsock_port.Available {
109110
portDrivers = append(portDrivers, "gvisor-tap-vsock(experimental)")
110111
}
112+
if pasta_port.Available {
113+
portDrivers = append(portDrivers, "pesto(experimental)")
114+
}
111115
portDriversHelp := strings.Join(portDrivers, ", ")
112116

113117
app.Flags = []cli.Flag{
@@ -134,6 +138,11 @@ See https://rootlesscontaine.rs/getting-started/common/ .
134138
Usage: "path of pasta binary for --net=pasta",
135139
Value: "pasta",
136140
}, CategoryPasta),
141+
Categorize(&cli.StringFlag{
142+
Name: "pesto-binary",
143+
Usage: "path of pesto binary for --port-driver=pesto",
144+
Value: "pesto",
145+
}, CategoryPasta),
137146
Categorize(&cli.StringFlag{
138147
Name: "slirp4netns-binary",
139148
Usage: "path of slirp4netns binary for --net=slirp4netns",
@@ -431,6 +440,14 @@ func createParentOpt(clicontext *cli.Context) (parent.Opt, error) {
431440
if clicontext.String("port-driver") == "slirp4netns" {
432441
slirp4netnsAPISocketPath = filepath.Join(opt.StateDir, ".s4nn.sock")
433442
}
443+
portDriver := clicontext.String("port-driver")
444+
if portDriver == "pasta" {
445+
return opt, errors.New(`unknown port driver "pasta": did you mean "pesto"?`)
446+
}
447+
pestoSocketPath := ""
448+
if portDriver == "pesto" {
449+
pestoSocketPath = filepath.Join(opt.StateDir, ".pasta.sock")
450+
}
434451
switch s := clicontext.String("net"); s {
435452
case "host":
436453
// NOP
@@ -471,14 +488,14 @@ func createParentOpt(clicontext *cli.Context) (parent.Opt, error) {
471488
}
472489
var implicitPortForward bool
473490
switch portDriver := clicontext.String("port-driver"); portDriver {
474-
case "none":
491+
case "none", "pesto":
475492
implicitPortForward = false
476493
case "implicit":
477494
implicitPortForward = true
478495
default:
479-
return opt, errors.New("network \"pasta\" requires port driver \"none\" or \"implicit\"")
496+
return opt, errors.New("network \"pasta\" requires port driver \"none\" or \"implicit\" or \"pesto\"")
480497
}
481-
opt.NetworkDriver, err = pasta.NewParentDriver(&logrusDebugWriter{label: "network/pasta"}, binary, mtu, ipnet, ifname, disableHostLoopback, ipv6, implicitPortForward)
498+
opt.NetworkDriver, err = pasta.NewParentDriver(&logrusDebugWriter{label: "network/pasta"}, binary, mtu, ipnet, ifname, pestoSocketPath, disableHostLoopback, ipv6, implicitPortForward)
482499
if err != nil {
483500
return opt, err
484501
}
@@ -610,7 +627,7 @@ func createParentOpt(clicontext *cli.Context) (parent.Opt, error) {
610627
}
611628
case "implicit":
612629
if clicontext.String("net") != "pasta" {
613-
return opt, errors.New("port driver requires pasta network")
630+
return opt, errors.New("port driver \"implicit\" requires --net=pasta")
614631
}
615632
// NOP
616633
case "slirp4netns":
@@ -638,6 +655,18 @@ func createParentOpt(clicontext *cli.Context) (parent.Opt, error) {
638655
if err != nil {
639656
return opt, err
640657
}
658+
case "pesto":
659+
if clicontext.String("net") != "pasta" {
660+
return opt, errors.New("port driver \"pesto\" requires --net=pasta")
661+
}
662+
binary := clicontext.String("pesto-binary")
663+
if _, err := exec.LookPath(binary); err != nil {
664+
return opt, err
665+
}
666+
opt.PortDriver, err = pasta_port.NewParentDriver(&logrusDebugWriter{label: "port/pesto"}, binary, pestoSocketPath)
667+
if err != nil {
668+
return opt, err
669+
}
641670
default:
642671
return opt, fmt.Errorf("unknown port driver: %s", s)
643672
}
@@ -731,6 +760,8 @@ func createChildOpt(clicontext *cli.Context) (child.Opt, error) {
731760
opt.PortDriver = builtin.NewChildDriver(&logrusDebugWriter{label: "port/builtin"})
732761
case "gvisor-tap-vsock":
733762
opt.PortDriver = gvisortapvsock_port.NewChildDriver()
763+
case "pesto":
764+
opt.PortDriver = pasta_port.NewChildDriver()
734765
default:
735766
return opt, fmt.Errorf("unknown port driver: %s", s)
736767
}

docs/network.md

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -147,7 +147,7 @@ If `--disable-host-loopback` is not specified, ports listening on 127.0.0.1 in t
147147
### `--net=pasta` (experimental)
148148

149149
`--net=pasta` (since RootlessKit v2.0, EXPERIMENTAL) uses [pasta (passt)](https://passt.top/passt/).
150-
`--net=pasta` is expected to be used in conjunction with `--port-driver=implicit`.
150+
`--net=pasta` is expected to be used in conjunction with `--port-driver=pesto` or `--port-driver=implicit`.
151151

152152
> **Note**
153153
> `--net=pasta` needs [pasta (passt)](https://passt.top/passt/) `2023_06_25.32660ce` or later.
@@ -170,9 +170,7 @@ Pros:
170170
* Supports ICMP Echo (`ping`) when `/proc/sys/net/ipv4/ping_group_range` is configured
171171
* TCP port forwarding (`--port-driver=implicit`) is very fast
172172
* TCP port forwarding (`--port-driver=implicit`) can retain source IP addresses
173-
174-
Cons:
175-
* Lacks API for explicit port forwarding (`rootlessctl (list-ports|add-ports|remove-ports)`)
173+
* Explicit port forwarding via `rootlessctl (list-ports|add-ports|remove-ports)` is supported with `--port-driver=pesto` (experimental, IPv4-only, requires pasta `2026_05_07.1afd4ed` or later)
176174

177175
The network configuration for pasta is similar to slirp4netns.
178176
As in `--net=slirp4netns`, specifying `--copy-up=/etc` and `--disable-host-loopback` is highly recommended.

docs/port.md

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,13 +9,20 @@ The default value is `none` (do not expose ports).
99
| `slirp4netns` | 8.03 Gbps | Propagated |
1010
| `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.
1111
| `implicit` | 37.6 Gbps | Propagated | Requires `pasta` network
12+
| `pesto` (Experimental) | TBD | Propagated | Requires `pasta` network and passt `2026_05_07.1afd4ed` or later. Supports explicit port management through `rootlessctl`.
1213
| `gvisor-tap-vsock` (Experimental) | 3.83 Gbps | Not propagated | Throughput is currently limited; see issue link below for improvement ideas.
1314

1415
Benchmark: iperf3 from the parent to the child is measured on GitHub Actions ([Apr 10, 2026](https://github.com/rootless-containers/rootlesskit/actions/runs/24200485791/job/70642399211))
1516

1617
The `builtin` driver is fast and should be the best choice for most use cases.
1718

18-
For [`pasta`](./network.md) networks, the `implicit` port driver is the best choice.
19+
For [`pasta`](./network.md) networks, choose the port driver based on how
20+
ports should be exposed:
21+
22+
* Use `pesto` to expose only explicitly published ports using `rootlessctl list-ports`, `add-ports`, and `remove-ports`.
23+
* Use `implicit` to automatically expose ports listened on in the network namespace. This may expose ports that were not intended to be reachable from the host.
24+
25+
The `pesto` port driver is experimental and currently supports IPv4 port forwarding only. Both the `pasta` and `pesto` executables must be installed.
1926

2027
> [!NOTE]
2128
> The `gvisor-tap-vsock` port driver is experimental.

hack/benchmark-iperf3-port.sh

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ function benchmark::iperf3::port() {
1919
portid=$($rootlessctl add-ports 127.0.0.1:5201:5201/tcp)
2020
$rootlessctl list-ports
2121
fi
22+
# wait for the port to be bound
23+
sleep 3
2224
$IPERF3C 127.0.0.1
2325
if [ $portdriver != "implicit" ]; then
2426
$rootlessctl remove-ports $portid

hack/integration-port.sh

Lines changed: 138 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
#!/bin/bash
22
source $(realpath $(dirname $0))/common.inc.sh
33

4+
ROOTLESSCTL="rootlessctl"
5+
46
# test_port PORT_DRIVER CURL_URL EXPECTATION [ROOTLESSKIT ARGS...]
57
function test_port() {
68
args="$@"
@@ -12,19 +14,30 @@ function test_port() {
1214
shift
1315
rootlesskit_args="$@"
1416
INFO "Testing port_driver=\"${port_driver}\" curl_url=\"${curl_url}\" expectation=\"${expectation}\" rootlesskit_args=\"${rootlesskit_args}\""
17+
net=slirp4netns
18+
if [ "${port_driver}" = "pesto" ]; then
19+
# The pesto port driver is specific to the pasta network driver
20+
net=pasta
21+
fi
1522
tmp=$(mktemp -d)
1623
state_dir=${tmp}/state
1724
html_dir=${tmp}/html
1825
mkdir -p ${html_dir}
1926
echo "test_port ($args)" >${html_dir}/index.html
27+
httpd="busybox httpd -f -v -p 80 -h ${html_dir}"
28+
if echo "${rootlesskit_args}" | grep -q -- --detach-netns; then
29+
# With --detach-netns, the child command runs in the host's network
30+
# namespace, so the server has to enter the detached netns explicitly.
31+
httpd="nsenter -n${state_dir}/netns ${httpd}"
32+
fi
2033
$ROOTLESSKIT \
2134
--state-dir=${state_dir} \
22-
--net=slirp4netns \
35+
--net=${net} \
2336
--disable-host-loopback \
2437
--copy-up=/etc \
2538
--port-driver=${port_driver} \
2639
${rootlesskit_args} \
27-
busybox httpd -f -v -p 80 -h ${html_dir} \
40+
${httpd} \
2841
2>&1 &
2942
pid=$!
3043
sleep 1
@@ -49,8 +62,108 @@ function test_port() {
4962
fi
5063

5164
INFO "Test pasing, stopping httpd (\"exit status 255\" is negligible here)"
52-
kill -SIGTERM $(cat ${state_dir}/child_pid)
65+
# child_pid does not exist when rootlesskit itself was expected to fail on startup
66+
if [ -f ${state_dir}/child_pid ]; then
67+
kill -SIGTERM $(cat ${state_dir}/child_pid) || true
68+
fi
5369
wait $pid >/dev/null 2>&1 || true
70+
wait_for_pasta_exit
71+
rm -rf $tmp
72+
}
73+
74+
function wait_for_pasta_exit() {
75+
for i in $(seq 50); do
76+
pgrep -x pasta >/dev/null || break
77+
sleep 0.1
78+
done
79+
}
80+
81+
# test_pesto_dynamic [ROOTLESSKIT ARGS...]
82+
function test_pesto_dynamic() {
83+
rootlesskit_args="$@"
84+
INFO "Testing dynamic port management (rootlesskit_args=\"${rootlesskit_args}\")"
85+
tmp=$(mktemp -d)
86+
state_dir=${tmp}/state
87+
html_dir=${tmp}/html
88+
mkdir -p ${html_dir}
89+
echo "test_pesto_dynamic" >${html_dir}/index.html
90+
httpd="busybox httpd -f -v -p 80 -h ${html_dir}"
91+
if echo "${rootlesskit_args}" | grep -q -- --detach-netns; then
92+
# With --detach-netns, the child command runs in the host's network
93+
# namespace, so the server has to enter the detached netns explicitly.
94+
httpd="nsenter -n${state_dir}/netns ${httpd}"
95+
fi
96+
$ROOTLESSKIT \
97+
--state-dir=${state_dir} \
98+
--net=pasta \
99+
--disable-host-loopback \
100+
--copy-up=/etc \
101+
--port-driver=pesto \
102+
${rootlesskit_args} \
103+
${httpd} \
104+
2>&1 &
105+
pid=$!
106+
sleep 1
107+
api_sock=${state_dir}/api.sock
108+
109+
INFO "= the port must not be reachable before add-ports ="
110+
if curl -fsSL http://127.0.0.1:8080; then
111+
ERROR "curl should not success before add-ports"
112+
exit 1
113+
fi
114+
115+
INFO "= add-ports, then the port must be reachable ="
116+
id=$($ROOTLESSCTL --socket=${api_sock} add-ports 127.0.0.1:8080:80/tcp)
117+
curl -fsSL http://127.0.0.1:8080
118+
$ROOTLESSCTL --socket=${api_sock} list-ports --json | grep -q '"parentPort":8080'
119+
120+
INFO "= adding a conflicting port must fail ="
121+
if $ROOTLESSCTL --socket=${api_sock} add-ports 127.0.0.1:8080:80/tcp; then
122+
ERROR "add-ports should fail for a conflicting port"
123+
exit 1
124+
fi
125+
126+
INFO "= ChildIP matching the namespace address (10.0.2.100) is accepted ="
127+
# 10.0.2.100 is the first address of the default CIDR (10.0.2.0/24) + 100
128+
$ROOTLESSCTL --socket=${api_sock} add-ports 127.0.0.1:8081:10.0.2.100:80/tcp
129+
curl -fsSL http://127.0.0.1:8081
130+
131+
INFO "= any other ChildIP must be rejected (pasta cannot honor it) ="
132+
if $ROOTLESSCTL --socket=${api_sock} add-ports 127.0.0.1:8083:10.9.9.9:80/tcp; then
133+
ERROR "add-ports should fail for an unsupported ChildIP"
134+
exit 1
135+
fi
136+
137+
INFO "= unsupported proto (tcp6) must be rejected ="
138+
if $ROOTLESSCTL --socket=${api_sock} add-ports :8082:80/tcp6; then
139+
ERROR "add-ports should fail for tcp6"
140+
exit 1
141+
fi
142+
143+
INFO "= udp rules can be added and removed ="
144+
udp_id=$($ROOTLESSCTL --socket=${api_sock} add-ports 127.0.0.1:5353:5353/udp)
145+
$ROOTLESSCTL --socket=${api_sock} list-ports --json | grep -q '"proto":"udp"'
146+
$ROOTLESSCTL --socket=${api_sock} remove-ports ${udp_id}
147+
148+
INFO "= remove-ports, then new connections must be refused ="
149+
$ROOTLESSCTL --socket=${api_sock} remove-ports ${id}
150+
if curl -fsSL http://127.0.0.1:8080; then
151+
ERROR "curl should not success after remove-ports"
152+
exit 1
153+
fi
154+
155+
INFO "= removing an unknown port ID must fail ="
156+
if $ROOTLESSCTL --socket=${api_sock} remove-ports ${id}; then
157+
ERROR "remove-ports should fail for an unknown ID"
158+
exit 1
159+
fi
160+
161+
INFO "Test passing, stopping httpd (\"exit status 255\" is negligible here)"
162+
if [ -f ${state_dir}/child_pid ]; then
163+
kill -SIGTERM $(cat ${state_dir}/child_pid) || true
164+
fi
165+
wait $pid >/dev/null 2>&1 || true
166+
wait_for_pasta_exit
54167
rm -rf $tmp
55168
}
56169

@@ -89,4 +202,26 @@ test_port slirp4netns http://127.0.0.1:8080 "should success" -p 0.0.0.0:8080:80/
89202
INFO "=== protocol \"tcp4\" is strictly v4-only ==="
90203
test_port slirp4netns http://[::1]:8080 "should fail" -p 0.0.0.0:8080:80/tcp4
91204

205+
INFO "===== Port driver: pesto (--net=pasta) ====="
206+
INFO "=== static publishing via -p ==="
207+
test_port pesto http://127.0.0.1:8080 "should success" -p 0.0.0.0:8080:80/tcp
208+
test_port pesto http://127.0.0.1:8080 "should success" -p 127.0.0.1:8080:80/tcp
209+
test_port pesto http://127.0.0.1:8080 "should success" -p 0.0.0.0:8080:80/tcp4
210+
211+
INFO "=== port translation (8080 -> 80) ==="
212+
test_port pesto http://127.0.0.1:8080 "should success" -p :8080:80/tcp
213+
214+
INFO "=== a port that is not published must not be reachable ==="
215+
test_port pesto http://127.0.0.1:9090 "should fail" -p 0.0.0.0:8080:80/tcp
216+
217+
INFO "=== unsupported proto (tcp6) makes the startup fail ==="
218+
test_port pesto http://[::1]:8080 "should fail" -p [::]:8080:80/tcp6
219+
220+
INFO "=== dynamic port management via rootlessctl ==="
221+
test_pesto_dynamic
222+
223+
INFO "=== with --detach-netns ==="
224+
test_port pesto http://127.0.0.1:8080 "should success" -p 0.0.0.0:8080:80/tcp --detach-netns
225+
test_pesto_dynamic --detach-netns
226+
92227
INFO "===== PASSING ====="

0 commit comments

Comments
 (0)