feat: extend the extension service spec with container security options

We extend the extension service spec with three security options,
WithWriteableSysfs, WithMaskedPaths, WithReadonlyPaths

Fixes #5411

Signed-off-by: Philipp Sauter <philipp.sauter@siderolabs.com>
This commit is contained in:
Philipp Sauter 2022-05-12 11:01:42 +02:00
parent 850cfba72f
commit f2d89735fd
No known key found for this signature in database
GPG Key ID: D3F8AF32D62A348D
8 changed files with 464 additions and 11 deletions

2
go.mod
View File

@ -178,6 +178,7 @@ require (
github.com/gogo/googleapis v1.4.0 // indirect
github.com/gogo/protobuf v1.3.2 // indirect
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
github.com/golang/mock v1.6.0 // indirect
github.com/golang/protobuf v1.5.2 // indirect
github.com/google/btree v1.0.1 // indirect
github.com/google/gnostic v0.5.7-v3refs // indirect
@ -247,6 +248,7 @@ require (
github.com/spf13/cast v1.4.1 // indirect
github.com/spf13/jwalterweatherman v1.1.0 // indirect
github.com/spf13/viper v1.10.0 // indirect
github.com/stretchr/objx v0.2.0 // indirect
github.com/subosito/gotenv v1.2.0 // indirect
github.com/u-root/uio v0.0.0-20210528114334-82958018845c // indirect
github.com/ulikunitz/xz v0.5.8 // indirect

1
go.sum
View File

@ -528,6 +528,7 @@ github.com/golang/mock v1.4.1/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt
github.com/golang/mock v1.4.3/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw=
github.com/golang/mock v1.4.4/go.mod h1:l3mdAwkq5BuhzHwde/uurv3sEJeZMXNpwsxVWU71h+4=
github.com/golang/mock v1.5.0/go.mod h1:CWnOUgYIOo4TcNZ0wHX3YZCqsaM1I1Jvs6v3mP3KVu8=
github.com/golang/mock v1.6.0 h1:ErTB+efbowRARo13NNdxyJji2egdxLGQhRaY+DUumQc=
github.com/golang/mock v1.6.0/go.mod h1:p6yTPP+5HYm5mzsMV8JkE6ZKdX+/wYM6Hr+LicevLPs=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=

View File

@ -0,0 +1,12 @@
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at http://mozilla.org/MPL/2.0/.
package services
import "github.com/containerd/containerd/oci"
// GetOCIOptions gets all OCI options from an Extension.
func (svc *Extension) GetOCIOptions() []oci.SpecOpts {
return svc.getOCIOptions()
}

View File

@ -95,6 +95,37 @@ func (svc *Extension) DependsOn(r runtime.Runtime) []string {
return deps
}
func (svc *Extension) getOCIOptions() []oci.SpecOpts {
ociOpts := []oci.SpecOpts{
oci.WithRootFSPath(filepath.Join(constants.ExtensionServicesRootfsPath, svc.Spec.Name)),
oci.WithCgroup(constants.CgroupExtensions),
oci.WithMounts(svc.Spec.Container.Mounts),
oci.WithHostNamespace(specs.NetworkNamespace),
oci.WithSelinuxLabel(""),
oci.WithApparmorProfile(""),
oci.WithCapabilities(capability.AllGrantableCapabilities()),
oci.WithAllDevicesAllowed,
}
if !svc.Spec.Container.Security.WriteableRootfs {
ociOpts = append(ociOpts, oci.WithRootFSReadonly())
}
if svc.Spec.Container.Security.WriteableSysfs {
ociOpts = append(ociOpts, oci.WithWriteableSysfs)
}
if svc.Spec.Container.Security.MaskedPaths != nil {
ociOpts = append(ociOpts, oci.WithMaskedPaths(svc.Spec.Container.Security.MaskedPaths))
}
if svc.Spec.Container.Security.ReadonlyPaths != nil {
ociOpts = append(ociOpts, oci.WithReadonlyPaths(svc.Spec.Container.Security.ReadonlyPaths))
}
return ociOpts
}
// Runner implements the Service interface.
func (svc *Extension) Runner(r runtime.Runtime) (runner.Runner, error) {
args := runner.Args{
@ -138,17 +169,7 @@ func (svc *Extension) Runner(r runtime.Runtime) (runner.Runner, error) {
runner.WithNamespace(constants.SystemContainerdNamespace),
runner.WithContainerdAddress(constants.SystemContainerdAddress),
runner.WithEnv(env),
runner.WithOCISpecOpts(
oci.WithRootFSPath(filepath.Join(constants.ExtensionServicesRootfsPath, svc.Spec.Name)),
oci.WithRootFSReadonly(),
oci.WithCgroup(constants.CgroupExtensions),
oci.WithMounts(svc.Spec.Container.Mounts),
oci.WithHostNamespace(specs.NetworkNamespace),
oci.WithSelinuxLabel(""),
oci.WithApparmorProfile(""),
oci.WithCapabilities(capability.AllGrantableCapabilities()),
oci.WithAllDevicesAllowed,
),
runner.WithOCISpecOpts(svc.getOCIOptions()...),
runner.WithOOMScoreAdj(-600),
),
restart.WithType(restartType),

View File

@ -0,0 +1,139 @@
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at http://mozilla.org/MPL/2.0/.
package services_test
import (
"context"
"testing"
"github.com/containerd/containerd/containers"
"github.com/containerd/containerd/namespaces"
"github.com/containerd/containerd/oci"
"github.com/containerd/containerd/snapshots"
"github.com/golang/mock/gomock"
"github.com/stretchr/testify/assert"
"github.com/talos-systems/talos/internal/app/machined/pkg/system/services"
"github.com/talos-systems/talos/internal/app/machined/pkg/system/services/mocks"
extservices "github.com/talos-systems/talos/pkg/machinery/extensions/services"
)
type MockClient struct {
controller *gomock.Controller
}
func (c *MockClient) SnapshotService(snapshotterName string) snapshots.Snapshotter {
return mocks.NewMockSnapshotter(c.controller)
}
func TestGetOCIOptions(t *testing.T) {
mockClient := MockClient{
controller: gomock.NewController(t),
}
defer mockClient.controller.Finish()
generateOCISpec := func(svc *services.Extension) (*oci.Spec, error) {
return oci.GenerateSpec(namespaces.WithNamespace(context.Background(), "testNamespace"), &mockClient, &containers.Container{}, svc.GetOCIOptions()...)
}
t.Run("default configurations are cleared away if user passes empty arrays for MaskedPaths and ReadonlyPaths", func(t *testing.T) {
// given
svc := &services.Extension{
Spec: &extservices.Spec{
Container: extservices.Container{
Security: extservices.Security{
MaskedPaths: []string{},
ReadonlyPaths: []string{},
},
},
},
}
// when
spec, err := generateOCISpec(svc)
// then
assert.NoError(t, err)
assert.Equal(t, []string{}, spec.Linux.MaskedPaths)
assert.Equal(t, []string{}, spec.Linux.ReadonlyPaths)
})
t.Run("default configuration applies if user passes nil for MaskedPaths and ReadonlyPaths", func(t *testing.T) {
// given
svc := &services.Extension{
Spec: &extservices.Spec{
Container: extservices.Container{
Security: extservices.Security{
MaskedPaths: nil,
ReadonlyPaths: nil,
},
},
},
}
// when
spec, err := generateOCISpec(svc)
// then
assert.NoError(t, err)
assert.Equal(t, []string{
"/proc/acpi",
"/proc/asound",
"/proc/kcore",
"/proc/keys",
"/proc/latency_stats",
"/proc/timer_list",
"/proc/timer_stats",
"/proc/sched_debug",
"/sys/firmware",
"/proc/scsi",
}, spec.Linux.MaskedPaths)
assert.Equal(t, []string{
"/proc/bus",
"/proc/fs",
"/proc/irq",
"/proc/sys",
"/proc/sysrq-trigger",
}, spec.Linux.ReadonlyPaths)
})
t.Run("root fs is readonly unless explicitly enabled", func(t *testing.T) {
// given
svc := &services.Extension{
Spec: &extservices.Spec{
Container: extservices.Container{
Security: extservices.Security{
WriteableRootfs: true,
},
},
},
}
// when
spec, err := generateOCISpec(svc)
// then
assert.NoError(t, err)
assert.Equal(t, false, spec.Root.Readonly)
})
t.Run("root fs is readonly by default", func(t *testing.T) {
// given
svc := &services.Extension{
Spec: &extservices.Spec{
Container: extservices.Container{
Security: extservices.Security{},
},
},
}
// when
spec, err := generateOCISpec(svc)
// then
assert.NoError(t, err)
assert.Equal(t, true, spec.Root.Readonly)
})
}

View File

@ -0,0 +1,249 @@
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at http://mozilla.org/MPL/2.0/.
// Code generated by MockGen. DO NOT EDIT.
// Source: ~/go/pkg/mod/github.com/containerd/containerd@v1.6.4/snapshots/snapshotter.go
// Package mocks is a generated GoMock package.
package mocks
import (
context "context"
reflect "reflect"
mount "github.com/containerd/containerd/mount"
snapshots "github.com/containerd/containerd/snapshots"
gomock "github.com/golang/mock/gomock"
)
// MockSnapshotter is a mock of Snapshotter interface.
type MockSnapshotter struct {
ctrl *gomock.Controller
recorder *MockSnapshotterMockRecorder
}
// MockSnapshotterMockRecorder is the mock recorder for MockSnapshotter.
type MockSnapshotterMockRecorder struct {
mock *MockSnapshotter
}
// NewMockSnapshotter creates a new mock instance.
func NewMockSnapshotter(ctrl *gomock.Controller) *MockSnapshotter {
mock := &MockSnapshotter{ctrl: ctrl}
mock.recorder = &MockSnapshotterMockRecorder{mock}
return mock
}
// EXPECT returns an object that allows the caller to indicate expected use.
func (m *MockSnapshotter) EXPECT() *MockSnapshotterMockRecorder {
return m.recorder
}
// Close mocks base method.
func (m *MockSnapshotter) Close() error {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "Close")
ret0, _ := ret[0].(error)
return ret0
}
// Close indicates an expected call of Close.
func (mr *MockSnapshotterMockRecorder) Close() *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Close", reflect.TypeOf((*MockSnapshotter)(nil).Close))
}
// Commit mocks base method.
func (m *MockSnapshotter) Commit(ctx context.Context, name, key string, opts ...snapshots.Opt) error {
m.ctrl.T.Helper()
varargs := []interface{}{ctx, name, key}
for _, a := range opts {
varargs = append(varargs, a)
}
ret := m.ctrl.Call(m, "Commit", varargs...)
ret0, _ := ret[0].(error)
return ret0
}
// Commit indicates an expected call of Commit.
func (mr *MockSnapshotterMockRecorder) Commit(ctx, name, key interface{}, opts ...interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
varargs := append([]interface{}{ctx, name, key}, opts...)
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Commit", reflect.TypeOf((*MockSnapshotter)(nil).Commit), varargs...)
}
// Mounts mocks base method.
func (m *MockSnapshotter) Mounts(ctx context.Context, key string) ([]mount.Mount, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "Mounts", ctx, key)
ret0, _ := ret[0].([]mount.Mount)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// Mounts indicates an expected call of Mounts.
func (mr *MockSnapshotterMockRecorder) Mounts(ctx, key interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Mounts", reflect.TypeOf((*MockSnapshotter)(nil).Mounts), ctx, key)
}
// Prepare mocks base method.
func (m *MockSnapshotter) Prepare(ctx context.Context, key, parent string, opts ...snapshots.Opt) ([]mount.Mount, error) {
m.ctrl.T.Helper()
varargs := []interface{}{ctx, key, parent}
for _, a := range opts {
varargs = append(varargs, a)
}
ret := m.ctrl.Call(m, "Prepare", varargs...)
ret0, _ := ret[0].([]mount.Mount)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// Prepare indicates an expected call of Prepare.
func (mr *MockSnapshotterMockRecorder) Prepare(ctx, key, parent interface{}, opts ...interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
varargs := append([]interface{}{ctx, key, parent}, opts...)
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Prepare", reflect.TypeOf((*MockSnapshotter)(nil).Prepare), varargs...)
}
// Remove mocks base method.
func (m *MockSnapshotter) Remove(ctx context.Context, key string) error {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "Remove", ctx, key)
ret0, _ := ret[0].(error)
return ret0
}
// Remove indicates an expected call of Remove.
func (mr *MockSnapshotterMockRecorder) Remove(ctx, key interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Remove", reflect.TypeOf((*MockSnapshotter)(nil).Remove), ctx, key)
}
// Stat mocks base method.
func (m *MockSnapshotter) Stat(ctx context.Context, key string) (snapshots.Info, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "Stat", ctx, key)
ret0, _ := ret[0].(snapshots.Info)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// Stat indicates an expected call of Stat.
func (mr *MockSnapshotterMockRecorder) Stat(ctx, key interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Stat", reflect.TypeOf((*MockSnapshotter)(nil).Stat), ctx, key)
}
// Update mocks base method.
func (m *MockSnapshotter) Update(ctx context.Context, info snapshots.Info, fieldpaths ...string) (snapshots.Info, error) {
m.ctrl.T.Helper()
varargs := []interface{}{ctx, info}
for _, a := range fieldpaths {
varargs = append(varargs, a)
}
ret := m.ctrl.Call(m, "Update", varargs...)
ret0, _ := ret[0].(snapshots.Info)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// Update indicates an expected call of Update.
func (mr *MockSnapshotterMockRecorder) Update(ctx, info interface{}, fieldpaths ...interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
varargs := append([]interface{}{ctx, info}, fieldpaths...)
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Update", reflect.TypeOf((*MockSnapshotter)(nil).Update), varargs...)
}
// Usage mocks base method.
func (m *MockSnapshotter) Usage(ctx context.Context, key string) (snapshots.Usage, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "Usage", ctx, key)
ret0, _ := ret[0].(snapshots.Usage)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// Usage indicates an expected call of Usage.
func (mr *MockSnapshotterMockRecorder) Usage(ctx, key interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Usage", reflect.TypeOf((*MockSnapshotter)(nil).Usage), ctx, key)
}
// View mocks base method.
func (m *MockSnapshotter) View(ctx context.Context, key, parent string, opts ...snapshots.Opt) ([]mount.Mount, error) {
m.ctrl.T.Helper()
varargs := []interface{}{ctx, key, parent}
for _, a := range opts {
varargs = append(varargs, a)
}
ret := m.ctrl.Call(m, "View", varargs...)
ret0, _ := ret[0].([]mount.Mount)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// View indicates an expected call of View.
func (mr *MockSnapshotterMockRecorder) View(ctx, key, parent interface{}, opts ...interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
varargs := append([]interface{}{ctx, key, parent}, opts...)
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "View", reflect.TypeOf((*MockSnapshotter)(nil).View), varargs...)
}
// Walk mocks base method.
func (m *MockSnapshotter) Walk(ctx context.Context, fn snapshots.WalkFunc, filters ...string) error {
m.ctrl.T.Helper()
varargs := []interface{}{ctx, fn}
for _, a := range filters {
varargs = append(varargs, a)
}
ret := m.ctrl.Call(m, "Walk", varargs...)
ret0, _ := ret[0].(error)
return ret0
}
// Walk indicates an expected call of Walk.
func (mr *MockSnapshotterMockRecorder) Walk(ctx, fn interface{}, filters ...interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
varargs := append([]interface{}{ctx, fn}, filters...)
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Walk", reflect.TypeOf((*MockSnapshotter)(nil).Walk), varargs...)
}
// MockCleaner is a mock of Cleaner interface.
type MockCleaner struct {
ctrl *gomock.Controller
recorder *MockCleanerMockRecorder
}
// MockCleanerMockRecorder is the mock recorder for MockCleaner.
type MockCleanerMockRecorder struct {
mock *MockCleaner
}
// NewMockCleaner creates a new mock instance.
func NewMockCleaner(ctrl *gomock.Controller) *MockCleaner {
mock := &MockCleaner{ctrl: ctrl}
mock.recorder = &MockCleanerMockRecorder{mock}
return mock
}
// EXPECT returns an object that allows the caller to indicate expected use.
func (m *MockCleaner) EXPECT() *MockCleanerMockRecorder {
return m.recorder
}
// Cleanup mocks base method.
func (m *MockCleaner) Cleanup(ctx context.Context) error {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "Cleanup", ctx)
ret0, _ := ret[0].(error)
return ret0
}
// Cleanup indicates an expected call of Cleanup.
func (mr *MockCleanerMockRecorder) Cleanup(ctx interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Cleanup", reflect.TypeOf((*MockCleaner)(nil).Cleanup), ctx)
}

View File

@ -40,6 +40,20 @@ type Container struct {
Args []string `yaml:"args"`
// Volume mounts.
Mounts []specs.Mount `yaml:"mounts"`
// Security options.
Security Security `yaml:"security"`
}
// Security options for containers.
type Security struct {
// WriteableSysfs makes the '/sys' path writeable in the container namespace if set to true.
WriteableSysfs bool `yaml:"writeableSysfs"`
// MaskedPaths is a list of paths in the container namespace that should not be readable.
MaskedPaths []string `yaml:"maskedPaths"`
// ReadonlyPaths is a list of paths in the container namespace that should be read-only.
ReadonlyPaths []string `yaml:"readonlyPaths"`
// WriteableRootfs
WriteableRootfs bool `yaml:"writeableRootfs"`
}
// Dependency describes a service Dependency.

View File

@ -72,6 +72,21 @@ The section `mounts` uses the standard OCI spec:
All requested directories will be mounted into the extension service container mount namespace.
If the `source` directory doesn't exist in the host filesystem, it will be created (only for writable paths in the Talos root filesystem).
#### `container.security`
The section `security` follows this example:
```yaml
maskedPaths:
- "/should/be/masked"
readonlyPaths:
- "/path/that/should/be/readonly"
- "/another/readonly/path"
writeableRootfs: true
```
The rootfs is readonly by default unless `writeableRootfs: true` and masked paths will be mounted to /dev/null.
### `depends`
The `depends` section describes extension service start dependencies: the service will not be started until all dependencies are met.