diff --git a/console.go b/console.go index e8dc862..be48d59 100644 --- a/console.go +++ b/console.go @@ -19,146 +19,26 @@ package runc import ( - "fmt" - "net" - "os" - "path/filepath" - "github.com/containerd/console" - "golang.org/x/sys/unix" ) // NewConsoleSocket creates a new unix socket at the provided path to accept a // pty master created by runc for use by the container func NewConsoleSocket(path string) (*Socket, error) { - abs, err := filepath.Abs(path) - if err != nil { - return nil, err - } - addr, err := net.ResolveUnixAddr("unix", abs) - if err != nil { - return nil, err - } - l, err := net.ListenUnix("unix", addr) - if err != nil { - return nil, err - } - return &Socket{ - l: l, - }, nil + return newSocket(path) } // NewTempConsoleSocket returns a temp console socket for use with a container // On Close(), the socket is deleted func NewTempConsoleSocket() (*Socket, error) { - runtimeDir := os.Getenv("XDG_RUNTIME_DIR") - dir, err := os.MkdirTemp(runtimeDir, "pty") - if err != nil { - return nil, err - } - abs, err := filepath.Abs(filepath.Join(dir, "pty.sock")) - if err != nil { - return nil, err - } - addr, err := net.ResolveUnixAddr("unix", abs) - if err != nil { - return nil, err - } - l, err := net.ListenUnix("unix", addr) - if err != nil { - return nil, err - } - if runtimeDir != "" { - if err := os.Chmod(abs, 0o755|os.ModeSticky); err != nil { - return nil, err - } - } - return &Socket{ - l: l, - rmdir: true, - }, nil -} - -// Socket is a unix socket that accepts the pty master created by runc -type Socket struct { - rmdir bool - l *net.UnixListener -} - -// Path returns the path to the unix socket on disk -func (c *Socket) Path() string { - return c.l.Addr().String() -} - -// recvFd waits for a file descriptor to be sent over the given AF_UNIX -// socket. The file name of the remote file descriptor will be recreated -// locally (it is sent as non-auxiliary data in the same payload). -func recvFd(socket *net.UnixConn) (*os.File, error) { - const MaxNameLen = 4096 - oobSpace := unix.CmsgSpace(4) - - name := make([]byte, MaxNameLen) - oob := make([]byte, oobSpace) - - n, oobn, _, _, err := socket.ReadMsgUnix(name, oob) - if err != nil { - return nil, err - } - - if n >= MaxNameLen || oobn != oobSpace { - return nil, fmt.Errorf("recvfd: incorrect number of bytes read (n=%d oobn=%d)", n, oobn) - } - - // Truncate. - name = name[:n] - oob = oob[:oobn] - - scms, err := unix.ParseSocketControlMessage(oob) - if err != nil { - return nil, err - } - if len(scms) != 1 { - return nil, fmt.Errorf("recvfd: number of SCMs is not 1: %d", len(scms)) - } - scm := scms[0] - - fds, err := unix.ParseUnixRights(&scm) - if err != nil { - return nil, err - } - if len(fds) != 1 { - return nil, fmt.Errorf("recvfd: number of fds is not 1: %d", len(fds)) - } - fd := uintptr(fds[0]) - - return os.NewFile(fd, string(name)), nil + return newTempSocket("pty", "pty.sock") } // ReceiveMaster blocks until the socket receives the pty master func (c *Socket) ReceiveMaster() (console.Console, error) { - conn, err := c.l.Accept() - if err != nil { - return nil, err - } - defer conn.Close() - uc, ok := conn.(*net.UnixConn) - if !ok { - return nil, fmt.Errorf("received connection which was not a unix socket") - } - f, err := recvFd(uc) + f, err := c.receive() if err != nil { return nil, err } return console.ConsoleFromFile(f) } - -// Close closes the unix socket -func (c *Socket) Close() error { - err := c.l.Close() - if c.rmdir { - if rerr := os.RemoveAll(filepath.Dir(c.Path())); err == nil { - err = rerr - } - } - return err -} diff --git a/pidfd.go b/pidfd.go new file mode 100644 index 0000000..2c5d8ce --- /dev/null +++ b/pidfd.go @@ -0,0 +1,54 @@ +//go:build !windows + +/* + Copyright The containerd 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 runc + +import ( + "os" +) + +// NewPidfdSocket creates a new unix socket at the provided path to accept the +// pidfd of the process runc creates. +// +// Requires runc v1.2.0 or newer and a v5.3 or newer kernel. +func NewPidfdSocket(path string) (*Socket, error) { + return newSocket(path) +} + +// NewTempPidfdSocket returns a temp socket to accept the pidfd of the process +// runc creates. On Close(), the socket is deleted. +// +// Requires runc v1.2.0 or newer and a v5.3 or newer kernel. +func NewTempPidfdSocket() (*Socket, error) { + return newTempSocket("pidfd", "pidfd.sock") +} + +// ReceivePidfd blocks until the socket receives the pidfd of the process runc +// created. The pidfd refers to the container's init process for create and run, +// and to the exec'd process for exec. +// +// The pidfd is sent while the process is being set up, which is before `runc +// create` returns and before the container's start fifo is opened, so it is +// safe to receive it before starting the container. +// +// It is the caller's responsibility to close the returned file. The pidfd can +// be used to signal or wait on the process without the pid reuse races that +// come with a raw pid, e.g. with unix.PidfdSendSignal. +func (c *Socket) ReceivePidfd() (*os.File, error) { + return c.receive() +} diff --git a/pidfd_test.go b/pidfd_test.go new file mode 100644 index 0000000..8d257f0 --- /dev/null +++ b/pidfd_test.go @@ -0,0 +1,204 @@ +//go:build linux + +/* + Copyright The containerd 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 runc + +import ( + "errors" + "fmt" + "net" + "os" + "path/filepath" + "strconv" + "strings" + "testing" + + "golang.org/x/sys/unix" +) + +func TestPidfdSocket(t *testing.T) { + t.Run("received pidfd refers to the process it was opened for", func(t *testing.T) { + s, err := NewPidfdSocket(testSocketPath(t)) + if err != nil { + t.Fatal(err) + } + defer s.Close() + + go sendFd(t, s.Path(), "standard", openPidfd(t, os.Getpid())) + + pidfd, err := s.ReceivePidfd() + if err != nil { + t.Fatal(err) + } + defer pidfd.Close() + + if pid := pidfdTargetPid(t, pidfd); pid != os.Getpid() { + t.Fatalf("expected a pidfd for pid %d, got one for %d", os.Getpid(), pid) + } + // Signal 0 is a no-op which only checks the pidfd can be signalled through. + if err := unix.PidfdSendSignal(int(pidfd.Fd()), 0, nil, 0); err != nil { + t.Fatalf("cannot signal through the received pidfd: %v", err) + } + }) + + t.Run("temp socket is removed on close", func(t *testing.T) { + s, err := NewTempPidfdSocket() + if err != nil { + t.Fatal(err) + } + ensureSocketCleanup(t, s, s.Path()) + }) +} + +func TestPidfdSocketArgs(t *testing.T) { + socket, err := NewTempPidfdSocket() + if err != nil { + t.Fatal(err) + } + defer socket.Close() + + t.Run("create passes --pidfd-socket when set", func(t *testing.T) { + o := &CreateOpts{PidfdSocket: socket} + args, err := o.args() + if err != nil { + t.Fatal(err) + } + assertArgs(t, args, []string{"--pidfd-socket", socket.Path()}) + }) + + t.Run("create omits --pidfd-socket when unset", func(t *testing.T) { + o := &CreateOpts{} + args, err := o.args() + if err != nil { + t.Fatal(err) + } + assertArgs(t, args, nil) + }) + + t.Run("exec passes --pidfd-socket when set", func(t *testing.T) { + o := &ExecOpts{PidfdSocket: socket} + args, err := o.args() + if err != nil { + t.Fatal(err) + } + assertArgs(t, args, []string{"--pidfd-socket", socket.Path()}) + }) + + t.Run("exec omits --pidfd-socket when unset", func(t *testing.T) { + o := &ExecOpts{} + args, err := o.args() + if err != nil { + t.Fatal(err) + } + assertArgs(t, args, nil) + }) +} + +func assertArgs(t *testing.T, got, expected []string) { + t.Helper() + if len(got) != len(expected) { + t.Fatalf("expected args %v, got %v", expected, got) + } + for i := range expected { + if got[i] != expected[i] { + t.Fatalf("expected args %v, got %v", expected, got) + } + } +} + +// pidfdTargetPid returns the pid the given pidfd refers to, as reported by +// procfs. +func pidfdTargetPid(t *testing.T, pidfd *os.File) int { + t.Helper() + data, err := os.ReadFile(fmt.Sprintf("/proc/self/fdinfo/%d", pidfd.Fd())) + if err != nil { + t.Fatalf("failed to read fdinfo of the received fd: %v", err) + } + for _, line := range strings.Split(string(data), "\n") { + if !strings.HasPrefix(line, "Pid:") { + continue + } + pid, err := strconv.Atoi(strings.TrimSpace(strings.TrimPrefix(line, "Pid:"))) + if err != nil { + t.Fatalf("failed to parse %q from fdinfo: %v", line, err) + } + return pid + } + t.Fatalf("received fd is not a pidfd, fdinfo:\n%s", data) + return 0 +} + +// testSocketPath returns a path to bind a unix socket to. t.TempDir() is not +// used because it derives the directory name from the test's name, which is +// long enough here to exceed the 108 byte sun_path limit on some versions of +// Go. +func testSocketPath(t *testing.T) string { + t.Helper() + dir, err := os.MkdirTemp("", "gorunc") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { os.RemoveAll(dir) }) + + path := filepath.Join(dir, "sock") + if len(path) >= 108 { + t.Fatalf("socket path %q does not fit sun_path", path) + } + return path +} + +// openPidfd opens a pidfd for pid. Pidfds need a v5.3 or newer kernel, and +// pidfd_open may also be blocked by a sandbox, neither of which the library +// itself requires, so the test is skipped rather than failed in those cases. +func openPidfd(t *testing.T, pid int) int { + t.Helper() + fd, err := unix.PidfdOpen(pid, 0) + if err != nil { + if errors.Is(err, unix.ENOSYS) || errors.Is(err, unix.EPERM) { + t.Skipf("pidfd_open is unavailable: %v", err) + } + t.Fatalf("failed to open a pidfd: %v", err) + } + t.Cleanup(func() { unix.Close(fd) }) + return fd +} + +// sendFd stands in for runc: it connects to the socket and sends fds along with +// name as the payload, the way runc's init process does. "standard" is the name +// runc uses for the pidfd of an init process, "setns" for an exec'd one. +func sendFd(t *testing.T, path, name string, fds ...int) { + conn, err := net.Dial("unix", path) + if err != nil { + t.Error(err) + return + } + defer conn.Close() + + uc, ok := conn.(*net.UnixConn) + if !ok { + t.Error("expected a unix connection") + return + } + var rights []byte + if len(fds) > 0 { + rights = unix.UnixRights(fds...) + } + if _, _, err := uc.WriteMsgUnix([]byte(name), rights, nil); err != nil { + t.Error(err) + } +} diff --git a/runc.go b/runc.go index 61646df..4ab67e0 100644 --- a/runc.go +++ b/runc.go @@ -126,18 +126,28 @@ type ConsoleSocket interface { Path() string } +// PidfdSocket handles the path of the socket which receives the pidfd of the +// process runc creates. +type PidfdSocket interface { + Path() string +} + // CreateOpts holds all the options information for calling runc with supported options type CreateOpts struct { IO // PidFile is a path to where a pid file should be created PidFile string ConsoleSocket ConsoleSocket - Detach bool - NoPivot bool - NoNewKeyring bool - ExtraFiles []*os.File - Started chan<- int - ExtraArgs []string + // PidfdSocket receives a pidfd referencing the container's init process, + // which lets the caller signal or wait on it without racing against pid + // reuse. Requires runc v1.2.0 or newer. + PidfdSocket PidfdSocket + Detach bool + NoPivot bool + NoNewKeyring bool + ExtraFiles []*os.File + Started chan<- int + ExtraArgs []string } func (o *CreateOpts) args() (out []string, err error) { @@ -151,6 +161,9 @@ func (o *CreateOpts) args() (out []string, err error) { if o.ConsoleSocket != nil { out = append(out, "--console-socket", o.ConsoleSocket.Path()) } + if o.PidfdSocket != nil { + out = append(out, "--pidfd-socket", o.PidfdSocket.Path()) + } if o.NoPivot { out = append(out, "--no-pivot") } @@ -230,15 +243,22 @@ type ExecOpts struct { IO PidFile string ConsoleSocket ConsoleSocket - Detach bool - Started chan<- int - ExtraArgs []string + // PidfdSocket receives a pidfd referencing the exec'd process, which lets + // the caller signal or wait on it without racing against pid reuse. + // Requires runc v1.2.0 or newer. + PidfdSocket PidfdSocket + Detach bool + Started chan<- int + ExtraArgs []string } func (o *ExecOpts) args() (out []string, err error) { if o.ConsoleSocket != nil { out = append(out, "--console-socket", o.ConsoleSocket.Path()) } + if o.PidfdSocket != nil { + out = append(out, "--pidfd-socket", o.PidfdSocket.Path()) + } if o.Detach { out = append(out, "--detach") } diff --git a/socket.go b/socket.go new file mode 100644 index 0000000..5b97dd2 --- /dev/null +++ b/socket.go @@ -0,0 +1,152 @@ +//go:build !windows + +/* + Copyright The containerd 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 runc + +import ( + "fmt" + "net" + "os" + "path/filepath" + + "golang.org/x/sys/unix" +) + +// Socket is a unix socket that accepts a file descriptor sent by runc, such as +// the pty master for the container's console or the pidfd of the process runc +// creates. +type Socket struct { + rmdir bool + l *net.UnixListener +} + +// newSocket creates a new unix socket listening at the provided path. +func newSocket(path string) (*Socket, error) { + abs, err := filepath.Abs(path) + if err != nil { + return nil, err + } + addr, err := net.ResolveUnixAddr("unix", abs) + if err != nil { + return nil, err + } + l, err := net.ListenUnix("unix", addr) + if err != nil { + return nil, err + } + return &Socket{ + l: l, + }, nil +} + +// newTempSocket creates a unix socket named name inside a new temp directory +// created with the provided prefix. The directory is removed on Close(). +func newTempSocket(prefix, name string) (*Socket, error) { + runtimeDir := os.Getenv("XDG_RUNTIME_DIR") + dir, err := os.MkdirTemp(runtimeDir, prefix) + if err != nil { + return nil, err + } + s, err := newSocket(filepath.Join(dir, name)) + if err != nil { + os.RemoveAll(dir) + return nil, err + } + s.rmdir = true + if runtimeDir != "" { + if err := os.Chmod(s.Path(), 0o755|os.ModeSticky); err != nil { + s.Close() + return nil, err + } + } + return s, nil +} + +// Path returns the path to the unix socket on disk +func (c *Socket) Path() string { + return c.l.Addr().String() +} + +// Close closes the unix socket +func (c *Socket) Close() error { + err := c.l.Close() + if c.rmdir { + if rerr := os.RemoveAll(filepath.Dir(c.Path())); err == nil { + err = rerr + } + } + return err +} + +// receive blocks until the socket receives a file descriptor +func (c *Socket) receive() (*os.File, error) { + conn, err := c.l.Accept() + if err != nil { + return nil, err + } + defer conn.Close() + uc, ok := conn.(*net.UnixConn) + if !ok { + return nil, fmt.Errorf("received connection which was not a unix socket") + } + return recvFd(uc) +} + +// recvFd waits for a file descriptor to be sent over the given AF_UNIX +// socket. The file name of the remote file descriptor will be recreated +// locally (it is sent as non-auxiliary data in the same payload). +func recvFd(socket *net.UnixConn) (*os.File, error) { + const MaxNameLen = 4096 + oobSpace := unix.CmsgSpace(4) + + name := make([]byte, MaxNameLen) + oob := make([]byte, oobSpace) + + n, oobn, _, _, err := socket.ReadMsgUnix(name, oob) + if err != nil { + return nil, err + } + + if n >= MaxNameLen || oobn != oobSpace { + return nil, fmt.Errorf("recvfd: incorrect number of bytes read (n=%d oobn=%d)", n, oobn) + } + + // Truncate. + name = name[:n] + oob = oob[:oobn] + + scms, err := unix.ParseSocketControlMessage(oob) + if err != nil { + return nil, err + } + if len(scms) != 1 { + return nil, fmt.Errorf("recvfd: number of SCMs is not 1: %d", len(scms)) + } + scm := scms[0] + + fds, err := unix.ParseUnixRights(&scm) + if err != nil { + return nil, err + } + if len(fds) != 1 { + return nil, fmt.Errorf("recvfd: number of fds is not 1: %d", len(fds)) + } + fd := uintptr(fds[0]) + + return os.NewFile(fd, string(name)), nil +}