Skip to content

Commit 8ddf5d4

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 5e61f82 commit 8ddf5d4

4 files changed

Lines changed: 309 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: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
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 replaces the full table with
5+
// the aggregate ports of all running bridge containers on each change.
6+
//
7+
// Limitations:
8+
// - Full table replacement only (no incremental add/delete yet)
9+
// - IPv4 only (netavark DNAT is IPv4; IPv6 bindings cause RST)
10+
// - TCP and UDP only (SCTP is silently skipped)
11+
// - Brief forwarding gap during replacement with many containers
12+
13+
package pasta
14+
15+
import (
16+
"errors"
17+
"fmt"
18+
"os/exec"
19+
"strings"
20+
21+
"github.com/sirupsen/logrus"
22+
"go.podman.io/common/libnetwork/types"
23+
"go.podman.io/common/pkg/config"
24+
)
25+
26+
// TODO: When pesto gains --add, --clear, --delete flags, switch from full
27+
// table replacement to incremental updates.
28+
29+
const PestoBinaryName = "pesto"
30+
31+
// PestoSetupPorts adds port forwarding rules for a container to the shared
32+
// pasta instance. allPorts must include ports from all bridge containers
33+
// (including the new one) because pesto replaces the entire table.
34+
func PestoSetupPorts(conf *config.Config, socketPath string, allPorts []types.PortMapping) error {
35+
if socketPath == "" {
36+
return errors.New("pesto control socket not available")
37+
}
38+
logrus.Debugf("pesto: setting up port forwarding (%d total port mappings)", len(allPorts))
39+
return pestoReplacePorts(conf, socketPath, allPorts)
40+
}
41+
42+
// PestoTeardownPorts removes a container's port forwarding from the shared
43+
// pasta instance. remainingPorts should include ports from all bridge
44+
// containers EXCEPT the one being torn down.
45+
func PestoTeardownPorts(conf *config.Config, socketPath string, remainingPorts []types.PortMapping) error {
46+
if socketPath == "" {
47+
return nil
48+
}
49+
logrus.Debugf("pesto: tearing down port forwarding (%d remaining port mappings)", len(remainingPorts))
50+
return pestoReplacePorts(conf, socketPath, remainingPorts)
51+
}
52+
53+
// pestoReplacePorts invokes pesto to replace the forwarding table on the
54+
// running pasta instance reachable via socketPath. ports contains the full
55+
// set of port mappings that should be active after the call.
56+
func pestoReplacePorts(conf *config.Config, socketPath string, ports []types.PortMapping) error {
57+
pestoPath, err := conf.FindHelperBinary(PestoBinaryName, true)
58+
if err != nil {
59+
return fmt.Errorf("could not find pesto binary: %w", err)
60+
}
61+
62+
args := portMappingsToPestoArgs(ports)
63+
args = append(args, socketPath)
64+
65+
logrus.Debugf("pesto arguments: %s", strings.Join(args, " "))
66+
67+
out, err := exec.Command(pestoPath, args...).CombinedOutput()
68+
if err != nil {
69+
return fmt.Errorf("pesto failed: %w\noutput: %s", err, string(out))
70+
}
71+
if len(out) > 0 {
72+
logrus.Debugf("pesto output: %s", strings.TrimSpace(string(out)))
73+
}
74+
return nil
75+
}
76+
77+
// portMappingsToPestoArgs converts PortMappings into pesto CLI arguments.
78+
//
79+
// Pesto only forwards traffic from the host into the rootless netns. This
80+
// does NOT perform DNAT to the container. Netavark handles that inside the
81+
// netns. Therefore each mapping uses HostPort as both source and destination
82+
// (e.g. "-t 0.0.0.0/8080") so traffic arrives at the port netavark expects.
83+
func portMappingsToPestoArgs(ports []types.PortMapping) []string {
84+
var args []string
85+
86+
hasTCP := false
87+
hasUDP := false
88+
89+
for _, p := range ports {
90+
// Netavark's DNAT rules use "dnat ip to" which only matches IPv4.
91+
// Restrict pesto to the correct address family so pasta doesn't
92+
// accept IPv6 connections that can't be DNAT'd (which causes RST).
93+
addr := "0.0.0.0/"
94+
if p.HostIP != "" {
95+
if strings.Contains(p.HostIP, ":") {
96+
addr = "[" + p.HostIP + "]/"
97+
} else {
98+
addr = p.HostIP + "/"
99+
}
100+
}
101+
102+
for protocol := range strings.SplitSeq(p.Protocol, ",") {
103+
var flag string
104+
switch protocol {
105+
case "tcp":
106+
flag = "-t"
107+
hasTCP = true
108+
case "udp":
109+
flag = "-u"
110+
hasUDP = true
111+
default:
112+
logrus.Warnf("pesto: unsupported protocol %q, skipping", protocol)
113+
continue
114+
}
115+
116+
portRange := p.Range
117+
if portRange == 0 {
118+
portRange = 1
119+
}
120+
121+
var arg string
122+
if portRange == 1 {
123+
arg = fmt.Sprintf("%s%d", addr, p.HostPort)
124+
} else {
125+
arg = fmt.Sprintf("%s%d-%d", addr, p.HostPort, p.HostPort+portRange-1)
126+
}
127+
args = append(args, flag, arg)
128+
}
129+
}
130+
131+
if !hasTCP {
132+
args = append(args, "-t", "none")
133+
}
134+
if !hasUDP {
135+
args = append(args, "-u", "none")
136+
}
137+
138+
return args
139+
}
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 appends none for both protocols",
18+
ports: nil,
19+
want: []string{"-t", "none", "-u", "none"},
20+
},
21+
{
22+
name: "empty slice same as nil",
23+
ports: []types.PortMapping{},
24+
want: []string{"-t", "none", "-u", "none"},
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", "-u", "none"},
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", "-t", "none"},
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", "-u", "none"},
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", "-u", "none"},
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", "-u", "none"},
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", "-u", "none"},
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", "-u", "none"},
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", "-t", "none"},
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", "-u", "none"},
104+
},
105+
{
106+
name: "unsupported protocol is skipped",
107+
ports: []types.PortMapping{
108+
{HostPort: 80, ContainerPort: 80, Protocol: "sctp", Range: 1},
109+
},
110+
want: []string{"-t", "none", "-u", "none"},
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", "-u", "none"},
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", "-t", "none"},
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", "-u", "none"},
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", "-t", "none"},
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
@@ -361,6 +361,9 @@ type RootlessNetnsInfo struct {
361361
DnsForwardIps []string
362362
// MapGuestIps should be used for the host.containers.internal entry when set
363363
MapGuestIps []string
364+
// PestoSocketPath is the path to the pasta control socket for dynamic
365+
// port forwarding via pesto. Empty when pasta was started without -c.
366+
PestoSocketPath string
364367
}
365368

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

0 commit comments

Comments
 (0)