diff --git a/libpod/define/info.go b/libpod/define/info.go index e03c39289f..2dacf09c92 100644 --- a/libpod/define/info.go +++ b/libpod/define/info.go @@ -53,8 +53,11 @@ type HostInfo struct { // RemoteSocket returns the UNIX domain socket the Podman service is listening on RemoteSocket *RemoteSocket `json:"remoteSocket,omitempty"` // RootlessNetworkCmd returns the default rootless network command (pasta) - RootlessNetworkCmd string `json:"rootlessNetworkCmd"` - RuntimeInfo map[string]any `json:"runtimeInfo,omitempty"` + RootlessNetworkCmd string `json:"rootlessNetworkCmd"` + // RootlessPortForwarder returns the port forwarding mechanism for rootless + // bridge networks: "rootlessport" (default) or "pasta" (experimental) + RootlessPortForwarder string `json:"rootlessPortForwarder"` + RuntimeInfo map[string]any `json:"runtimeInfo,omitempty"` // ServiceIsRemote is true when the podman/libpod service is remote to the client ServiceIsRemote bool `json:"serviceIsRemote"` Security SecurityInfo `json:"security"` diff --git a/libpod/info.go b/libpod/info.go index 8140fb12e0..5aab80902d 100644 --- a/libpod/info.go +++ b/libpod/info.go @@ -108,26 +108,27 @@ func (r *Runtime) hostInfo() (*define.HostInfo, error) { } info := define.HostInfo{ - Arch: runtime.GOARCH, - BuildahVersion: buildah.Version, - DatabaseBackend: r.state.Name(), - Linkmode: linkmode.Linkmode(), - CPUs: runtime.NumCPU(), - CPUUtilization: cpuUtil, - Distribution: hostDistributionInfo, - LogDriver: r.config.Containers.LogDriver, - EventLogger: r.eventer.String(), - FreeLocks: locksFree, - Hostname: host, - Kernel: kv, - MemFree: mi.MemFree, - MemTotal: mi.MemTotal, - NetworkBackend: r.config.Network.NetworkBackend, - NetworkBackendInfo: r.network.NetworkInfo(), - OS: runtime.GOOS, - RootlessNetworkCmd: r.config.Network.DefaultRootlessNetworkCmd, - SwapFree: mi.SwapFree, - SwapTotal: mi.SwapTotal, + Arch: runtime.GOARCH, + BuildahVersion: buildah.Version, + DatabaseBackend: r.state.Name(), + Linkmode: linkmode.Linkmode(), + CPUs: runtime.NumCPU(), + CPUUtilization: cpuUtil, + Distribution: hostDistributionInfo, + LogDriver: r.config.Containers.LogDriver, + EventLogger: r.eventer.String(), + FreeLocks: locksFree, + Hostname: host, + Kernel: kv, + MemFree: mi.MemFree, + MemTotal: mi.MemTotal, + NetworkBackend: r.config.Network.NetworkBackend, + NetworkBackendInfo: r.network.NetworkInfo(), + OS: runtime.GOOS, + RootlessNetworkCmd: r.config.Network.DefaultRootlessNetworkCmd, + RootlessPortForwarder: r.config.Network.RootlessPortForwarder, + SwapFree: mi.SwapFree, + SwapTotal: mi.SwapTotal, } platform := parse.DefaultPlatform() pArr := strings.Split(platform, "/") diff --git a/libpod/networking_common.go b/libpod/networking_common.go index 03bd8d99d0..25d50256d8 100644 --- a/libpod/networking_common.go +++ b/libpod/networking_common.go @@ -110,11 +110,27 @@ func (r *Runtime) teardownNetwork(ctr *Container) error { return err } - if !ctr.config.NetMode.IsPasta() && len(networks) > 0 { - netOpts := ctr.getNetworkOptions(networks) - return r.teardownNetworkBackend(ctr.state.NetNS, netOpts) + if len(networks) == 0 { + return nil } - return nil + + // --net=pasta: per-container pasta cleans up when it exits, nothing to tear down. + if ctr.config.NetMode.IsPasta() { + return nil + } + + // Pasta forwarding mode: remove port forwarding rules (via pesto) before + // netavark tears down bridge/nftables so pasta stops forwarding first. + // Rootlessport mode: no explicit teardown needed (exits with conmon). + if rootless.IsRootless() && ctr.config.NetMode.IsBridge() && len(ctr.config.PortMappings) > 0 && + r.config.Network.RootlessPortForwarder == config.RootlessPortForwarderPasta { + if err := r.teardownRootlessPortMappingViaPesto(ctr); err != nil { + logrus.Warnf("pesto port cleanup failed for container %s: %v", ctr.ID(), err) + } + } + + netOpts := ctr.getNetworkOptions(networks) + return r.teardownNetworkBackend(ctr.state.NetNS, netOpts) } // isBridgeNetMode checks if the given network mode is bridge. @@ -439,7 +455,7 @@ func (c *Container) NetworkDisconnect(nameOrID, netName string, _ bool) error { // Reload ports when there are still connected networks, maybe we removed the network interface with the child ip. // Reloading without connected networks does not make sense, so we can skip this step. - if rootless.IsRootless() && len(networkStatus) > 0 { + if rootless.IsRootless() && c.runtime.config.Network.RootlessPortForwarder == config.RootlessPortForwarderRootlessport && len(networkStatus) > 0 { if err := c.reloadRootlessRLKPortMapping(); err != nil { return err } @@ -595,7 +611,7 @@ func (c *Container) NetworkConnect(nameOrID, netName string, netOpts types.PerNe // The first network needs a port reload to set the correct child ip for the rootlessport process. // Adding a second network does not require a port reload because the child ip is still valid. - if rootless.IsRootless() && len(networks) == 0 { + if rootless.IsRootless() && c.runtime.config.Network.RootlessPortForwarder == config.RootlessPortForwarderRootlessport && len(networks) == 0 { if err := c.reloadRootlessRLKPortMapping(); err != nil { return err } diff --git a/libpod/networking_freebsd.go b/libpod/networking_freebsd.go index e296f996f0..561e91fa1c 100644 --- a/libpod/networking_freebsd.go +++ b/libpod/networking_freebsd.go @@ -224,3 +224,7 @@ func (c *Container) inspectJoinedNetworkNS(_ string) (q types.StatusBlock, retEr func (c *Container) reloadRootlessRLKPortMapping() error { return errors.New("unsupported (*Container).reloadRootlessRLKPortMapping") } + +func (r *Runtime) teardownRootlessPortMappingViaPesto(_ *Container) error { + return errors.New("unsupported teardownRootlessPortMappingViaPesto on FreeBSD") +} diff --git a/libpod/networking_linux.go b/libpod/networking_linux.go index f16b95504c..d764b82965 100644 --- a/libpod/networking_linux.go +++ b/libpod/networking_linux.go @@ -11,6 +11,7 @@ import ( "github.com/sirupsen/logrus" "github.com/vishvananda/netlink" "go.podman.io/common/libnetwork/types" + "go.podman.io/common/pkg/config" "go.podman.io/common/pkg/netns" "go.podman.io/podman/v6/libpod/define" "go.podman.io/podman/v6/pkg/rootless" @@ -59,15 +60,19 @@ func (r *Runtime) configureNetNS(ctr *Container, ctrNS string) (status map[strin } }() - // set up rootless port forwarder when rootless with ports and the network status is empty, - // if this is called from network reload the network status will not be empty and we should - // not set up port because they are still active - if rootless.IsRootless() && len(ctr.config.PortMappings) > 0 && ctr.getNetworkStatus() == nil { - // set up port forwarder for rootless netns - // make sure to fix this in container.handleRestartPolicy() as well - // Important we have to call this after r.setUpNetwork() so that - // we can use the proper netStatus - err = r.setupRootlessPortMappingViaRLK(ctr, ctrNS, netStatus) + // Set up port forwarding for rootless bridge networks. + if rootless.IsRootless() && len(ctr.config.PortMappings) > 0 { + switch r.config.Network.RootlessPortForwarder { + case config.RootlessPortForwarderPasta: + err = r.setupRootlessPortMappingViaPesto(ctr) + case config.RootlessPortForwarderRootlessport, "": + if ctr.getNetworkStatus() == nil { + err = r.setupRootlessPortMappingViaRLK(ctr, ctrNS, netStatus) + } + default: + err = fmt.Errorf("invalid rootless_port_forwarder value %q, must be %q or %q", + r.config.Network.RootlessPortForwarder, config.RootlessPortForwarderRootlessport, config.RootlessPortForwarderPasta) + } } return netStatus, err } diff --git a/libpod/networking_pesto_linux.go b/libpod/networking_pesto_linux.go new file mode 100644 index 0000000000..873647e139 --- /dev/null +++ b/libpod/networking_pesto_linux.go @@ -0,0 +1,52 @@ +//go:build !remote + +// Pesto integration for rootless bridge network port forwarding. +// +// A shared pasta instance in the rootless netns (-c pasta.sock) handles +// host-side port forwarding. On container start/stop, pesto incrementally +// adds or deletes port forwarding rules for that container. Pasta forwards +// via kernel splice (localhost) or TAP (external), preserving source IPs. +// The container sees the real client's address instead of a proxy or bridge +// gateway address. +// +// Container start: +// - netavark sets up bridge + DNAT +// - pesto --add: adds this container's ports to pasta +// +// Container stop: +// - pesto --delete: removes this container's ports from pasta +// - netavark tears down bridge/DNAT + +package libpod + +import ( + "go.podman.io/common/libnetwork/pasta" +) + +func (r *Runtime) pestoSocketPath() string { + info, err := r.network.RootlessNetnsInfo() + if err != nil || info == nil { + return "" + } + return info.PestoSocketPath +} + +// setupRootlessPortMappingViaPesto adds this container's port forwarding +// rules to the shared pasta instance. +func (r *Runtime) setupRootlessPortMappingViaPesto(ctr *Container) error { + ports := ctr.convertPortMappings() + if len(ports) == 0 { + return nil + } + return pasta.PestoAddPorts(r.config, r.pestoSocketPath(), ports) +} + +// teardownRootlessPortMappingViaPesto removes this container's port +// forwarding rules from the shared pasta instance. +func (r *Runtime) teardownRootlessPortMappingViaPesto(ctr *Container) error { + ports := ctr.convertPortMappings() + if len(ports) == 0 { + return nil + } + return pasta.PestoDeletePorts(r.config, r.pestoSocketPath(), ports) +} diff --git a/libpod/oci_conmon_common.go b/libpod/oci_conmon_common.go index 91028d8c35..730e2bded0 100644 --- a/libpod/oci_conmon_common.go +++ b/libpod/oci_conmon_common.go @@ -1188,8 +1188,11 @@ func (r *ConmonOCIRuntime) createOCIContainer(ctr *Container, restoreOptions *Co // process cannot use them. cmd.ExtraFiles = append(cmd.ExtraFiles, ports...) - // For rootless port forwarding, create sync pipe and leak write end to conmon - if rootless.IsRootless() && len(ctr.config.PortMappings) > 0 { + // For rootless port forwarding via rootlessport, create sync pipe and + // leak write end to conmon. Pasta forwarding mode does not use + // rootlessport, so no pipe is needed. + if rootless.IsRootless() && len(ctr.config.PortMappings) > 0 && + ctr.runtime.config.Network.RootlessPortForwarder == config.RootlessPortForwarderRootlessport { ctr.rootlessPortSyncR, ctr.rootlessPortSyncW, err = os.Pipe() if err != nil { return 0, fmt.Errorf("failed to create rootless port sync pipe: %w", err) diff --git a/test/e2e/common_test.go b/test/e2e/common_test.go index 53d8c1d8e7..667c394e1e 100644 --- a/test/e2e/common_test.go +++ b/test/e2e/common_test.go @@ -1629,6 +1629,35 @@ func testPortConnection(port int) { Expect(err).ToNot(HaveOccurred()) } +// startNCContainer starts a detached container running nc (netcat) listening +// on the given port, waits for it to be ready, and returns the container name. +// +//nolint:unused,nolintlint // only called from linux-only test files, unused on freebsd +func (p *PodmanTestIntegration) startNCContainer(name string, listenPort int, extraArgs ...string) string { + GinkgoHelper() + portStr := strconv.Itoa(listenPort) + args := append([]string{"run", "-d", "--name", name}, extraArgs...) + args = append(args, ALPINE, "sh", "-c", "nc -l -n -v -p "+portStr+" 2>&1") + p.PodmanExitCleanly(args...) + p.WaitForContainerLog(name, "listening") + return name +} + +// WaitForContainerLog polls container logs until the given substring appears +// in either stdout or stderr. Fails the test if not found within the timeout. +func (p *PodmanTestIntegration) WaitForContainerLog(ctrName string, substr string) { + GinkgoHelper() + for range 10 { + logs := p.Podman([]string{"logs", ctrName}) + logs.WaitWithDefaultTimeout() + if strings.Contains(logs.ErrorToString(), substr) || strings.Contains(logs.OutputToString(), substr) { + return + } + time.Sleep(500 * time.Millisecond) + } + Fail(fmt.Sprintf("timed out waiting for %q in logs of container %s", substr, ctrName)) +} + func createNetworkName(name string) string { return name + stringid.GenerateRandomID()[:10] } diff --git a/test/e2e/run_networking_test.go b/test/e2e/run_networking_test.go index 7ef3d9ef35..5906bd5adf 100644 --- a/test/e2e/run_networking_test.go +++ b/test/e2e/run_networking_test.go @@ -4,11 +4,15 @@ package integration import ( "fmt" + "io" "math/rand" "net" "os" + "os/exec" + "path/filepath" "strings" "syscall" + "time" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" @@ -1049,6 +1053,198 @@ EXPOSE 2004-2005/tcp`, ALPINE) Expect(session).Should(ExitCleanly()) }) + // configurePortForwarder sets rootless_port_forwarder via + // CONTAINERS_CONF_OVERRIDE for the duration of this test. + // For "pasta": requires rootless and pesto binary, skips otherwise. + // For "rootlessport": no-op (it's the default). + configurePortForwarder := func(forwarder string) { + GinkgoHelper() + if forwarder == "pasta" { + if !isRootless() { + Skip("pasta port forwarding requires rootless") + } + if _, err := exec.LookPath("pesto"); err != nil { + Skip("pesto binary not found (requires passt >= passt-0^20260507.g1afd4ed)") + } + } + conffile := filepath.Join(podmanTest.TempDir, forwarder+"-forwarder.conf") + err := os.WriteFile(conffile, []byte(fmt.Sprintf("[network]\nrootless_port_forwarder=\"%s\"\n", forwarder)), 0o755) + Expect(err).ToNot(HaveOccurred()) + GinkgoT().Setenv("CONTAINERS_CONF_OVERRIDE", conffile) + if IsRemote() { + podmanTest.RestartRemoteService() + } + } + + for _, forwarder := range []string{"rootlessport", "pasta"} { + for i, tc := range []struct { + name string + ipv6 bool + hostIP string + diffPorts bool + }{ + {name: "IPv4"}, + {name: "IPv4 explicit HostIP", hostIP: "127.0.0.1"}, + {name: "IPv4 different ports", diffPorts: true}, + {name: "IPv6", ipv6: true}, + {name: "IPv6 explicit HostIP", ipv6: true, hostIP: "[::1]"}, + {name: "IPv6 different ports", ipv6: true, diffPorts: true}, + } { + It(fmt.Sprintf("podman run bridge source IP %s %s", forwarder, tc.name), func() { + if tc.ipv6 { + SkipIfNotRootless("netavark does not support IPv6 port forwarding") + if forwarder == "rootlessport" { + Skip("rootlessport does not support native IPv6 port forwarding") + } + } + configurePortForwarder(forwarder) + + // Each forwarder+test combination needs a unique subnet + // to avoid collisions when running in parallel. + pastaOffset := 0 + if forwarder == "pasta" { + pastaOffset = 10 + } + var subnet, subnetMatch, connectAddr string + if tc.ipv6 { + subnet = fmt.Sprintf("fd00:%x::/64", 0x42+i+pastaOffset) + subnetMatch = fmt.Sprintf("fd00:%x:", 0x42+i+pastaOffset) + connectAddr = "[::1]" + } else { + subnet = fmt.Sprintf("172.%d.0.0/24", 30+i+pastaOffset) + subnetMatch = fmt.Sprintf(`172\.%d\.`, 30+i+pastaOffset) + connectAddr = "127.0.0.1" + } + + createArgs := []string{"network", "create"} + if tc.ipv6 { + createArgs = append(createArgs, "--ipv6") + } + createArgs = append(createArgs, "--subnet", subnet) + netName := createNetworkName("srcip") + createArgs = append(createArgs, netName) + podmanTest.PodmanExitCleanly(createArgs...) + defer podmanTest.removeNetwork(netName) + + hostPort := GetPort() + ctrPort := hostPort + if tc.diffPorts { + ctrPort = GetPort() + } + portFlag := fmt.Sprintf("%d:%d", hostPort, ctrPort) + if tc.hostIP != "" { + portFlag = fmt.Sprintf("%s:%d:%d", tc.hostIP, hostPort, ctrPort) + } + ctr := podmanTest.startNCContainer( + "srcip-ctr", ctrPort, + "--network", netName, + "-p", portFlag, + ) + + msg := RandomString(20) + sendMessageToAddr(fmt.Sprintf("%s:%d", connectAddr, hostPort), msg) + podmanTest.WaitForContainerLog(ctr, msg) + + logs := podmanTest.PodmanExitCleanly("logs", ctr) + output := logs.OutputToString() + Expect(output).To(MatchRegexp(`connect to .* from`)) + + if forwarder == "rootlessport" { + Expect(output).To(MatchRegexp(`connect to .* from .*` + subnetMatch)) + } else { + Expect(output).ToNot(MatchRegexp(`connect to .* from .*` + subnetMatch)) + } + + podmanTest.PodmanExitCleanly("rm", "-f", ctr) + podmanTest.PodmanExitCleanly("rm", "-f", netName) + }) + } + + It(fmt.Sprintf("podman run bridge network port cleanup on container stop with %s", forwarder), func() { + configurePortForwarder(forwarder) + netName := createNetworkName("cleanup") + podmanTest.PodmanExitCleanly("network", "create", netName) + defer podmanTest.removeNetwork(netName) + + port := GetPort() + podmanTest.PodmanExitCleanly( + "run", "-d", + "--name", "cleanup-ctr", + "--network", netName, + "-p", fmt.Sprintf("127.0.0.1:%d:80", port), + NGINX_IMAGE, + ) + + testPortConnection(port) + podmanTest.PodmanExitCleanly("rm", "-f", "cleanup-ctr") + + podmanTest.PodmanExitCleanly( + "run", "-d", + "--name", "cleanup-ctr2", + "--network", netName, + "-p", fmt.Sprintf("127.0.0.1:%d:80", port), + NGINX_IMAGE, + ) + testPortConnection(port) + + podmanTest.PodmanExitCleanly("rm", "-f", "cleanup-ctr2") + }) + + It(fmt.Sprintf("podman run bridge dual-stack network IPv4 and IPv6 port forwarding with %s", forwarder), func() { + SkipIfNotRootless("netavark does not support IPv6 port forwarding") + configurePortForwarder(forwarder) + + netName := createNetworkName("dual-stack") + podmanTest.PodmanExitCleanly("network", "create", "--ipv6", + "--subnet", "fd00:42::/64", "--subnet", "172.42.0.0/24", netName) + defer podmanTest.removeNetwork(netName) + + port6 := GetPort() + ctr6 := podmanTest.startNCContainer( + "c-ipv6", port6, + "--network", netName, + "-p", fmt.Sprintf("%d:%d", port6, port6), + ) + msg6 := RandomString(20) + sendMessageToAddr(fmt.Sprintf("[::1]:%d", port6), msg6) + podmanTest.WaitForContainerLog(ctr6, msg6) + + port4 := GetPort() + ctr4 := podmanTest.startNCContainer( + "c-ipv4", port4, + "--network", netName, + "-p", fmt.Sprintf("%d:%d", port4, port4), + ) + msg4 := RandomString(20) + sendMessageToAddr(fmt.Sprintf("127.0.0.1:%d", port4), msg4) + podmanTest.WaitForContainerLog(ctr4, msg4) + }) + } + + It("podman run pasta network preserves source IP", func() { + SkipIfNotRootless("pasta network mode is only supported rootless") + port := GetPort() + ctrName := podmanTest.startNCContainer( + "srcip-pasta-ctr", port, + "--net=pasta", + "-p", fmt.Sprintf("%d:%d", port, port), + ) + + msg := RandomString(20) + sendMessageToAddr(fmt.Sprintf("127.0.0.1:%d", port), msg) + podmanTest.WaitForContainerLog(ctrName, msg) + + logs := podmanTest.PodmanExitCleanly("logs", ctrName) + output := logs.OutputToString() + // With --net=pasta, pasta handles port forwarding directly without + // a bridge or netavark. The source IP is the host address (not a + // bridge gateway), confirming pasta's native source preservation. + Expect(output).To(MatchRegexp(`connect to .* from`)) + Expect(output).ToNot(MatchRegexp(`connect to .* from .*127\.0\.0\.`)) + + podmanTest.PodmanExitCleanly("rm", "-f", "srcip-pasta-ctr") + }) + It("Rootless podman run with --net=bridge works and connects to default network", func() { // This is harmless when run as root, so we'll just let it run. ctrName := "testctr" @@ -1465,3 +1661,22 @@ options ndots:1 }) } }) + +// sendMessageToAddr sends a message to the given tcp address (host:port). +func sendMessageToAddr(addr string, message string) { + GinkgoHelper() + conn, err := net.DialTimeout("tcp", addr, 5*time.Second) + Expect(err).ToNot(HaveOccurred(), "should connect to %s", addr) + + tcpConn := conn.(*net.TCPConn) + _, err = tcpConn.Write([]byte(message)) + Expect(err).ToNot(HaveOccurred()) + + err = tcpConn.CloseWrite() + Expect(err).ToNot(HaveOccurred()) + + err = tcpConn.SetReadDeadline(time.Now().Add(5 * time.Second)) + Expect(err).ToNot(HaveOccurred()) + _, _ = io.Copy(io.Discard, tcpConn) + tcpConn.Close() +} diff --git a/test/system/500-networking.bats b/test/system/500-networking.bats index 97e7e6275a..a0aa3a6d55 100644 --- a/test/system/500-networking.bats +++ b/test/system/500-networking.bats @@ -300,9 +300,21 @@ function run_pod_etc_hosts_test(){ run_podman 1 network rm $mynetname } -# CANNOT BE PARALLELIZED due to nft commands -@test "podman network reload" { - skip_if_remote "podman network reload does not have remote support" +# _test_network_reload: run network reload test with the given port forwarder +# $1: port forwarder name ("rootlessport" or "pasta") +function _test_network_reload() { + local forwarder="$1" + + # Set up pasta forwarder config if requested + unset CONTAINERS_CONF_OVERRIDE + if [[ "$forwarder" == "pasta" ]]; then + local conffile=$PODMAN_TMPDIR/pasta-forwarder.conf + cat >$conffile </dev/null || skip "pesto not available" + _test_network_reload pasta } # bats test_tags=ci:parallel @@ -470,9 +499,21 @@ function run_pod_etc_hosts_test(){ run_podman network rm -t 0 -f $netname } -# Test for https://github.com/containers/podman/issues/10052 -# bats test_tags=ci:parallel -@test "podman network connect/disconnect with port forwarding" { +# _test_network_connect_disconnect: Test for https://github.com/containers/podman/issues/10052 +# $1: port forwarder name ("rootlessport" or "pasta") +function _test_network_connect_disconnect() { + local forwarder="$1" + + unset CONTAINERS_CONF_OVERRIDE + if [[ "$forwarder" == "pasta" ]]; then + local conffile=$PODMAN_TMPDIR/pasta-forwarder.conf + cat >$conffile </dev/null || skip "pesto not available" + _test_network_connect_disconnect pasta +} + +# _test_network_after_restart: run network restart test with the given port forwarder +# $1: port forwarder name ("rootlessport" or "pasta") +function _test_network_after_restart() { + local forwarder="$1" + + unset CONTAINERS_CONF_OVERRIDE + if [[ "$forwarder" == "pasta" ]]; then + local conffile=$PODMAN_TMPDIR/pasta-forwarder.conf + cat >$conffile </dev/null || skip "pesto not available" + _test_network_after_restart pasta } # FIXME: random_rfc1918_subnet is not parallel-safe