Skip to content

Commit 8e9f756

Browse files
committed
Add pesto support for dynamic port forwarding via pasta control socket
Fixes: https://redhat.atlassian.net/browse/RUN-2214 Fixes: containers/podman#8193 Fixes: https://redhat.atlassian.net/browse/RUN-3587 Signed-off-by: Jan Rodák <hony.com@seznam.cz>
1 parent 380549a commit 8e9f756

4 files changed

Lines changed: 290 additions & 4 deletions

File tree

common/libnetwork/internal/rootlessnetns/netns_linux.go

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,11 @@ const (
4040
// rootlessNetNsConnPidFile is the name of the rootless netns slirp4netns/pasta pid file.
4141
rootlessNetNsConnPidFile = "rootless-netns-conn.pid"
4242

43+
// pestoSocketFile is the name of the UNIX domain socket file used by
44+
// pesto to communicate with the running pasta instance. Pasta is started
45+
// with "-c <socketPath>" to enable this control channel.
46+
pestoSocketFile = "pasta.sock"
47+
4348
tmpfs = "tmpfs"
4449
none = "none"
4550
resolvConfName = "resolv.conf"
@@ -197,11 +202,12 @@ func (n *Netns) cleanup() error {
197202

198203
func (n *Netns) setupPasta(nsPath string) error {
199204
pidPath := n.getPath(rootlessNetNsConnPidFile)
205+
socketPath := n.getPath(pestoSocketFile)
200206

201207
pastaOpts := pasta.SetupOptions{
202208
Config: n.config,
203209
Netns: nsPath,
204-
ExtraOptions: []string{"--pid", pidPath},
210+
ExtraOptions: []string{"--pid", pidPath, "-c", socketPath},
205211
}
206212
res, err := pasta.Setup(&pastaOpts)
207213
if err != nil {
@@ -235,9 +241,10 @@ func (n *Netns) setupPasta(nsPath string) error {
235241
}
236242

237243
n.info = &types.RootlessNetnsInfo{
238-
IPAddresses: res.IPAddresses,
239-
DnsForwardIps: res.DNSForwardIPs,
240-
MapGuestIps: res.MapGuestAddrIPs,
244+
IPAddresses: res.IPAddresses,
245+
DnsForwardIps: res.DNSForwardIPs,
246+
MapGuestIps: res.MapGuestAddrIPs,
247+
PestoSocketPath: socketPath,
241248
}
242249
if err := n.serializeInfo(); err != nil {
243250
return wrapError("serialize info", err)
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
// Pesto client for dynamic port forwarding on a running pasta instance.
2+
//
3+
// Pesto updates pasta's forwarding table via a UNIX domain socket (-c).
4+
// Used by rootless bridge networking: pesto incrementally adds or deletes
5+
// port forwarding rules for individual containers.
6+
//
7+
// Limitations:
8+
// - IPv4 only (netavark DNAT is IPv4; IPv6 bindings cause RST)
9+
// - TCP and UDP only (SCTP is silently skipped)
10+
11+
package pasta
12+
13+
import (
14+
"errors"
15+
"fmt"
16+
"os/exec"
17+
"strings"
18+
19+
"github.com/sirupsen/logrus"
20+
"go.podman.io/common/libnetwork/types"
21+
"go.podman.io/common/pkg/config"
22+
)
23+
24+
const PestoBinaryName = "pesto"
25+
26+
// PestoAddPorts adds port forwarding rules to the running pasta instance
27+
// via -A/--add. Idempotent: adding already-active ports is a no-op.
28+
func PestoAddPorts(conf *config.Config, socketPath string, ports []types.PortMapping) error {
29+
if socketPath == "" {
30+
return errors.New("pesto control socket not available")
31+
}
32+
logrus.Debugf("pesto: adding %d port mappings", len(ports))
33+
return pestoModifyPorts(conf, socketPath, ports, "--add")
34+
}
35+
36+
// PestoDeletePorts removes port forwarding rules from the running pasta
37+
// instance via -D/--delete.
38+
func PestoDeletePorts(conf *config.Config, socketPath string, ports []types.PortMapping) error {
39+
if socketPath == "" {
40+
return nil
41+
}
42+
logrus.Debugf("pesto: deleting %d port mappings", len(ports))
43+
return pestoModifyPorts(conf, socketPath, ports, "--delete")
44+
}
45+
46+
func pestoModifyPorts(conf *config.Config, socketPath string, ports []types.PortMapping, mode string) error {
47+
pestoPath, err := conf.FindHelperBinary(PestoBinaryName, true)
48+
if err != nil {
49+
return fmt.Errorf("could not find pesto binary: %w", err)
50+
}
51+
52+
pestoArgs := portMappingsToPestoArgs(ports)
53+
args := make([]string, 0, len(pestoArgs)+2) // +2 for mode and socket path
54+
args = append(args, mode)
55+
args = append(args, pestoArgs...)
56+
args = append(args, socketPath)
57+
58+
logrus.Debugf("pesto arguments: %s", strings.Join(args, " "))
59+
60+
out, err := exec.Command(pestoPath, args...).CombinedOutput()
61+
if err != nil {
62+
return fmt.Errorf("pesto failed: %w\noutput: %s", err, string(out))
63+
}
64+
if len(out) > 0 {
65+
logrus.Debugf("pesto output: %s", strings.TrimSpace(string(out)))
66+
}
67+
return nil
68+
}
69+
70+
// portMappingsToPestoArgs converts PortMappings into pesto CLI arguments.
71+
//
72+
// Pesto only forwards traffic from the host into the rootless netns. This
73+
// does NOT perform DNAT to the container. Netavark handles that inside the
74+
// netns. Therefore each mapping uses HostPort as both source and destination
75+
// (e.g. "-t 0.0.0.0/8080") so traffic arrives at the port netavark expects.
76+
func portMappingsToPestoArgs(ports []types.PortMapping) []string {
77+
var args []string
78+
79+
for _, p := range ports {
80+
// Netavark's DNAT rules use "dnat ip to" which only matches IPv4.
81+
// Restrict pesto to the correct address family so pasta doesn't
82+
// accept IPv6 connections that can't be DNAT'd (which causes RST).
83+
addr := "0.0.0.0/"
84+
if p.HostIP != "" {
85+
if strings.Contains(p.HostIP, ":") {
86+
addr = "[" + p.HostIP + "]/"
87+
} else {
88+
addr = p.HostIP + "/"
89+
}
90+
}
91+
92+
for protocol := range strings.SplitSeq(p.Protocol, ",") {
93+
var flag string
94+
switch protocol {
95+
case "tcp":
96+
flag = "-t"
97+
case "udp":
98+
flag = "-u"
99+
default:
100+
logrus.Warnf("pesto: unsupported protocol %q, skipping", protocol)
101+
continue
102+
}
103+
104+
portRange := p.Range
105+
if portRange == 0 {
106+
portRange = 1
107+
}
108+
109+
var arg string
110+
if portRange == 1 {
111+
arg = fmt.Sprintf("%s%d", addr, p.HostPort)
112+
} else {
113+
arg = fmt.Sprintf("%s%d-%d", addr, p.HostPort, p.HostPort+portRange-1)
114+
}
115+
args = append(args, flag, arg)
116+
}
117+
}
118+
119+
return args
120+
}
Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
package pasta
2+
3+
import (
4+
"testing"
5+
6+
"github.com/stretchr/testify/assert"
7+
"go.podman.io/common/libnetwork/types"
8+
)
9+
10+
func Test_portMappingsToPestoArgs(t *testing.T) {
11+
tests := []struct {
12+
name string
13+
ports []types.PortMapping
14+
want []string
15+
}{
16+
{
17+
name: "no ports returns nil",
18+
ports: nil,
19+
want: nil,
20+
},
21+
{
22+
name: "empty slice same as nil",
23+
ports: []types.PortMapping{},
24+
want: nil,
25+
},
26+
{
27+
name: "single tcp port defaults to 0.0.0.0",
28+
ports: []types.PortMapping{
29+
{HostPort: 8080, ContainerPort: 80, Protocol: "tcp", Range: 1},
30+
},
31+
want: []string{"-t", "0.0.0.0/8080"},
32+
},
33+
{
34+
name: "single udp port",
35+
ports: []types.PortMapping{
36+
{HostPort: 53, ContainerPort: 53, Protocol: "udp", Range: 1},
37+
},
38+
want: []string{"-u", "0.0.0.0/53"},
39+
},
40+
{
41+
name: "tcp and udp port",
42+
ports: []types.PortMapping{
43+
{HostPort: 80, ContainerPort: 80, Protocol: "tcp", Range: 1},
44+
{HostPort: 53, ContainerPort: 53, Protocol: "udp", Range: 1},
45+
},
46+
want: []string{"-t", "0.0.0.0/80", "-u", "0.0.0.0/53"},
47+
},
48+
{
49+
name: "dual protocol on single mapping",
50+
ports: []types.PortMapping{
51+
{HostPort: 80, ContainerPort: 80, Protocol: "tcp,udp", Range: 1},
52+
},
53+
want: []string{"-t", "0.0.0.0/80", "-u", "0.0.0.0/80"},
54+
},
55+
{
56+
name: "port range expands to host port range",
57+
ports: []types.PortMapping{
58+
{HostPort: 8000, ContainerPort: 80, Protocol: "tcp", Range: 5},
59+
},
60+
want: []string{"-t", "0.0.0.0/8000-8004"},
61+
},
62+
{
63+
name: "range of zero treated as single port",
64+
ports: []types.PortMapping{
65+
{HostPort: 80, ContainerPort: 80, Protocol: "tcp", Range: 0},
66+
},
67+
want: []string{"-t", "0.0.0.0/80"},
68+
},
69+
{
70+
name: "range of two",
71+
ports: []types.PortMapping{
72+
{HostPort: 3000, ContainerPort: 3000, Protocol: "tcp", Range: 2},
73+
},
74+
want: []string{"-t", "0.0.0.0/3000-3001"},
75+
},
76+
{
77+
name: "explicit IPv4 host IP",
78+
ports: []types.PortMapping{
79+
{HostIP: "127.0.0.1", HostPort: 443, ContainerPort: 443, Protocol: "tcp", Range: 1},
80+
},
81+
want: []string{"-t", "127.0.0.1/443"},
82+
},
83+
{
84+
name: "IPv6 host IP gets brackets",
85+
ports: []types.PortMapping{
86+
{HostIP: "::1", HostPort: 8080, ContainerPort: 80, Protocol: "tcp", Range: 1},
87+
},
88+
want: []string{"-t", "[::1]/8080"},
89+
},
90+
{
91+
name: "full-form IPv6 host IP",
92+
ports: []types.PortMapping{
93+
{HostIP: "fd00::1", HostPort: 80, ContainerPort: 80, Protocol: "udp", Range: 1},
94+
},
95+
want: []string{"-u", "[fd00::1]/80"},
96+
},
97+
{
98+
name: "multiple tcp ports",
99+
ports: []types.PortMapping{
100+
{HostPort: 80, ContainerPort: 80, Protocol: "tcp", Range: 1},
101+
{HostPort: 443, ContainerPort: 443, Protocol: "tcp", Range: 1},
102+
},
103+
want: []string{"-t", "0.0.0.0/80", "-t", "0.0.0.0/443"},
104+
},
105+
{
106+
name: "unsupported protocol is skipped",
107+
ports: []types.PortMapping{
108+
{HostPort: 80, ContainerPort: 80, Protocol: "sctp", Range: 1},
109+
},
110+
want: nil,
111+
},
112+
{
113+
name: "unsupported protocol mixed with valid",
114+
ports: []types.PortMapping{
115+
{HostPort: 80, ContainerPort: 80, Protocol: "tcp", Range: 1},
116+
{HostPort: 90, ContainerPort: 90, Protocol: "sctp", Range: 1},
117+
},
118+
want: []string{"-t", "0.0.0.0/80"},
119+
},
120+
{
121+
name: "explicit host IP on udp",
122+
ports: []types.PortMapping{
123+
{HostIP: "10.0.0.1", HostPort: 3000, ContainerPort: 3000, Protocol: "udp", Range: 1},
124+
},
125+
want: []string{"-u", "10.0.0.1/3000"},
126+
},
127+
{
128+
name: "container port does not appear in args",
129+
ports: []types.PortMapping{
130+
{HostPort: 9090, ContainerPort: 3000, Protocol: "tcp", Range: 1},
131+
},
132+
want: []string{"-t", "0.0.0.0/9090"},
133+
},
134+
{
135+
name: "host IP with range",
136+
ports: []types.PortMapping{
137+
{HostIP: "10.0.0.1", HostPort: 3000, ContainerPort: 3000, Protocol: "udp", Range: 3},
138+
},
139+
want: []string{"-u", "10.0.0.1/3000-3002"},
140+
},
141+
{
142+
name: "range with dual protocol",
143+
ports: []types.PortMapping{
144+
{HostPort: 5000, ContainerPort: 5000, Protocol: "tcp,udp", Range: 3},
145+
},
146+
want: []string{"-t", "0.0.0.0/5000-5002", "-u", "0.0.0.0/5000-5002"},
147+
},
148+
}
149+
150+
for _, tt := range tests {
151+
t.Run(tt.name, func(t *testing.T) {
152+
got := portMappingsToPestoArgs(tt.ports)
153+
assert.Equal(t, tt.want, got)
154+
})
155+
}
156+
}

common/libnetwork/types/network.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -377,6 +377,9 @@ type RootlessNetnsInfo struct {
377377
DnsForwardIps []string
378378
// MapGuestIps should be used for the host.containers.internal entry when set
379379
MapGuestIps []string
380+
// PestoSocketPath is the path to the pasta control socket for dynamic
381+
// port forwarding via pesto. Empty when pasta was started without -c.
382+
PestoSocketPath string
380383
}
381384

382385
// FilterFunc can be passed to NetworkList to filter the networks.

0 commit comments

Comments
 (0)