Add portforward command to runsc

Add portforward comand so that we can use runsc to forward connections
to container ports. This will eventually be supported in k8s.

PiperOrigin-RevId: 520739913
This commit is contained in:
Zach Koopmans
2023-03-30 14:16:19 -07:00
committed by gVisor bot
parent 0101b166b4
commit f92957314c
14 changed files with 574 additions and 13 deletions
+1 -1
View File
@@ -271,7 +271,7 @@ steps:
# See above: not truly a source test.
<<: *source_test
label: ":docker: Docker tests (cgroupv2)"
command: make docker-tests
command: make portforward-tests docker-tests
agents:
<<: *ubuntu_agents
arch: "amd64"
+8 -1
View File
@@ -262,6 +262,13 @@ arm-qemu-smoke-test: $(RUNTIME_BIN) load-arm-qemu
simple-tests: unit-tests # Compatibility target.
.PHONY: simple-tests
portforward-tests: load-basic_redis $(RUNTIME_BIN)
@$(call install_runtime,$(RUNTIME),--network=sandbox)
@$(call sudo,test/root:portforward_test,--runtime=$(RUNTIME) -test.v)
@$(call install_runtime,$(RUNTIME),--network=host)
@$(call sudo,test/root:portforward_test,--runtime=$(RUNTIME) -test.v)
.PHONY: portforward-test
# Standard integration targets.
INTEGRATION_TARGETS := //test/image:image_test //test/e2e:integration_test
@@ -324,6 +331,7 @@ fsstress-test: load-basic $(RUNTIME_BIN)
@$(call test_runtime,$(RUNTIME),//test/fsstress:fsstress_test)
.PHONY: fsstress-test
# Specific containerd version tests.
containerd-test-%: load-basic_alpine load-basic_python load-basic_busybox load-basic_symlink-resolv load-basic_httpd load-basic_ubuntu $(RUNTIME_BIN)
@$(call install_runtime,$(RUNTIME),) # Clear flags.
@@ -335,7 +343,6 @@ else
sudo tar -C "$$(dirname $$(which containerd))" -zxvf - containerd-shim-runsc-v1
endif
@$(call sudo,test/root:root_test,--runtime=$(RUNTIME) -test.v)
containerd-tests-min: containerd-test-1.4.12
##
+1
View File
@@ -0,0 +1 @@
FROM redis:5.0.4
+27 -3
View File
@@ -45,9 +45,8 @@ import (
// client. Container is backed by the offical golang docker API.
// See: https://pkg.go.dev/github.com/docker/docker.
type Container struct {
Name string
runtime string
Name string
runtime string
logger testutil.Logger
client *client.Client
id string
@@ -90,6 +89,11 @@ type RunOpts struct {
// Privileged enables privileged mode.
Privileged bool
// Sets network mode for the container. See container.NetworkMode for types. Several options will
// not work w/ gVisor. For example, you can't set the "sandbox" network option for gVisor using
// this handle.
NetworkMode string
// CapAdd are the extra set of capabilities to add.
CapAdd []string
@@ -270,6 +274,7 @@ func (c *Container) hostConfig(r RunOpts) *container.HostConfig {
CapDrop: r.CapDrop,
Privileged: r.Privileged,
ReadonlyRootfs: r.ReadOnly,
NetworkMode: container.NetworkMode(r.NetworkMode),
Resources: container.Resources{
Memory: int64(r.Memory), // In bytes.
CpusetCpus: r.CpusetCpus,
@@ -341,6 +346,25 @@ func (c *Container) ID() string {
return c.id
}
// RootDirectory returns an educated guess about the container's root directory.
func (c *Container) RootDirectory() (string, error) {
// The root directory of this container's runtime.
rootDir := fmt.Sprintf("/var/run/docker/runtime-%s/moby", c.runtime)
_, err := os.Stat(rootDir)
if err == nil {
return rootDir, nil
}
// In docker v20+, due to https://github.com/moby/moby/issues/42345 the
// rootDir seems to always be the following.
const defaultDir = "/var/run/docker/runtime-runc/moby"
_, derr := os.Stat(defaultDir)
if derr == nil {
return defaultDir, nil
}
return "", fmt.Errorf("cannot stat %q: %v or %q: %v", rootDir, err, defaultDir, derr)
}
// SandboxPid returns the container's pid.
func (c *Container) SandboxPid(ctx context.Context) (int, error) {
resp, err := c.client.ContainerInspect(ctx, c.id)
+3 -6
View File
@@ -81,12 +81,9 @@ func (p *profile) createProcess(c *Container) error {
return fmt.Errorf("failed to get runtime path: %v", err)
}
// The root directory of this container's runtime.
rootDir := fmt.Sprintf("/var/run/docker/runtime-%s/moby", c.runtime)
if _, err := os.Stat(rootDir); os.IsNotExist(err) {
// In docker v20+, due to https://github.com/moby/moby/issues/42345 the
// rootDir seems to always be the following.
rootDir = "/var/run/docker/runtime-runc/moby"
rootDir, err := c.RootDirectory()
if err != nil {
return fmt.Errorf("failed to get root directory: %v", err)
}
// Format is `runsc --root=rootDir debug --profile-*=file --duration=24h containerID`.
+3
View File
@@ -58,6 +58,9 @@ const (
// ContMgrExecuteAsync executes a command in a container.
ContMgrExecuteAsync = "containerManager.ExecuteAsync"
// ContMgrPortForward starts port forwarding with the sandbox.
ContMgrPortForward = "containerManager.PortForward"
// ContMgrProcesses lists processes running in a container.
ContMgrProcesses = "containerManager.Processes"
@@ -48,7 +48,7 @@ type hostInetConn struct {
// NewHostInetConn creates a hostInetConn backed by a host socket on the localhost address.
func NewHostInetConn(port uint16) (proxyConn, error) {
// NOTE: Options must match sandbox seccomp filters. See filter/config.go
fd, err := unix.Socket(unix.AF_INET, unix.SOCK_STREAM|unix.SOCK_NONBLOCK|unix.SOCK_CLOEXEC, 0)
fd, err := unix.Socket(unix.AF_INET, unix.SOCK_STREAM|unix.SOCK_NONBLOCK|unix.SOCK_CLOEXEC, unix.IPPROTO_TCP)
if err != nil {
return nil, err
}
+1
View File
@@ -76,6 +76,7 @@ func Main() {
subcommands.Register(new(cmd.List), "")
subcommands.Register(new(cmd.PS), "")
subcommands.Register(new(cmd.Pause), "")
subcommands.Register(new(cmd.PortForward), "")
subcommands.Register(new(cmd.Restore), "")
subcommands.Register(new(cmd.Resume), "")
subcommands.Register(new(cmd.Run), "")
+2
View File
@@ -33,6 +33,7 @@ go_library(
"path.go",
"pause.go",
"platforms.go",
"portforward.go",
"ps.go",
"read_control.go",
"restore.go",
@@ -70,6 +71,7 @@ go_library(
"//pkg/state/statefile",
"//pkg/sync",
"//pkg/unet",
"//pkg/urpc",
"//runsc/boot",
"//runsc/cmd/util",
"//runsc/config",
+321
View File
@@ -0,0 +1,321 @@
// Copyright 2023 The gVisor Authors.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package cmd
import (
"context"
"fmt"
"io"
"io/ioutil"
"math"
"net"
"os"
"os/signal"
"strconv"
"strings"
"sync"
"syscall"
"github.com/google/subcommands"
"gvisor.dev/gvisor/pkg/log"
"gvisor.dev/gvisor/pkg/urpc"
"gvisor.dev/gvisor/runsc/boot"
"gvisor.dev/gvisor/runsc/cmd/util"
"gvisor.dev/gvisor/runsc/config"
"gvisor.dev/gvisor/runsc/container"
"gvisor.dev/gvisor/runsc/flag"
)
// PortForward implements subcommands.Command for the "portforward" command.
type PortForward struct {
portNum int
stream string
}
// Name implements subcommands.Command.Name.
func (*PortForward) Name() string {
return "port-forward"
}
// Synopsis implements subcommands.Command.Synopsis.
func (*PortForward) Synopsis() string {
return "port forward to a secure container"
}
// Usage implements subcommands.Command.Usage.
func (*PortForward) Usage() string {
return `port-forward CONTAINER_ID [LOCAL_PORT:]REMOTE_PORT - port forward to gvisor container.
Open a local port and forward connections to another port inside the specified
container.
EXAMPLES:
The following will forward connections on local port 8080 to port 80 in the
container named 'nginx':
# runsc port-forward nginx 8080:80
`
}
// SetFlags implements subcommands.Command.SetFlags.
func (p *PortForward) SetFlags(f *flag.FlagSet) {}
// Execute implements subcommands.Command.Execute.
func (p *PortForward) Execute(ctx context.Context, f *flag.FlagSet, args ...any) subcommands.ExitStatus {
conf := args[0].(*config.Config)
// Requires at least the container id and port.
if f.NArg() != 2 {
f.Usage()
return subcommands.ExitUsageError
}
id := f.Arg(0)
portStr := f.Arg(1)
c, err := container.Load(conf.RootDir, container.FullID{ContainerID: id}, container.LoadOpts{})
if err != nil {
util.Fatalf("loading container: %v", err)
}
// Allow forwarding to a local port.
ports := strings.Split(portStr, ":")
if len(ports) != 2 {
util.Fatalf("invalid port string %q", portStr)
}
localPort, err := strconv.Atoi(ports[0])
if err != nil {
util.Fatalf("invalid port string %q: %v", portStr, err)
}
portNum, err := strconv.Atoi(ports[1])
if err != nil {
util.Fatalf("invalid port string %q: %v", portStr, err)
}
if portNum <= 0 || portNum > math.MaxUint16 {
util.Fatalf("invalid port %d: %v", portNum, err)
}
// Start port forwarding with the local port.
var wg sync.WaitGroup
ctx, cancel := context.WithCancel(ctx)
wg.Add(3)
go func(localPort, portNum int) {
defer cancel()
defer wg.Done()
// Print message to local user.
fmt.Printf("Forwarding local port %d to %d...\n", localPort, portNum)
if err := localForward(ctx, c, localPort, uint16(portNum)); err != nil {
log.Warningf("port forwarding: %v", err)
}
}(localPort, portNum)
// Exit port forwarding if the container exits.
go func() {
defer wg.Done()
// Cancel port forwarding after Wait returns regardless of return
// value as err may indicate sandbox has terminated already.
_, _ = c.Wait()
fmt.Printf("Container %q stopped. Exiting...\n", c.ID)
cancel()
}()
// Wait for ^C from the user.
go func() {
defer wg.Done()
sig := waitSignal()
fmt.Printf("Got %v, Exiting...\n", sig)
cancel()
}()
// Wait on a WaitGroup for port forwarding to clean up before exiting.
wg.Wait()
return subcommands.ExitSuccess
}
// localForward starts port forwarding from the given local port.
func localForward(ctx context.Context, c *container.Container, localPort int, containerPort uint16) error {
l, err := net.Listen("tcp", ":"+strconv.Itoa(localPort))
if err != nil {
return err
}
defer l.Close()
var localConnChan = make(chan net.Conn, 1)
var errChan = make(chan error, 1)
go func() {
for {
if ctx.Err() != nil {
return
}
localConn, err := l.Accept()
if err != nil {
errChan <- err
continue
}
localConnChan <- localConn
}
}()
for {
// Exit if the context is done.
select {
case <-ctx.Done():
return ctx.Err()
case err := <-errChan:
if err != nil {
log.Warningf("accepting local connection: %v", err)
}
case localConn := <-localConnChan:
// Dispatch a new goroutine to handle the new connection.
go func() {
defer localConn.Close()
fmt.Println("Forwarding new connection...")
err := portCopy(ctx, c, localConn, containerPort)
if err != nil {
log.Warningf("port forwarding: %v", err)
}
fmt.Println("Finished forwarding connection...")
}()
}
}
}
// portCopy creates a UDS and begins copying data to and from the local
// connection.
func portCopy(ctx context.Context, c *container.Container, localConn net.Conn, port uint16) error {
// Create a new path address for the UDS.
addr, err := tmpUDSAddr()
if err != nil {
return err
}
// Create the UDS and Listen on it.
l, err := net.Listen("unix", addr)
if err != nil {
return err
}
defer l.Close()
// Open the UDS as a File so it can be donated to the sentry.
streamFile, err := openStream(addr)
if err != nil {
return fmt.Errorf("opening uds stream: %v", err)
}
defer streamFile.Close()
// Request port forwarding from the sentry. This request will return
// immediately after port forwarding is started and connection state is
// handled via the UDS from then on.
if err := c.PortForward(&boot.PortForwardOpts{
Port: port,
FilePayload: urpc.FilePayload{Files: []*os.File{streamFile}},
}); err != nil {
return fmt.Errorf("PortForward: %v", err)
}
// We have already opened a single connection on the UDS and passed the
// client end to the sentry. We accept the connection now in order to get
// the other half of the connection.
conn, err := l.Accept()
if err != nil {
return err
}
toErrCh := make(chan error)
fromErrCh := make(chan error)
// Copy data from the local port to the UDS.
go func() {
defer conn.Close()
defer localConn.Close()
log.Debugf("Start copying from %q to %q", localConn.LocalAddr().String(), conn.LocalAddr().String())
_, err := io.Copy(localConn, conn)
log.Debugf("Stopped copying from %q to %q", localConn.LocalAddr().String(), conn.LocalAddr().String())
toErrCh <- err
close(toErrCh)
}()
// Copy data from the UDS to the local port.
go func() {
defer conn.Close()
defer localConn.Close()
log.Debugf("Start copying from %q to %q", conn.LocalAddr().String(), localConn.LocalAddr().String())
_, err := io.Copy(conn, localConn)
log.Debugf("Stopped copying from %q to %q", conn.LocalAddr().String(), localConn.LocalAddr().String())
fromErrCh <- err
close(fromErrCh)
}()
errMap := map[string]error{}
for {
if len(errMap) == 2 {
return nil
}
select {
case e := <-toErrCh:
errMap["toChannel"] = e
case e := <-fromErrCh:
errMap["fromChannel"] = e
case <-ctx.Done():
log.Debugf("Port forwarding connection canceled for %q: %v", localConn.LocalAddr().String(), ctx.Err())
return ctx.Err()
}
}
}
// tmpUDS generates a temporary UDS addr.
func tmpUDSAddr() (string, error) {
tmpFile, err := ioutil.TempFile("", "runsc-port-forward")
if err != nil {
return "", err
}
path := tmpFile.Name()
// Remove the tempfile and just use its name.
os.Remove(path)
return path, nil
}
// openStream opens a UDS as a socket and returns the file descriptor in an
// os.File object.
func openStream(name string) (*os.File, error) {
// The net package will abstract the fd, so we use raw syscalls.
fd, err := syscall.Socket(syscall.AF_UNIX, syscall.SOCK_STREAM, 0)
if err != nil {
return nil, err
}
// We are acting as a client so we will connect to the socket.
if err = syscall.Connect(fd, &syscall.SockaddrUnix{Name: name}); err != nil {
syscall.Close(fd)
return nil, err
}
// Return a File so that we can pass it to the Sentry.
return os.NewFile(uintptr(fd), name), nil
}
// waitSignal waits for SIGINT, SIGQUIT, or SIGTERM from the user.
func waitSignal() os.Signal {
ch := make(chan os.Signal, 2)
signal.Notify(
ch,
syscall.SIGINT,
syscall.SIGQUIT,
syscall.SIGTERM,
)
return <-ch
}
+9
View File
@@ -574,6 +574,15 @@ func (c *Container) Event() (*boot.EventOut, error) {
return event, nil
}
// PortForward starts port forwarding to the container.
func (c *Container) PortForward(opts *boot.PortForwardOpts) error {
if err := c.requireStatus("port forward", Running); err != nil {
return err
}
opts.ContainerID = c.ID
return c.Sandbox.PortForward(opts)
}
// SandboxPid returns the Getpid of the sandbox the container is running in, or -1 if the
// container is not running.
func (c *Container) SandboxPid() int {
+17 -1
View File
@@ -567,6 +567,22 @@ func (s *Sandbox) Event(cid string) (*boot.EventOut, error) {
return &e, nil
}
// PortForward starts port forwarding to the sandbox.
func (s *Sandbox) PortForward(opts *boot.PortForwardOpts) error {
log.Debugf("Requesting port forward for container %q in sandbox %q: %+v", opts.ContainerID, s.ID, opts)
conn, err := s.sandboxConnect()
if err != nil {
return err
}
defer conn.Close()
if err := conn.Call(boot.ContMgrPortForward, opts, nil); err != nil {
return fmt.Errorf("port forwarding to sandbox: %v", err)
}
return nil
}
func (s *Sandbox) sandboxConnect() (*urpc.Client, error) {
log.Debugf("Connecting to sandbox %q", s.ID)
conn, err := client.ConnectTo(s.ControlAddress)
@@ -1322,7 +1338,7 @@ func (s *Sandbox) waitForStopped() error {
return nil
}
// The sandbox process is a child of the current process,
// so we can wait it and collect its zombie.
// so we can wait on it to terminate and collect its zombie.
if _, err := unix.Wait4(int(pid), &s.status, 0, nil); err != nil {
return fmt.Errorf("error waiting the sandbox process: %v", err)
}
+20
View File
@@ -47,3 +47,23 @@ go_test(
"@org_golang_x_sys//unix:go_default_library",
],
)
go_test(
name = "portforward_test",
srcs = ["portforward_test.go"],
pure = True,
tags = [
"local",
"manual",
],
visibility = ["//:sandbox"],
deps = [
"//pkg/cleanup",
"//pkg/test/dockerutil",
"//runsc/config",
"//runsc/flag",
"//runsc/specutils",
"@com_github_syndtr_gocapability//capability:go_default_library",
"@org_golang_x_sync//errgroup:go_default_library",
],
)
+160
View File
@@ -0,0 +1,160 @@
// Copyright 2023 The gVisor Authors.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
// Package portforward_test holds a docker test for port forward. It is separate
// from other root tests so that both hostinet and netstack can be tested in
// one Makefile target.
package portforward_test
import (
"bytes"
"context"
"fmt"
"net"
"os"
"os/exec"
"strings"
"testing"
"github.com/syndtr/gocapability/capability"
"golang.org/x/sync/errgroup"
"gvisor.dev/gvisor/pkg/cleanup"
"gvisor.dev/gvisor/pkg/test/dockerutil"
"gvisor.dev/gvisor/runsc/config"
"gvisor.dev/gvisor/runsc/flag"
"gvisor.dev/gvisor/runsc/specutils"
)
func TestPortForward(t *testing.T) {
ctx := context.Background()
server := dockerutil.MakeContainer(ctx, t)
defer server.CleanUp(ctx)
redisPort := 6379
if err := server.Spawn(ctx, dockerutil.RunOpts{
Image: "basic/redis",
}); err != nil {
t.Fatalf("failed to create redis server: %v", err)
}
localPort, err := getUnusedPort()
if err != nil {
t.Fatalf("failed to pick unused port: %v", err)
}
ctx, cancel := context.WithCancel(ctx)
defer cancel()
pf, err := newPortForwardProcess(ctx, server, localPort, redisPort)
if err != nil {
t.Fatalf("failed to create port forward process: %v", err)
}
var g errgroup.Group
g.Go(func() error {
pf.Wait()
if pf.Error() != nil {
return fmt.Errorf("portforward command: err: %v out: %s", pf.Error(), pf.Output())
}
return nil
})
cu := cleanup.Make(pf.Kill)
defer cu.Clean()
client := dockerutil.MakeNativeContainer(ctx, t)
defer client.CleanUp(ctx)
out, err := client.Run(ctx, dockerutil.RunOpts{
Image: "basic/redis",
NetworkMode: "host",
}, "redis-cli", "--verbose", "-p", fmt.Sprintf("%d", localPort), "-i", "1", "-r", "5", "ping")
if err != nil {
t.Logf("portforward command: err: %v out: %s", pf.Error(), pf.Output())
t.Fatalf("failed to run client: %v out: %s", err, out)
}
if !strings.Contains(out, "PONG") {
t.Logf("portforward command: err: %v out: %s", pf.Error(), pf.Output())
t.Fatalf("could not reach redis server: %s", out)
}
cu.Clean()
if err := g.Wait(); err != nil {
t.Fatalf("failed to kill portforward process: %v", err)
}
}
func getUnusedPort() (int, error) {
l, err := net.Listen("tcp", ":0")
if err != nil {
return 0, err
}
defer l.Close()
return l.Addr().(*net.TCPAddr).Port, nil
}
type portForwardProcess struct {
cmd *exec.Cmd
buf bytes.Buffer
}
func newPortForwardProcess(ctx context.Context, c *dockerutil.Container, localPort, containerPort int) (*portForwardProcess, error) {
rootDir, err := c.RootDirectory()
if err != nil {
return nil, err
}
args := []string{"-root", rootDir, "port-forward", c.ID(), fmt.Sprintf("%d:%d", localPort, containerPort)}
cmd := exec.CommandContext(ctx, specutils.ExePath, args...)
ret := &portForwardProcess{cmd: cmd}
ret.cmd.Stdout = &ret.buf
ret.cmd.Stderr = &ret.buf
if err := cmd.Start(); err != nil {
return nil, err
}
return ret, nil
}
func (p *portForwardProcess) Close() error {
return p.cmd.Wait()
}
func (p *portForwardProcess) Wait() { p.cmd.Wait() }
func (p *portForwardProcess) Kill() { p.cmd.Process.Kill() }
func (p *portForwardProcess) Output() string { return p.buf.String() }
func (p *portForwardProcess) Error() error { return p.cmd.Err }
func TestMain(m *testing.M) {
config.RegisterFlags(flag.CommandLine)
if !flag.CommandLine.Parsed() {
flag.Parse()
}
if !specutils.HasCapabilities(capability.CAP_SYS_ADMIN, capability.CAP_DAC_OVERRIDE) {
fmt.Println("Test requires sysadmin privileges to run. Try again with sudo.")
os.Exit(1)
}
dockerutil.EnsureSupportedDockerVersion()
// Configure exe for tests.
path, err := dockerutil.RuntimePath()
if err != nil {
panic(err.Error())
}
specutils.ExePath = path
os.Exit(m.Run())
}