Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions guest/boot/boot.go
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ func Run(logger *slog.Logger, opts ...Option) (shutdown func(), err error) {
cfg.workspaceUID,
cfg.workspaceGID,
cfg.mountRetries,
cfg.workspaceReadOnly,
); err != nil {
logger.Warn("workspace mount failed, continuing without workspace", "error", err)
}
Expand Down
13 changes: 13 additions & 0 deletions guest/boot/boot_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ func TestDefaultConfig(t *testing.T) {
assert.Equal(t, "workspace", cfg.workspaceTag)
assert.Equal(t, 1000, cfg.workspaceUID)
assert.Equal(t, 1000, cfg.workspaceGID)
assert.False(t, cfg.workspaceReadOnly)
assert.Equal(t, 5, cfg.mountRetries)
assert.Equal(t, 22, cfg.sshPort)
assert.Equal(t, "/home/sandbox/.ssh/authorized_keys", cfg.sshKeysPath)
Expand All @@ -38,6 +39,18 @@ func TestDefaultConfig(t *testing.T) {
assert.True(t, cfg.lockdownRoot)
}

func TestWithWorkspaceReadOnly(t *testing.T) {
t.Parallel()
cfg := defaultConfig()
assert.False(t, cfg.workspaceReadOnly)

WithWorkspaceReadOnly(true).apply(cfg)
assert.True(t, cfg.workspaceReadOnly)

WithWorkspaceReadOnly(false).apply(cfg)
assert.False(t, cfg.workspaceReadOnly)
}

func TestWithWorkspace(t *testing.T) {
t.Parallel()
cfg := defaultConfig()
Expand Down
7 changes: 7 additions & 0 deletions guest/boot/options.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ type config struct {
workspaceTag string
workspaceUID int
workspaceGID int
workspaceReadOnly bool
mountRetries int
sshPort int
sshKeysPath string
Expand Down Expand Up @@ -66,6 +67,12 @@ func WithWorkspace(mountPoint, tag string, uid, gid int) Option {
})
}

// WithWorkspaceReadOnly makes the workspace virtiofs mount read-only inside the
// guest. The mount is performed with MS_RDONLY so guest processes cannot write.
func WithWorkspaceReadOnly(readOnly bool) Option {
return optionFunc(func(c *config) { c.workspaceReadOnly = readOnly })
}

// WithMountRetries sets the maximum number of retries for workspace mount.
func WithMountRetries(n int) Option {
return optionFunc(func(c *config) { c.mountRetries = n })
Expand Down
24 changes: 19 additions & 5 deletions guest/mount/mount.go
Original file line number Diff line number Diff line change
Expand Up @@ -88,18 +88,32 @@ func Essential(logger *slog.Logger, tmpSizeMiB uint32) error {

// Workspace mounts a virtiofs share at the given mount point, retrying up to
// maxRetries times to allow the host to expose the filesystem. On success the
// mount point is chowned to uid:gid.
func Workspace(logger *slog.Logger, mountPoint, tag string, uid, gid, maxRetries int) error {
// mount point is chowned to uid:gid. When readOnly is true the mount is
// performed with MS_RDONLY so the guest cannot write to it.
func Workspace(logger *slog.Logger, mountPoint, tag string, uid, gid, maxRetries int, readOnly bool) error {
if err := os.MkdirAll(mountPoint, 0o755); err != nil {
return fmt.Errorf("creating workspace mount point %s: %w", mountPoint, err)
}

flags := uintptr(syscall.MS_NOSUID | syscall.MS_NODEV)
if readOnly {
flags |= syscall.MS_RDONLY
}

var lastErr error
for i := range maxRetries {
lastErr = syscall.Mount(tag, mountPoint, "virtiofs", syscall.MS_NOSUID|syscall.MS_NODEV, "")
lastErr = syscall.Mount(tag, mountPoint, "virtiofs", flags, "")
if lastErr == nil {
if err := os.Chown(mountPoint, uid, gid); err != nil {
return fmt.Errorf("chown workspace %s: %w", mountPoint, err)
// Skip chown on read-only mounts: chown returns EROFS on a
// filesystem mounted with MS_RDONLY. Ownership is cosmetic
// anyway since the mount prevents writes regardless.
if !readOnly {
if err := os.Chown(mountPoint, uid, gid); err != nil {
// Clean up the mount so we don't leave a mounted
// filesystem that the caller thinks failed.
_ = syscall.Unmount(mountPoint, 0)
return fmt.Errorf("chown workspace %s: %w", mountPoint, err)
}
}
return nil
}
Expand Down
2 changes: 1 addition & 1 deletion guest/mount/mount_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ func TestWorkspaceReturnsErrorForInvalidMount(t *testing.T) {
if os.Getuid() == 0 {
t.Skip("test must run as non-root")
}
err := Workspace(slog.Default(), t.TempDir()+"/ws", "nonexistent-tag", 1000, 1000, 1)
err := Workspace(slog.Default(), t.TempDir()+"/ws", "nonexistent-tag", 1000, 1000, 1, false)
assert.Error(t, err)
}

Expand Down
11 changes: 11 additions & 0 deletions guest/vmconfig/vmconfig.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,17 @@ type Config struct {
// TmpSizeMiB is the size of the /tmp tmpfs in MiB. Zero means use the
// mount package default (256 MiB).
TmpSizeMiB uint32 `json:"tmp_size_mib,omitempty"`
// VirtioFSMounts describes virtiofs mounts the host configured for the VM.
// The guest init uses this to determine mount-time options (e.g. read-only).
VirtioFSMounts []VirtioFSMountInfo `json:"virtiofs_mounts,omitempty"`
}

// VirtioFSMountInfo carries mount metadata from the host to the guest init.
type VirtioFSMountInfo struct {
// Tag is the virtiofs tag identifying this mount.
Tag string `json:"tag"`
// ReadOnly indicates the mount should be read-only inside the guest.
ReadOnly bool `json:"read_only,omitempty"`
}

// Read loads the VM config from /etc/go-microvm.json.
Expand Down
8 changes: 8 additions & 0 deletions guest/vmconfig/vmconfig_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,14 @@ func TestReadFrom(t *testing.T) {
content: strPtr(`{"tmp_size_mib":512}`),
want: Config{TmpSizeMiB: 512},
},
{
name: "virtiofs mounts with read-only",
content: strPtr(`{"virtiofs_mounts":[{"tag":"workspace","read_only":true},{"tag":"data"}]}`),
want: Config{VirtioFSMounts: []VirtioFSMountInfo{
{Tag: "workspace", ReadOnly: true},
{Tag: "data"},
}},
},
{
name: "empty JSON object",
content: strPtr(`{}`),
Expand Down
8 changes: 8 additions & 0 deletions hooks/hooks_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,14 @@ func TestInjectVMConfig(t *testing.T) {
name: "zero-value config",
cfg: vmconfig.Config{},
},
{
name: "config with read-only mounts",
cfg: vmconfig.Config{
VirtioFSMounts: []vmconfig.VirtioFSMountInfo{
{Tag: "workspace", ReadOnly: true},
},
},
},
}

for _, tt := range tests {
Expand Down
1 change: 1 addition & 0 deletions hypervisor/backend.go
Original file line number Diff line number Diff line change
Expand Up @@ -90,4 +90,5 @@ type PortForward struct {
type FilesystemMount struct {
Tag string
HostPath string
ReadOnly bool
}
2 changes: 1 addition & 1 deletion hypervisor/libkrun/backend.go
Original file line number Diff line number Diff line change
Expand Up @@ -187,7 +187,7 @@ func toRunnerPortForwards(ports []hypervisor.PortForward) []runner.PortForward {
func toRunnerVirtioFS(mounts []hypervisor.FilesystemMount) []runner.VirtioFSMount {
out := make([]runner.VirtioFSMount, len(mounts))
for i, m := range mounts {
out[i] = runner.VirtioFSMount{Tag: m.Tag, HostPath: m.HostPath}
out[i] = runner.VirtioFSMount{Tag: m.Tag, HostPath: m.HostPath, ReadOnly: m.ReadOnly}
}
return out
}
1 change: 1 addition & 0 deletions hypervisor/libkrun/backend_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,7 @@ func TestBackend_Start_Success(t *testing.T) {
},
FilesystemMounts: []hypervisor.FilesystemMount{
{Tag: "workspace", HostPath: "/tmp/src"},
{Tag: "data", HostPath: "/tmp/data", ReadOnly: true},
},
NetEndpoint: hypervisor.NetEndpoint{
Type: hypervisor.NetEndpointUnixSocket,
Expand Down
27 changes: 21 additions & 6 deletions microvm.go
Original file line number Diff line number Diff line change
Expand Up @@ -125,11 +125,12 @@ func Run(ctx context.Context, imageRef string, opts ...Option) (*VM, error) {
}
}

// 3b. Inject VM config for the guest init (e.g. /tmp size).
// Only written when a non-default value is configured, keeping the
// file absent for callers that rely on the built-in 256 MiB default.
if cfg.tmpSizeMiB > 0 {
vmCfgHook := hooks.InjectVMConfig(vmconfig.Config{TmpSizeMiB: cfg.tmpSizeMiB})
// 3b. Inject VM config for the guest init (e.g. /tmp size, mount flags).
// Only written when non-default values are configured, keeping the
// file absent for callers that rely on built-in defaults.
guestVMCfg := buildVMConfig(cfg)
if guestVMCfg.TmpSizeMiB > 0 || len(guestVMCfg.VirtioFSMounts) > 0 {
vmCfgHook := hooks.InjectVMConfig(guestVMCfg)
if err := vmCfgHook(rootfs.Path, rootfs.Config); err != nil {
return nil, fmt.Errorf("inject vm config: %w", err)
}
Expand Down Expand Up @@ -395,10 +396,24 @@ func toHypervisorPorts(ports []PortForward) []hypervisor.PortForward {
return out
}

func buildVMConfig(cfg *config) vmconfig.Config {
var vc vmconfig.Config
vc.TmpSizeMiB = cfg.tmpSizeMiB
for _, m := range cfg.virtioFS {
if m.ReadOnly {
vc.VirtioFSMounts = append(vc.VirtioFSMounts, vmconfig.VirtioFSMountInfo{
Tag: m.Tag,
ReadOnly: true,
})
}
}
return vc
}

func toHypervisorMounts(mounts []VirtioFSMount) []hypervisor.FilesystemMount {
out := make([]hypervisor.FilesystemMount, len(mounts))
for i, m := range mounts {
out[i] = hypervisor.FilesystemMount{Tag: m.Tag, HostPath: m.HostPath}
out[i] = hypervisor.FilesystemMount{Tag: m.Tag, HostPath: m.HostPath, ReadOnly: m.ReadOnly}
}
return out
}
Expand Down
39 changes: 38 additions & 1 deletion microvm_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import (
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

"github.com/stacklok/go-microvm/guest/vmconfig"
"github.com/stacklok/go-microvm/hypervisor"
"github.com/stacklok/go-microvm/image"
"github.com/stacklok/go-microvm/internal/testutil"
Expand Down Expand Up @@ -92,16 +93,18 @@ func TestToHypervisorMounts(t *testing.T) {

mounts := []VirtioFSMount{
{Tag: "workspace", HostPath: "/home/user/src"},
{Tag: "data", HostPath: "/var/data"},
{Tag: "data", HostPath: "/var/data", ReadOnly: true},
}

result := toHypervisorMounts(mounts)

require.Len(t, result, 2)
assert.Equal(t, "workspace", result[0].Tag)
assert.Equal(t, "/home/user/src", result[0].HostPath)
assert.False(t, result[0].ReadOnly)
assert.Equal(t, "data", result[1].Tag)
assert.Equal(t, "/var/data", result[1].HostPath)
assert.True(t, result[1].ReadOnly)
}

func TestToHypervisorMounts_Empty(t *testing.T) {
Expand All @@ -111,6 +114,40 @@ func TestToHypervisorMounts_Empty(t *testing.T) {
assert.Empty(t, result)
}

func TestBuildVMConfig(t *testing.T) {
t.Parallel()

t.Run("empty config produces zero value", func(t *testing.T) {
t.Parallel()
cfg := defaultConfig()
vc := buildVMConfig(cfg)
assert.Zero(t, vc.TmpSizeMiB)
assert.Empty(t, vc.VirtioFSMounts)
})

t.Run("only read-only mounts are included", func(t *testing.T) {
t.Parallel()
cfg := defaultConfig()
cfg.virtioFS = []VirtioFSMount{
{Tag: "workspace", HostPath: "/src"},
{Tag: "data", HostPath: "/data", ReadOnly: true},
{Tag: "config", HostPath: "/cfg", ReadOnly: true},
}
vc := buildVMConfig(cfg)
require.Len(t, vc.VirtioFSMounts, 2)
assert.Equal(t, vmconfig.VirtioFSMountInfo{Tag: "data", ReadOnly: true}, vc.VirtioFSMounts[0])
assert.Equal(t, vmconfig.VirtioFSMountInfo{Tag: "config", ReadOnly: true}, vc.VirtioFSMounts[1])
})

t.Run("tmpSizeMiB is propagated", func(t *testing.T) {
t.Parallel()
cfg := defaultConfig()
cfg.tmpSizeMiB = 512
vc := buildVMConfig(cfg)
assert.Equal(t, uint32(512), vc.TmpSizeMiB)
})
}

// --- Mock types for Run() tests ---

// mockImageFetcher implements image.ImageFetcher for testing.
Expand Down
5 changes: 5 additions & 0 deletions options.go
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,11 @@ type PortForward struct {
type VirtioFSMount struct {
Tag string
HostPath string
// ReadOnly makes the mount read-only inside the guest. Enforcement is
// guest-side via MS_RDONLY mount flags; libkrun does not currently
// support host-side read-only virtiofs. A compromised guest kernel
// could bypass this restriction.
ReadOnly bool
}

// EgressPolicy restricts outbound VM traffic to specific DNS hostnames.
Expand Down
15 changes: 15 additions & 0 deletions options_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,21 @@ func TestWithVirtioFS(t *testing.T) {
require.Len(t, cfg.virtioFS, 1)
assert.Equal(t, "workspace", cfg.virtioFS[0].Tag)
assert.Equal(t, "/home/user/src", cfg.virtioFS[0].HostPath)
assert.False(t, cfg.virtioFS[0].ReadOnly)
}

func TestWithVirtioFS_ReadOnly(t *testing.T) {
t.Parallel()

cfg := defaultConfig()
WithVirtioFS(
VirtioFSMount{Tag: "data", HostPath: "/var/data", ReadOnly: true},
).apply(cfg)

require.Len(t, cfg.virtioFS, 1)
assert.Equal(t, "data", cfg.virtioFS[0].Tag)
assert.Equal(t, "/var/data", cfg.virtioFS[0].HostPath)
assert.True(t, cfg.virtioFS[0].ReadOnly)
}

func TestWithVirtioFS_Appends(t *testing.T) {
Expand Down
7 changes: 7 additions & 0 deletions runner/cmd/go-microvm-runner/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,8 @@ type VirtioFSMount struct {
Tag string `json:"tag"`
// Path is the host directory path.
Path string `json:"path"`
// ReadOnly makes the mount read-only inside the guest.
ReadOnly bool `json:"read_only,omitempty"`
}

// Exit codes for the runner binary.
Expand Down Expand Up @@ -201,7 +203,12 @@ func runVM(config *Config) error {
}

// Configure virtio-fs mounts.
// Note: libkrun's krun_add_virtiofs does not support a read-only flag.
// ReadOnly enforcement happens guest-side via MS_RDONLY mount flags.
for _, mount := range config.VirtioFSMounts {
if mount.ReadOnly {
fmt.Fprintf(os.Stderr, "Warning: virtiofs mount %q is read-only but libkrun has no host-side enforcement; relying on guest-side MS_RDONLY\n", mount.Tag)
}
if err := ctx.AddVirtioFS(mount.Tag, mount.Path); err != nil {
_ = ctx.Free()
return fmt.Errorf("add virtiofs mount %s: %w", mount.Tag, err)
Expand Down
2 changes: 2 additions & 0 deletions runner/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -57,4 +57,6 @@ type VirtioFSMount struct {
Tag string `json:"tag"`
// HostPath is the host directory path.
HostPath string `json:"path"`
// ReadOnly makes the mount read-only inside the guest.
ReadOnly bool `json:"read_only,omitempty"`
}
Loading
Loading