Replace the shell-string launcher in buildProcessRunner with a direct
exec.Command. The previous sh -c wrapper relied on shellQuote escaping
for every MachineConfig field that flowed into the launch script; any
future field that ever carried an attacker-controlled value would have
become RCE-as-root. The new path passes binary path and flags as
separate argv entries, so there is no shell to interpret anything.
The wrapper also did two things the shell can no longer do for us:
1. umask 077 — moved to syscall.Umask in cmd/bangerd/main.go so every
firecracker child (and any other file the daemon creates) inherits
0600 by default. Single-user dev sandbox state should be private.
2. chown_watcher — the SDK's HTTP probe inside Machine.Start connects
to the API socket the moment it appears. Under sudo the socket is
created root-owned and the daemon's connect(2) gets EACCES, so the
post-Start EnsureSocketAccess never runs. The shell papered over
this with a backgrounded chown loop. Replaced by
fcproc.EnsureSocketAccessForAsync: same race-window guarantee, in
pure Go, kicked off in LaunchFirecracker right before Start and
awaited right after.
Tests updated: shell-substring assertions replaced with cmd-arg
assertions, plus a new fcproc test pinning the async chown sequence.
Smoke (full systemd two-service install + KVM scenarios) passes.
285 lines
7.5 KiB
Go
285 lines
7.5 KiB
Go
package firecracker
|
|
|
|
import (
|
|
"context"
|
|
"io"
|
|
"log/slog"
|
|
"os"
|
|
"os/exec"
|
|
"strings"
|
|
"sync"
|
|
|
|
sdk "github.com/firecracker-microvm/firecracker-go-sdk"
|
|
models "github.com/firecracker-microvm/firecracker-go-sdk/client/models"
|
|
"github.com/sirupsen/logrus"
|
|
|
|
"banger/internal/vsockagent"
|
|
)
|
|
|
|
type MachineConfig struct {
|
|
BinaryPath string
|
|
VMID string
|
|
SocketPath string
|
|
LogPath string
|
|
MetricsPath string
|
|
KernelImagePath string
|
|
InitrdPath string
|
|
KernelArgs string
|
|
Drives []DriveConfig
|
|
TapDevice string
|
|
VSockPath string
|
|
VSockCID uint32
|
|
VCPUCount int
|
|
MemoryMiB int
|
|
Logger *slog.Logger
|
|
}
|
|
|
|
type DriveConfig struct {
|
|
ID string
|
|
Path string
|
|
ReadOnly bool
|
|
IsRoot bool
|
|
}
|
|
|
|
type Machine struct {
|
|
machine *sdk.Machine
|
|
logFile *os.File
|
|
closeOnce sync.Once
|
|
}
|
|
|
|
type Client struct {
|
|
client *sdk.Client
|
|
}
|
|
|
|
func NewMachine(ctx context.Context, cfg MachineConfig) (*Machine, error) {
|
|
logFile, err := openLogFile(cfg.LogPath)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
cmd := buildProcessRunner(cfg, logFile)
|
|
machine, err := sdk.NewMachine(
|
|
ctx,
|
|
buildConfig(cfg),
|
|
sdk.WithProcessRunner(cmd),
|
|
sdk.WithLogger(newLogger(cfg.Logger)),
|
|
)
|
|
if err != nil {
|
|
if logFile != nil {
|
|
_ = logFile.Close()
|
|
}
|
|
return nil, err
|
|
}
|
|
|
|
return &Machine{machine: machine, logFile: logFile}, nil
|
|
}
|
|
|
|
func (m *Machine) Start(ctx context.Context) error {
|
|
// The caller's ctx is INTENTIONALLY not forwarded to the SDK.
|
|
// firecracker-go-sdk's startVMM (machine.go) spawns a goroutine
|
|
// that SIGTERMs firecracker the instant this ctx cancels, and
|
|
// retains it for the lifetime of the VMM — not just the boot
|
|
// phase. Plumbing an RPC request ctx through would mean
|
|
// firecracker dies the moment the daemon writes its RPC response
|
|
// (daemon.go:handleConn defers cancel). That silently breaks
|
|
// `vm start` on a stopped VM: start "succeeds", the handler
|
|
// returns, ctx cancels, firecracker is SIGTERMed, and the next
|
|
// `vm ssh` hits `vmAlive = false`. `vm.create` sidesteps the bug
|
|
// because BeginVMCreate detaches to a background ctx before
|
|
// calling startVMLocked.
|
|
//
|
|
// We own firecracker lifecycle explicitly — StopVM / KillVM /
|
|
// cleanupRuntime — so losing ctx-driven cancellation here is
|
|
// deliberate. The SDK still enforces its own boot-phase timeouts
|
|
// (socket wait, HTTP) with internal deadlines.
|
|
if err := m.machine.Start(context.Background()); err != nil {
|
|
m.closeLog()
|
|
return err
|
|
}
|
|
_ = ctx
|
|
|
|
go func() {
|
|
_ = m.machine.Wait(context.Background())
|
|
m.closeLog()
|
|
}()
|
|
|
|
return nil
|
|
}
|
|
|
|
func (m *Machine) PID() (int, error) {
|
|
return m.machine.PID()
|
|
}
|
|
|
|
func New(apiSock string, logger *slog.Logger) *Client {
|
|
return &Client{client: sdk.NewClient(apiSock, newLogger(logger), false)}
|
|
}
|
|
|
|
func (c *Client) SendCtrlAltDel(ctx context.Context) error {
|
|
action := models.InstanceActionInfoActionTypeSendCtrlAltDel
|
|
_, err := c.client.CreateSyncAction(ctx, &models.InstanceActionInfo{
|
|
ActionType: &action,
|
|
})
|
|
return err
|
|
}
|
|
|
|
func openLogFile(path string) (*os.File, error) {
|
|
if path == "" {
|
|
return nil, nil
|
|
}
|
|
return os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644)
|
|
}
|
|
|
|
func buildConfig(cfg MachineConfig) sdk.Config {
|
|
rootDrive, extraDrives := splitDrives(cfg.Drives)
|
|
drivesBuilder := sdk.NewDrivesBuilder(rootDrive.Path).
|
|
WithRootDrive(rootDrive.Path, sdk.WithDriveID(defaultDriveID(rootDrive, "rootfs")), sdk.WithReadOnly(rootDrive.ReadOnly))
|
|
for _, drive := range extraDrives {
|
|
if strings.TrimSpace(drive.Path) == "" {
|
|
continue
|
|
}
|
|
drivesBuilder = drivesBuilder.AddDrive(drive.Path, drive.ReadOnly, sdk.WithDriveID(defaultDriveID(drive, "drive")))
|
|
}
|
|
drives := drivesBuilder.Build()
|
|
|
|
return sdk.Config{
|
|
SocketPath: cfg.SocketPath,
|
|
LogPath: cfg.LogPath,
|
|
MetricsPath: cfg.MetricsPath,
|
|
KernelImagePath: cfg.KernelImagePath,
|
|
InitrdPath: cfg.InitrdPath,
|
|
KernelArgs: cfg.KernelArgs,
|
|
Drives: drives,
|
|
NetworkInterfaces: sdk.NetworkInterfaces{{
|
|
StaticConfiguration: &sdk.StaticNetworkConfiguration{
|
|
HostDevName: cfg.TapDevice,
|
|
},
|
|
}},
|
|
VsockDevices: buildVsockDevices(cfg),
|
|
MachineCfg: models.MachineConfiguration{
|
|
VcpuCount: sdk.Int64(int64(cfg.VCPUCount)),
|
|
MemSizeMib: sdk.Int64(int64(cfg.MemoryMiB)),
|
|
Smt: sdk.Bool(false),
|
|
},
|
|
VMID: cfg.VMID,
|
|
}
|
|
}
|
|
|
|
func buildVsockDevices(cfg MachineConfig) []sdk.VsockDevice {
|
|
if strings.TrimSpace(cfg.VSockPath) == "" || cfg.VSockCID == 0 {
|
|
return nil
|
|
}
|
|
return []sdk.VsockDevice{{
|
|
ID: "vsock",
|
|
Path: cfg.VSockPath,
|
|
CID: cfg.VSockCID,
|
|
}}
|
|
}
|
|
|
|
func splitDrives(drives []DriveConfig) (DriveConfig, []DriveConfig) {
|
|
root := DriveConfig{ID: "rootfs"}
|
|
var extras []DriveConfig
|
|
for _, drive := range drives {
|
|
if strings.TrimSpace(drive.Path) == "" {
|
|
continue
|
|
}
|
|
if drive.IsRoot {
|
|
root = drive
|
|
if root.ID == "" {
|
|
root.ID = "rootfs"
|
|
}
|
|
continue
|
|
}
|
|
extras = append(extras, drive)
|
|
}
|
|
return root, extras
|
|
}
|
|
|
|
func defaultDriveID(drive DriveConfig, fallback string) string {
|
|
if strings.TrimSpace(drive.ID) != "" {
|
|
return drive.ID
|
|
}
|
|
return fallback
|
|
}
|
|
|
|
// buildProcessRunner constructs the *exec.Cmd the SDK will start. Args are
|
|
// passed directly — no shell, no string interpolation — so any future change
|
|
// to MachineConfig fields can't smuggle shell metacharacters into the launch.
|
|
//
|
|
// The daemon and root-helper processes set umask 077 at startup, so the
|
|
// API/vsock sockets firecracker creates inherit 0600 mode without needing a
|
|
// shell-level `umask` wrapper.
|
|
//
|
|
// When firecracker has to be launched under sudo (non-root daemon), the
|
|
// resulting sockets are root-owned. The caller (LaunchFirecracker) kicks off
|
|
// fcproc.EnsureSocketAccessForAsync immediately *before* Machine.Start so the
|
|
// chown wins the race against the SDK's HTTP probe over the API socket. That
|
|
// replaces the previous in-shell chown_watcher.
|
|
func buildProcessRunner(cfg MachineConfig, logFile *os.File) *exec.Cmd {
|
|
args := []string{"--api-sock", cfg.SocketPath, "--id", cfg.VMID}
|
|
var cmd *exec.Cmd
|
|
if os.Geteuid() == 0 {
|
|
cmd = exec.Command(cfg.BinaryPath, args...)
|
|
} else {
|
|
cmd = exec.Command("sudo", append([]string{"-n", "-E", cfg.BinaryPath}, args...)...)
|
|
}
|
|
cmd.Stdin = nil
|
|
if logFile != nil {
|
|
cmd.Stdout = logFile
|
|
cmd.Stderr = logFile
|
|
}
|
|
return cmd
|
|
}
|
|
|
|
func newLogger(base *slog.Logger) *logrus.Entry {
|
|
logger := logrus.New()
|
|
logger.SetOutput(io.Discard)
|
|
logger.SetLevel(logrus.DebugLevel)
|
|
logger.AddHook(slogHook{logger: base})
|
|
return logrus.NewEntry(logger)
|
|
}
|
|
|
|
func HealthVSock(ctx context.Context, logger *slog.Logger, socketPath string) error {
|
|
return vsockagent.Health(ctx, logger, socketPath)
|
|
}
|
|
|
|
func PingVSock(ctx context.Context, logger *slog.Logger, socketPath string) error {
|
|
return HealthVSock(ctx, logger, socketPath)
|
|
}
|
|
|
|
type slogHook struct {
|
|
logger *slog.Logger
|
|
}
|
|
|
|
func (h slogHook) Levels() []logrus.Level {
|
|
return logrus.AllLevels
|
|
}
|
|
|
|
func (h slogHook) Fire(entry *logrus.Entry) error {
|
|
if h.logger == nil {
|
|
return nil
|
|
}
|
|
level := slog.LevelDebug
|
|
switch entry.Level {
|
|
case logrus.PanicLevel, logrus.FatalLevel, logrus.ErrorLevel:
|
|
level = slog.LevelError
|
|
case logrus.WarnLevel:
|
|
level = slog.LevelWarn
|
|
default:
|
|
level = slog.LevelDebug
|
|
}
|
|
attrs := make([]any, 0, len(entry.Data)*2+2)
|
|
attrs = append(attrs, "component", "firecracker_sdk")
|
|
for key, value := range entry.Data {
|
|
attrs = append(attrs, key, value)
|
|
}
|
|
h.logger.Log(context.Background(), level, entry.Message, attrs...)
|
|
return nil
|
|
}
|
|
|
|
func (m *Machine) closeLog() {
|
|
m.closeOnce.Do(func() {
|
|
if m.logFile != nil {
|
|
_ = m.logFile.Close()
|
|
}
|
|
})
|
|
}
|