Skip to content
Open
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
77 changes: 77 additions & 0 deletions cmd/vminitd/bind_mounts.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
//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 main

import (
"context"
"fmt"
"os"
"strings"

"github.com/containerd/containerd/v2/core/mount"
"github.com/containerd/log"
)

type bindMounts []bindMount

type bindMount struct {
tag string
target string
}

func (b *bindMounts) String() string {
ss := make([]string, 0, len(*b))
for _, bm := range *b {
ss = append(ss, bm.tag+":"+bm.target)
}
return strings.Join(ss, ",")
}

func (b *bindMounts) Set(value string) error {
tag, target, ok := strings.Cut(value, ":")
if !ok || len(tag) == 0 || len(target) == 0 {
return fmt.Errorf("invalid bind mount %q: expected format: tag:target", value)
}
*b = append(*b, bindMount{
tag: tag,
target: target,
})
return nil
}

func (b *bindMounts) mountAll(ctx context.Context) error {
for _, bm := range *b {
log.G(ctx).WithFields(log.Fields{
"tag": bm.tag,
"target": bm.target,
}).Info("mounting virtiofs filesystem")

if err := os.MkdirAll(bm.target, 0700); err != nil {
Copy link

Copilot AI Jan 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The directory is created with 0700 permissions which restricts access to the owner only. Consider if this permission level is appropriate for all use cases, as containers may need broader access depending on their user configuration.

Suggested change
if err := os.MkdirAll(bm.target, 0700); err != nil {
if err := os.MkdirAll(bm.target, 0755); err != nil {

Copilot uses AI. Check for mistakes.
return fmt.Errorf("failed to create bind mount target directory %s: %w", bm.target, err)
}
if err := mount.All([]mount.Mount{{
Type: "virtiofs",
Source: bm.tag,
Target: bm.target,
}}, "/"); err != nil {
return err
}
}
return nil
}
105 changes: 105 additions & 0 deletions cmd/vminitd/bind_mounts_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
//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 main

import (
"testing"

"github.com/stretchr/testify/assert"
)

func TestParseBindMounts(t *testing.T) {
testcases := []struct {
name string
inputs []string
want bindMounts
wantStr string
}{
{
name: "single bind mount",
inputs: []string{"foo:/mnt/foo"},
want: bindMounts{
{tag: "foo", target: "/mnt/foo"},
},
wantStr: "foo:/mnt/foo",
},
{
name: "multiple bind mounts",
inputs: []string{"foo:/mnt/foo", "bar:/mnt/bar"},
want: bindMounts{
{tag: "foo", target: "/mnt/foo"},
{tag: "bar", target: "/mnt/bar"},
},
wantStr: "foo:/mnt/foo,bar:/mnt/bar",
},
{
name: "bind mount with nested path",
inputs: []string{"config:/mnt/etc/config"},
want: bindMounts{
{tag: "config", target: "/mnt/etc/config"},
},
wantStr: "config:/mnt/etc/config",
},
}

for _, tc := range testcases {
t.Run(tc.name, func(t *testing.T) {
var b bindMounts
for _, input := range tc.inputs {
err := b.Set(input)
assert.NoError(t, err)
}
assert.Equal(t, tc.want, b)
// Try to convert back the parsed struct into a string to check if it matches the expected output.
assert.Equal(t, tc.wantStr, b.String())
})
}
}

func TestParseBindMountsError(t *testing.T) {
testcases := []struct {
name string
input string
}{
{
name: "missing target",
input: "foo",
},
{
name: "empty tag",
input: ":foo",
},
{
name: "empty target",
input: "foo:",
},
{
name: "empty string",
input: "",
},
}

for _, tc := range testcases {
t.Run(tc.name, func(t *testing.T) {
var b bindMounts
err := b.Set(tc.input)
assert.Error(t, err)
})
}
}
6 changes: 6 additions & 0 deletions cmd/vminitd/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ func main() {
flag.IntVar(&config.StreamPort, "vsock-stream-port", 1025, "vsock port to listen for streams on")
flag.IntVar(&config.VSockContextID, "vsock-cid", 0, "vsock context ID for vsock listen")
flag.Var(&config.Networks, "network", "network interfaces to set up")
flag.Var(&config.Mounts, "mount", "mounts to set up")
args := os.Args[1:]
// Strip "tsi_hijack" added by libkrun
if len(args) > 0 && args[0] == "tsi_hijack" {
Expand Down Expand Up @@ -193,6 +194,10 @@ func systemInit(ctx context.Context, config ServiceConfig) (func(context.Context
return nil, err
}

if err := config.Mounts.mountAll(ctx); err != nil {
return nil, err
}

config.Shutdown.RegisterCallback(func(ctx context.Context) error {
return dhcpReleaser()
})
Expand Down Expand Up @@ -263,6 +268,7 @@ type ServiceConfig struct {
RPCPort int
StreamPort int
Networks networks
Mounts bindMounts
Shutdown shutdown.Service
Debug bool
}
Expand Down
58 changes: 58 additions & 0 deletions docs/bind-mounts.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
# Bind Mounts

Nerdbox supports bind mounts from the host into containers running inside the
VM. Bind mounts are implemented using virtiofs to share host paths with the VM,
which then bind-mounts them into containers.

## How It Works

When a bind mount is specified in the container spec:

1. The shim transforms the bind mount into a virtiofs share
2. The host path is shared with the VM via virtiofs with a unique tag
3. Inside the VM, virtiofs is mounted at a temporary location (`/mnt/bind-{hash}`)
4. The container runtime bind-mounts from that location into the container

## Directory Bind Mounts

For directory bind mounts, the directory is shared directly via virtiofs:

```
Host: /host/data/ → virtiofs share → VM: /mnt/bind-{hash}/ → Container: /container/data/
```

## File Bind Mounts

When bind-mounting a single file, nerdbox shares the **parent directory** of
the file via virtiofs, then bind-mounts the specific file into the container:

```
Host: /host/config/app.yaml
virtiofs shares: /host/config/ (parent directory)
VM: /mnt/bind-{hash}/app.yaml
Container: /container/app.yaml
```

### Security Implications

When using file bind mounts, be aware that the **entire parent directory** is
exposed to the VM, not just the single file. This has security implications if
the VM is considered a security boundary:

- All files in the parent directory become accessible to the VM
- If an attacker compromises the VM, they can access any file in that directory
- Sensitive files that happen to be siblings of the bind-mounted file are exposed

**Recommendations:**

- Avoid bind-mounting files from directories containing secrets, credentials,
or sensitive data
- If the VM is treated as a security boundary, audit what gets exposed when
using file bind mounts
- Place files intended for bind-mounting in dedicated directories with no other
sensitive content
- Consider using directory bind mounts with only the necessary files instead of
file bind mounts
71 changes: 71 additions & 0 deletions internal/shim/task/mount.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,13 +18,17 @@ package task

import (
"context"
"crypto/sha256"
"fmt"
"os"
"path/filepath"
"strings"

"github.com/containerd/containerd/api/types"
"github.com/containerd/errdefs"
"github.com/containerd/log"

"github.com/containerd/nerdbox/internal/shim/task/bundle"
"github.com/containerd/nerdbox/internal/vm"
)

Expand Down Expand Up @@ -146,3 +150,70 @@ func filterOptions(options []string) []string {
}
return filtered
}

type bindMounter struct {
mounts []bindMount
}

type bindMount struct {
tag string
hostSrc string
vmTarget string
}

func (bm *bindMounter) FromBundle(ctx context.Context, b *bundle.Bundle) error {
for i, m := range b.Spec.Mounts {
if m.Type != "bind" {
continue
}

log.G(ctx).WithField("mount", m).Debug("transforming bind mount into a virtiofs mount")

fi, err := os.Stat(m.Source)
if err != nil {
return fmt.Errorf("failed to stat bind mount source %s: %w", m.Source, err)
}

hash := sha256.Sum256([]byte(m.Destination))
tag := fmt.Sprintf("bind-%x", hash[:8])
Copy link

Copilot AI Jan 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using only 8 bytes of the SHA256 hash for the tag creates potential for hash collisions when multiple bind mounts are used. Consider using the full hash or a longer prefix to ensure uniqueness, especially in scenarios with many bind mounts.

Suggested change
tag := fmt.Sprintf("bind-%x", hash[:8])
tag := fmt.Sprintf("bind-%x", hash)

Copilot uses AI. Check for mistakes.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does this need consistent hashing or could a random element be injected?

Copy link

@vvoland vvoland Jan 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

IIUC, I think this would also need to take the source into account if we want to have multiple containers share the same VM in the future?

Otherwise there would be a tag collision beetween mounts with the same destination (in container) but different source (on the host)?

vmTarget := "/mnt/" + tag

// For files, share the parent directory via virtiofs since virtiofs
// operates on directories. The spec source points to the file within
// the mounted directory.
hostSrc := m.Source
specSrc := vmTarget
if !fi.IsDir() {
hostSrc = filepath.Dir(m.Source)
specSrc = filepath.Join(vmTarget, filepath.Base(m.Source))
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

maybe considering use temporary bind mount in a new empty temporary dir in the host for this? is that practical for libkrun?

}

transformed := bindMount{
tag: tag,
hostSrc: hostSrc,
vmTarget: vmTarget,
}

bm.mounts = append(bm.mounts, transformed)
b.Spec.Mounts[i].Source = specSrc
}

return nil
}

func (bm *bindMounter) SetupVM(ctx context.Context, vmi vm.Instance) error {
for _, m := range bm.mounts {
if err := vmi.AddFS(ctx, m.tag, m.hostSrc); err != nil {
return err
}
}
return nil
}

func (bm *bindMounter) InitArgs() []string {
args := make([]string, 0, len(bm.mounts))
for _, m := range bm.mounts {
args = append(args, "-mount="+m.tag+":"+m.vmTarget)
}
return args
}
Loading