Bug: Firecracker creates its API and vsock sockets as root:root 0700 (enforced by the intentional umask 077 in buildProcessRunner). The daemon, running as the invoking user, then can't connect(2) to either — AF_UNIX connect needs write permission on the socket file and 0700 root-owned leaves thales without any. firecracker-go-sdk's Machine.Start() blocks on waitForSocket, which probes the socket with both os.Stat (succeeds — parent dir is the user's XDG_RUNTIME_DIR) and an HTTP GET over the socket (fails — EACCES on connect). The SDK loops for 3 seconds then fails with "Firecracker did not create API socket ... context deadline exceeded". The daemon's EnsureSocketAccess chown was meant to fix permissions, but it runs *after* Machine.Start returns — and Start never returns because it's still looping on the SDK's probe. Chicken-and-egg. Fix: inside the sudo'd shell that launches firecracker, spawn a background subshell that polls for each expected socket (API + vsock, when configured) and chowns it to $SUDO_UID:$SUDO_GID as soon as it appears. The background polling is bounded at 1s (20 × 50ms) so a broken firecracker invocation doesn't leak a waiting shell. Post-fix: socket appears root-owned 0600 briefly, is chowned to the invoking user within ~50ms, SDK's HTTP probe succeeds, Machine.Start returns normally. EnsureSocketAccess's later chmod 600 remains the belt-and-braces guarantee on final mode. Verified: manual repro of the shell script produces a socket owned by thales:thales that a non-root python socket.connect() accepts. Without the fix the same setup gives "PermissionError: [Errno 13] Permission denied". Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
292 lines
7.7 KiB
Go
292 lines
7.7 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 {
|
||
if err := m.machine.Start(ctx); err != nil {
|
||
m.closeLog()
|
||
return err
|
||
}
|
||
|
||
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
|
||
}
|
||
|
||
func buildProcessRunner(cfg MachineConfig, logFile *os.File) *exec.Cmd {
|
||
// Two moving parts, run inside a single sudo'd shell:
|
||
//
|
||
// 1. umask 077 + exec firecracker → the API and vsock sockets
|
||
// firecracker creates are born 0600 owned by root (sudo user),
|
||
// not 0755. Without the umask there's a real window where a
|
||
// local attacker could hit the control plane.
|
||
//
|
||
// 2. A background subshell polls for each expected socket and
|
||
// chowns it to $SUDO_UID:$SUDO_GID as soon as it appears.
|
||
//
|
||
// The chown is required *before* the firecracker-go-sdk's
|
||
// waitForSocket returns from Machine.Start — the SDK does both an
|
||
// os.Stat and an HTTP GET over the socket, and AF_UNIX connect(2)
|
||
// needs write permission on the socket file. With the socket at
|
||
// 0600 root:root, the daemon process (running as the invoking
|
||
// user) gets EACCES on connect and the SDK loops until its 3s
|
||
// timeout. The daemon's post-Start EnsureSocketAccess chown would
|
||
// fix it, but Start never returns to hand control back.
|
||
//
|
||
// Racing the chown inside sudo's shell closes the gap: by the
|
||
// time the SDK's HTTP probe fires, the socket is already owned by
|
||
// the invoking user.
|
||
chownWatcher := func(path string) string {
|
||
// Bounded poll: 20 × 50ms = 1s. Matches the SDK's 3s wait
|
||
// budget with headroom and bails quietly if firecracker
|
||
// never creates the socket (e.g. bad args — the error
|
||
// surfaces through firecracker's non-zero exit).
|
||
return `for _ in $(seq 1 20); do [ -S ` + shellQuote(path) + ` ] && break; sleep 0.05; done; ` +
|
||
`[ -S ` + shellQuote(path) + ` ] && chown "$SUDO_UID:$SUDO_GID" ` + shellQuote(path) + ` || true`
|
||
}
|
||
watchers := chownWatcher(cfg.SocketPath)
|
||
if strings.TrimSpace(cfg.VSockPath) != "" {
|
||
watchers += "; " + chownWatcher(cfg.VSockPath)
|
||
}
|
||
script := "umask 077 && (" + watchers + ") & exec " + shellQuote(cfg.BinaryPath) +
|
||
" --api-sock " + shellQuote(cfg.SocketPath) +
|
||
" --id " + shellQuote(cfg.VMID)
|
||
// sudo -E preserves SUDO_UID / SUDO_GID (sudo sets them itself
|
||
// regardless, but -E is already the convention in this codebase
|
||
// and the background subshell needs them).
|
||
cmd := exec.Command("sudo", "-n", "-E", "sh", "-c", script)
|
||
cmd.Stdin = nil
|
||
if logFile != nil {
|
||
cmd.Stdout = logFile
|
||
cmd.Stderr = logFile
|
||
}
|
||
return cmd
|
||
}
|
||
|
||
func shellQuote(value string) string {
|
||
return "'" + strings.ReplaceAll(value, "'", `'"'"'`) + "'"
|
||
}
|
||
|
||
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()
|
||
}
|
||
})
|
||
}
|