banger/internal/daemon/vm.go
Thales Maciel d743a8ba4b
daemon: persist teardown fallbacks and reject unsafe import paths
Preserve cleanup after daemon restarts and harden OCI and tar imports
against filenames that debugfs cannot encode safely.

Mirror tap, loop, and dm teardown identity onto VM.Runtime, teach
cleanup and reconcile to fall back to those persisted fields when
handles.json is missing or corrupt, and clear the recovery state on
stop, error, and delete paths.

Reject debugfs-hostile entry names during flattening and in
ApplyOwnership itself, then add regression coverage for corrupt
handles.json recovery and unsafe import paths.

Verified with targeted go tests, make lint-go, make lint-shell, and
make build.
2026-04-23 16:21:59 -03:00

182 lines
5.2 KiB
Go

package daemon
import (
"context"
"errors"
"fmt"
"os"
"strconv"
"strings"
"time"
"banger/internal/daemon/fcproc"
"banger/internal/model"
"banger/internal/namegen"
"banger/internal/system"
"banger/internal/vmdns"
)
// Cross-service constants. Kept in vm.go because both lifecycle
// (VMService) and networking (HostNetwork) reference them; moving
// them to either owner would read as a layering violation.
var (
errWaitForExitTimeout = fcproc.ErrWaitForExitTimeout
gracefulShutdownWait = 10 * time.Second
vsockReadyWait = 30 * time.Second
vsockReadyPoll = 200 * time.Millisecond
)
// rebuildDNS enumerates live VMs and republishes the DNS record set.
// Lives on VMService because "alive" is a VM-state concern that
// HostNetwork shouldn't need to reach into. VMService orchestrates:
// VM list from the store, alive filter, hand the resulting map to
// HostNetwork.replaceDNS.
func (s *VMService) rebuildDNS(ctx context.Context) error {
if s.net == nil {
return nil
}
vms, err := s.store.ListVMs(ctx)
if err != nil {
return err
}
records := make(map[string]string)
for _, vm := range vms {
if !s.vmAlive(vm) {
continue
}
if strings.TrimSpace(vm.Runtime.GuestIP) == "" {
continue
}
records[vmdns.RecordName(vm.Name)] = vm.Runtime.GuestIP
}
return s.net.replaceDNS(records)
}
func persistRuntimeTeardownState(vm *model.VMRecord, h model.VMHandles) {
if vm == nil {
return
}
vm.Runtime.TapDevice = h.TapDevice
vm.Runtime.BaseLoop = h.BaseLoop
vm.Runtime.COWLoop = h.COWLoop
vm.Runtime.DMName = h.DMName
vm.Runtime.DMDev = h.DMDev
}
func clearRuntimeTeardownState(vm *model.VMRecord) {
if vm == nil {
return
}
vm.Runtime.TapDevice = ""
vm.Runtime.BaseLoop = ""
vm.Runtime.COWLoop = ""
vm.Runtime.DMName = ""
vm.Runtime.DMDev = ""
}
func teardownHandlesForCleanup(vm model.VMRecord, live model.VMHandles) model.VMHandles {
recovered := live
if strings.TrimSpace(recovered.TapDevice) == "" {
recovered.TapDevice = strings.TrimSpace(vm.Runtime.TapDevice)
}
if strings.TrimSpace(recovered.BaseLoop) == "" {
recovered.BaseLoop = strings.TrimSpace(vm.Runtime.BaseLoop)
}
if strings.TrimSpace(recovered.COWLoop) == "" {
recovered.COWLoop = strings.TrimSpace(vm.Runtime.COWLoop)
}
if strings.TrimSpace(recovered.DMName) == "" {
recovered.DMName = strings.TrimSpace(vm.Runtime.DMName)
}
if strings.TrimSpace(recovered.DMDev) == "" {
recovered.DMDev = strings.TrimSpace(vm.Runtime.DMDev)
}
return recovered
}
// cleanupRuntime tears down the host-side state for a VM: firecracker
// process, DM snapshot, capabilities, tap, sockets. Lives on VMService
// because it reaches into handles (VMService-owned); the capability
// teardown goes through the capHooks seam to keep Daemon out of the
// dependency chain.
func (s *VMService) cleanupRuntime(ctx context.Context, vm model.VMRecord, preserveDisks bool) error {
if s.logger != nil {
s.logger.Debug("cleanup runtime", append(vmLogAttrs(vm), "preserve_disks", preserveDisks)...)
}
h := s.vmHandles(vm.ID)
cleanupPID := h.PID
if vm.Runtime.APISockPath != "" {
if pid, err := s.net.findFirecrackerPID(ctx, vm.Runtime.APISockPath); err == nil && pid > 0 {
cleanupPID = pid
}
}
if cleanupPID > 0 && system.ProcessRunning(cleanupPID, vm.Runtime.APISockPath) {
_ = s.net.killVMProcess(ctx, cleanupPID)
if err := s.net.waitForExit(ctx, cleanupPID, vm.Runtime.APISockPath, 30*time.Second); err != nil {
return err
}
}
handles := teardownHandlesForCleanup(vm, h)
snapshotErr := s.net.cleanupDMSnapshot(ctx, dmSnapshotHandles{
BaseLoop: handles.BaseLoop,
COWLoop: handles.COWLoop,
DMName: handles.DMName,
DMDev: handles.DMDev,
})
featureErr := s.capHooks.cleanupState(ctx, vm)
var tapErr error
// Prefer the handle cache (fresh from startVMLocked), but fall
// back to the VMRuntime mirrors so restart-time cleanup still works
// when handles.json is missing or corrupt.
tap := handles.TapDevice
if tap != "" {
tapErr = s.net.releaseTap(ctx, tap)
}
if vm.Runtime.APISockPath != "" {
_ = os.Remove(vm.Runtime.APISockPath)
}
if vm.Runtime.VSockPath != "" {
_ = os.Remove(vm.Runtime.VSockPath)
}
// The handles are only meaningful while the kernel objects exist;
// dropping them here keeps the cache in sync with reality even
// when the caller forgets to call clearVMHandles explicitly.
s.clearVMHandles(vm)
if !preserveDisks && vm.Runtime.VMDir != "" {
return errors.Join(snapshotErr, featureErr, tapErr, os.RemoveAll(vm.Runtime.VMDir))
}
return errors.Join(snapshotErr, featureErr, tapErr)
}
func (s *VMService) generateName(ctx context.Context) (string, error) {
_ = ctx
if name := strings.TrimSpace(namegen.Generate()); name != "" {
return name, nil
}
return "vm-" + strconv.FormatInt(time.Now().Unix(), 10), nil
}
func bridgePrefix(bridgeIP string) string {
parts := strings.Split(bridgeIP, ".")
if len(parts) < 3 {
return bridgeIP
}
return strings.Join(parts[:3], ".")
}
func optionalIntOrDefault(value *int, fallback int) int {
if value != nil {
return *value
}
return fallback
}
func validateOptionalPositiveSetting(label string, value *int) error {
if value == nil {
return nil
}
if *value <= 0 {
return fmt.Errorf("%s must be a positive integer", label)
}
return nil
}