Merge pull request #6308 from thaJeztah/rootless_devices

OCI: Mount (accessible) host devices in privileged rootless containers
This commit is contained in:
Michael Crosby 2021-12-14 15:21:31 -05:00 committed by GitHub
commit 89437597ac
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
4 changed files with 243 additions and 16 deletions

View File

@ -23,6 +23,7 @@ import (
"os" "os"
"path/filepath" "path/filepath"
"github.com/containerd/containerd/pkg/userns"
specs "github.com/opencontainers/runtime-spec/specs-go" specs "github.com/opencontainers/runtime-spec/specs-go"
"github.com/pkg/errors" "github.com/pkg/errors"
"golang.org/x/sys/unix" "golang.org/x/sys/unix"
@ -31,6 +32,13 @@ import (
// ErrNotADevice denotes that a file is not a valid linux device. // ErrNotADevice denotes that a file is not a valid linux device.
var ErrNotADevice = errors.New("not a device node") var ErrNotADevice = errors.New("not a device node")
// Testing dependencies
var (
osReadDir = os.ReadDir
usernsRunningInUserNS = userns.RunningInUserNS
overrideDeviceFromPath func(path string) error
)
// HostDevices returns all devices that can be found under /dev directory. // HostDevices returns all devices that can be found under /dev directory.
func HostDevices() ([]specs.LinuxDevice, error) { func HostDevices() ([]specs.LinuxDevice, error) {
return getDevices("/dev", "") return getDevices("/dev", "")
@ -53,7 +61,7 @@ func getDevices(path, containerPath string) ([]specs.LinuxDevice, error) {
return []specs.LinuxDevice{*dev}, nil return []specs.LinuxDevice{*dev}, nil
} }
files, err := os.ReadDir(path) files, err := osReadDir(path)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -73,6 +81,12 @@ func getDevices(path, containerPath string) ([]specs.LinuxDevice, error) {
} }
sub, err := getDevices(filepath.Join(path, f.Name()), cp) sub, err := getDevices(filepath.Join(path, f.Name()), cp)
if err != nil { if err != nil {
if errors.Is(err, os.ErrPermission) && usernsRunningInUserNS() {
// ignore the "permission denied" error if running in userns.
// This allows rootless containers to use devices that are
// accessible, ignoring devices / subdirectories that are not.
continue
}
return nil, err return nil, err
} }
@ -81,24 +95,31 @@ func getDevices(path, containerPath string) ([]specs.LinuxDevice, error) {
} }
case f.Name() == "console": case f.Name() == "console":
continue continue
} default:
device, err := DeviceFromPath(filepath.Join(path, f.Name())) device, err := DeviceFromPath(filepath.Join(path, f.Name()))
if err != nil { if err != nil {
if err == ErrNotADevice { if err == ErrNotADevice {
continue
}
if os.IsNotExist(err) {
continue
}
if errors.Is(err, os.ErrPermission) && usernsRunningInUserNS() {
// ignore the "permission denied" error if running in userns.
// This allows rootless containers to use devices that are
// accessible, ignoring devices that are not.
continue
}
return nil, err
}
if device.Type == fifoDevice {
continue continue
} }
if os.IsNotExist(err) { if containerPath != "" {
continue device.Path = filepath.Join(containerPath, filepath.Base(f.Name()))
} }
return nil, err out = append(out, *device)
} }
if device.Type == fifoDevice {
continue
}
if containerPath != "" {
device.Path = filepath.Join(containerPath, filepath.Base(f.Name()))
}
out = append(out, *device)
} }
return out, nil return out, nil
} }
@ -114,6 +135,12 @@ const (
// DeviceFromPath takes the path to a device to look up the information about a // DeviceFromPath takes the path to a device to look up the information about a
// linux device and returns that information as a LinuxDevice struct. // linux device and returns that information as a LinuxDevice struct.
func DeviceFromPath(path string) (*specs.LinuxDevice, error) { func DeviceFromPath(path string) (*specs.LinuxDevice, error) {
if overrideDeviceFromPath != nil {
if err := overrideDeviceFromPath(path); err != nil {
return nil, err
}
}
var stat unix.Stat_t var stat unix.Stat_t
if err := unix.Lstat(path, &stat); err != nil { if err := unix.Lstat(path, &stat); err != nil {
return nil, err return nil, err

View File

@ -0,0 +1,55 @@
//go:build !go1.17 && !windows && !darwin
// +build !go1.17,!windows,!darwin
/*
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 oci
import "io/fs"
// The following code is adapted from go1.17.1/src/io/fs/readdir.go
// to compensate for the lack of fs.FileInfoToDirEntry in Go 1.16.
// dirInfo is a DirEntry based on a FileInfo.
type dirInfo struct {
fileInfo fs.FileInfo
}
func (di dirInfo) IsDir() bool {
return di.fileInfo.IsDir()
}
func (di dirInfo) Type() fs.FileMode {
return di.fileInfo.Mode().Type()
}
func (di dirInfo) Info() (fs.FileInfo, error) {
return di.fileInfo, nil
}
func (di dirInfo) Name() string {
return di.fileInfo.Name()
}
// fileInfoToDirEntry returns a DirEntry that returns information from info.
// If info is nil, FileInfoToDirEntry returns nil.
func fileInfoToDirEntry(info fs.FileInfo) fs.DirEntry {
if info == nil {
return nil
}
return dirInfo{fileInfo: info}
}

View File

@ -0,0 +1,24 @@
//go:build go1.17 && !windows && !darwin
// +build go1.17,!windows,!darwin
/*
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 oci
import "io/fs"
var fileInfoToDirEntry = fs.FileInfoToDirEntry

View File

@ -19,7 +19,128 @@
package oci package oci
import "testing" import (
"errors"
"fmt"
"os"
"testing"
"github.com/stretchr/testify/assert"
"github.com/containerd/containerd/pkg/userns"
)
func cleanupTest() {
overrideDeviceFromPath = nil
osReadDir = os.ReadDir
usernsRunningInUserNS = userns.RunningInUserNS
}
// Based on test from runc:
// https://github.com/opencontainers/runc/blob/v1.0.0/libcontainer/devices/device_unix_test.go#L34-L47
func TestHostDevicesOSReadDirFailure(t *testing.T) {
testError := fmt.Errorf("test error: %w", os.ErrPermission)
// Override os.ReadDir to inject error.
osReadDir = func(dirname string) ([]os.DirEntry, error) {
return nil, testError
}
// Override userns.RunningInUserNS to ensure not running in user namespace.
usernsRunningInUserNS = func() bool {
return false
}
defer cleanupTest()
_, err := HostDevices()
if !errors.Is(err, testError) {
t.Fatalf("Unexpected error %v, expected %v", err, testError)
}
}
// Based on test from runc:
// https://github.com/opencontainers/runc/blob/v1.0.0/libcontainer/devices/device_unix_test.go#L34-L47
func TestHostDevicesOSReadDirFailureInUserNS(t *testing.T) {
testError := fmt.Errorf("test error: %w", os.ErrPermission)
// Override os.ReadDir to inject error.
osReadDir = func(dirname string) ([]os.DirEntry, error) {
if dirname == "/dev" {
fi, err := os.Lstat("/dev/null")
if err != nil {
t.Fatalf("Unexpected error %v", err)
}
return []os.DirEntry{fileInfoToDirEntry(fi)}, nil
}
return nil, testError
}
// Override userns.RunningInUserNS to ensure running in user namespace.
usernsRunningInUserNS = func() bool {
return true
}
defer cleanupTest()
_, err := HostDevices()
if !errors.Is(err, nil) {
t.Fatalf("Unexpected error %v, expected %v", err, nil)
}
}
// Based on test from runc:
// https://github.com/opencontainers/runc/blob/v1.0.0/libcontainer/devices/device_unix_test.go#L49-L74
func TestHostDevicesDeviceFromPathFailure(t *testing.T) {
testError := fmt.Errorf("test error: %w", os.ErrPermission)
// Override DeviceFromPath to produce an os.ErrPermission on /dev/null.
overrideDeviceFromPath = func(path string) error {
if path == "/dev/null" {
return testError
}
return nil
}
// Override userns.RunningInUserNS to ensure not running in user namespace.
usernsRunningInUserNS = func() bool {
return false
}
defer cleanupTest()
d, err := HostDevices()
if !errors.Is(err, testError) {
t.Fatalf("Unexpected error %v, expected %v", err, testError)
}
assert.Equal(t, 0, len(d))
}
// Based on test from runc:
// https://github.com/opencontainers/runc/blob/v1.0.0/libcontainer/devices/device_unix_test.go#L49-L74
func TestHostDevicesDeviceFromPathFailureInUserNS(t *testing.T) {
testError := fmt.Errorf("test error: %w", os.ErrPermission)
// Override DeviceFromPath to produce an os.ErrPermission on all devices,
// except for /dev/null.
overrideDeviceFromPath = func(path string) error {
if path == "/dev/null" {
return nil
}
return testError
}
// Override userns.RunningInUserNS to ensure running in user namespace.
usernsRunningInUserNS = func() bool {
return true
}
defer cleanupTest()
d, err := HostDevices()
if !errors.Is(err, nil) {
t.Fatalf("Unexpected error %v, expected %v", err, nil)
}
assert.Equal(t, 1, len(d))
assert.Equal(t, d[0].Path, "/dev/null")
}
func TestHostDevicesAllValid(t *testing.T) { func TestHostDevicesAllValid(t *testing.T) {
devices, err := HostDevices() devices, err := HostDevices()