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.
This commit is contained in:
parent
86a56fedb3
commit
d743a8ba4b
15 changed files with 272 additions and 81 deletions
|
|
@ -323,7 +323,7 @@ func (d *Daemon) reconcile(ctx context.Context) error {
|
|||
_ = d.vm.cleanupRuntime(ctx, vm, true)
|
||||
vm.State = model.VMStateStopped
|
||||
vm.Runtime.State = model.VMStateStopped
|
||||
vm.Runtime.TapDevice = ""
|
||||
clearRuntimeTeardownState(&vm)
|
||||
d.vm.clearVMHandles(vm)
|
||||
vm.UpdatedAt = model.Now()
|
||||
return d.store.UpsertVM(ctx, vm)
|
||||
|
|
|
|||
|
|
@ -216,7 +216,7 @@ func (s *StatsService) stopStaleVMs(ctx context.Context) (err error) {
|
|||
_ = s.cleanupRuntime(ctx, vm, true)
|
||||
vm.State = model.VMStateStopped
|
||||
vm.Runtime.State = model.VMStateStopped
|
||||
vm.Runtime.TapDevice = ""
|
||||
clearRuntimeTeardownState(&vm)
|
||||
vm.UpdatedAt = model.Now()
|
||||
return s.store.UpsertVM(ctx, vm)
|
||||
}); err != nil {
|
||||
|
|
|
|||
|
|
@ -52,6 +52,48 @@ func (s *VMService) rebuildDNS(ctx context.Context) error {
|
|||
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
|
||||
|
|
@ -74,22 +116,19 @@ func (s *VMService) cleanupRuntime(ctx context.Context, vm model.VMRecord, prese
|
|||
return err
|
||||
}
|
||||
}
|
||||
handles := teardownHandlesForCleanup(vm, h)
|
||||
snapshotErr := s.net.cleanupDMSnapshot(ctx, dmSnapshotHandles{
|
||||
BaseLoop: h.BaseLoop,
|
||||
COWLoop: h.COWLoop,
|
||||
DMName: h.DMName,
|
||||
DMDev: h.DMDev,
|
||||
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 Runtime.TapDevice — persisted to the DB in the same
|
||||
// stage — so a daemon restart or corrupt handles.json doesn't
|
||||
// leak the tap (or the NAT FORWARD rules keyed off it).
|
||||
tap := h.TapDevice
|
||||
if tap == "" {
|
||||
tap = vm.Runtime.TapDevice
|
||||
}
|
||||
// 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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -124,7 +124,8 @@ func (s *VMService) setVMHandlesInMemory(vmID string, h model.VMHandles) {
|
|||
|
||||
// vmHandles returns the cached handles for vm (zero-value if no
|
||||
// entry). The in-process handle cache is the authoritative source
|
||||
// for PID / loops / dm-name — VMRecord.Runtime holds only paths.
|
||||
// for PID and live kernel/network handles; VMRecord.Runtime only
|
||||
// mirrors teardown-critical fields for restart recovery.
|
||||
func (s *VMService) vmHandles(vmID string) model.VMHandles {
|
||||
if s == nil {
|
||||
return model.VMHandles{}
|
||||
|
|
@ -134,13 +135,15 @@ func (s *VMService) vmHandles(vmID string) model.VMHandles {
|
|||
return h
|
||||
}
|
||||
|
||||
// setVMHandles updates the in-memory cache AND the per-VM scratch
|
||||
// file. Scratch-file errors are logged but not returned; the cache
|
||||
// write is authoritative while the daemon is alive.
|
||||
func (s *VMService) setVMHandles(vm model.VMRecord, h model.VMHandles) {
|
||||
if s == nil {
|
||||
// setVMHandles updates the in-memory cache, mirrors teardown-critical
|
||||
// fields onto VMRuntime, and writes the per-VM scratch file.
|
||||
// Scratch-file errors are logged but not returned; the cache remains
|
||||
// authoritative while the daemon is alive.
|
||||
func (s *VMService) setVMHandles(vm *model.VMRecord, h model.VMHandles) {
|
||||
if s == nil || vm == nil {
|
||||
return
|
||||
}
|
||||
persistRuntimeTeardownState(vm, h)
|
||||
s.ensureHandleCache()
|
||||
s.handles.set(vm.ID, h)
|
||||
if err := writeHandlesFile(vm.Runtime.VMDir, h); err != nil && s.logger != nil {
|
||||
|
|
|
|||
|
|
@ -36,6 +36,30 @@ func TestHandlesFileRoundtrip(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestSetVMHandlesMirrorsRuntimeTeardownState(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
d := &Daemon{}
|
||||
wireServices(d)
|
||||
|
||||
vmDir := t.TempDir()
|
||||
vm := testVM("mirror", "image-mirror", "172.16.0.77")
|
||||
vm.Runtime.VMDir = vmDir
|
||||
|
||||
want := model.VMHandles{
|
||||
TapDevice: "tap-fc-0077",
|
||||
BaseLoop: "/dev/loop17",
|
||||
COWLoop: "/dev/loop18",
|
||||
DMName: "fc-rootfs-0077",
|
||||
DMDev: "/dev/mapper/fc-rootfs-0077",
|
||||
}
|
||||
d.vm.setVMHandles(&vm, want)
|
||||
|
||||
if vm.Runtime.TapDevice != want.TapDevice || vm.Runtime.BaseLoop != want.BaseLoop || vm.Runtime.COWLoop != want.COWLoop || vm.Runtime.DMName != want.DMName || vm.Runtime.DMDev != want.DMDev {
|
||||
t.Fatalf("runtime teardown state not mirrored: got %+v want %+v", vm.Runtime, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandlesFileMissingReturnsZero(t *testing.T) {
|
||||
t.Parallel()
|
||||
h, present, err := readHandlesFile(t.TempDir())
|
||||
|
|
|
|||
|
|
@ -80,7 +80,7 @@ func (s *VMService) startVMLocked(ctx context.Context, vm model.VMRecord, image
|
|||
vm.State = model.VMStateError
|
||||
vm.Runtime.State = model.VMStateError
|
||||
vm.Runtime.LastError = runErr.Error()
|
||||
vm.Runtime.TapDevice = ""
|
||||
clearRuntimeTeardownState(&vm)
|
||||
s.clearVMHandles(vm)
|
||||
if s.store != nil {
|
||||
_ = s.store.UpsertVM(context.Background(), vm)
|
||||
|
|
@ -113,7 +113,7 @@ func (s *VMService) stopVMLocked(ctx context.Context, current model.VMRecord) (v
|
|||
}
|
||||
vm.State = model.VMStateStopped
|
||||
vm.Runtime.State = model.VMStateStopped
|
||||
vm.Runtime.TapDevice = ""
|
||||
clearRuntimeTeardownState(&vm)
|
||||
s.clearVMHandles(vm)
|
||||
if err := s.store.UpsertVM(ctx, vm); err != nil {
|
||||
return model.VMRecord{}, err
|
||||
|
|
@ -138,7 +138,7 @@ func (s *VMService) stopVMLocked(ctx context.Context, current model.VMRecord) (v
|
|||
}
|
||||
vm.State = model.VMStateStopped
|
||||
vm.Runtime.State = model.VMStateStopped
|
||||
vm.Runtime.TapDevice = ""
|
||||
clearRuntimeTeardownState(&vm)
|
||||
s.clearVMHandles(vm)
|
||||
system.TouchNow(&vm)
|
||||
if err := s.store.UpsertVM(ctx, vm); err != nil {
|
||||
|
|
@ -170,7 +170,7 @@ func (s *VMService) killVMLocked(ctx context.Context, current model.VMRecord, si
|
|||
}
|
||||
vm.State = model.VMStateStopped
|
||||
vm.Runtime.State = model.VMStateStopped
|
||||
vm.Runtime.TapDevice = ""
|
||||
clearRuntimeTeardownState(&vm)
|
||||
s.clearVMHandles(vm)
|
||||
if err := s.store.UpsertVM(ctx, vm); err != nil {
|
||||
return model.VMRecord{}, err
|
||||
|
|
@ -200,7 +200,7 @@ func (s *VMService) killVMLocked(ctx context.Context, current model.VMRecord, si
|
|||
}
|
||||
vm.State = model.VMStateStopped
|
||||
vm.Runtime.State = model.VMStateStopped
|
||||
vm.Runtime.TapDevice = ""
|
||||
clearRuntimeTeardownState(&vm)
|
||||
s.clearVMHandles(vm)
|
||||
system.TouchNow(&vm)
|
||||
if err := s.store.UpsertVM(ctx, vm); err != nil {
|
||||
|
|
@ -262,6 +262,7 @@ func (s *VMService) deleteVMLocked(ctx context.Context, current model.VMRecord)
|
|||
if err := s.cleanupRuntime(ctx, vm, false); err != nil {
|
||||
return model.VMRecord{}, err
|
||||
}
|
||||
clearRuntimeTeardownState(&vm)
|
||||
op.stage("delete_store_record")
|
||||
if err := s.store.DeleteVM(ctx, vm.ID); err != nil {
|
||||
return model.VMRecord{}, err
|
||||
|
|
|
|||
|
|
@ -213,7 +213,7 @@ func (s *VMService) buildStartSteps(op *operationLog, sc *startContext) []startS
|
|||
sc.live.COWLoop = snapHandles.COWLoop
|
||||
sc.live.DMName = snapHandles.DMName
|
||||
sc.live.DMDev = snapHandles.DMDev
|
||||
s.setVMHandles(*sc.vm, *sc.live)
|
||||
s.setVMHandles(sc.vm, *sc.live)
|
||||
// Fields that used to land next to the (now-deleted)
|
||||
// cleanupOnErr closure. They belong with the DM
|
||||
// snapshot because that's the first step producing
|
||||
|
|
@ -282,10 +282,7 @@ func (s *VMService) buildStartSteps(op *operationLog, sc *startContext) []startS
|
|||
return err
|
||||
}
|
||||
sc.live.TapDevice = tap
|
||||
s.setVMHandles(*sc.vm, *sc.live)
|
||||
// Mirror onto VM.Runtime for NAT teardown resilience
|
||||
// across daemon crashes — see vm.Runtime.TapDevice docs.
|
||||
sc.vm.Runtime.TapDevice = tap
|
||||
s.setVMHandles(sc.vm, *sc.live)
|
||||
return nil
|
||||
},
|
||||
undo: func(ctx context.Context, sc *startContext) error {
|
||||
|
|
@ -360,11 +357,11 @@ func (s *VMService) buildStartSteps(op *operationLog, sc *startContext) []startS
|
|||
// PID so the undo can kill it; use a fresh ctx since
|
||||
// the request ctx may be cancelled by now.
|
||||
sc.live.PID = s.net.resolveFirecrackerPID(context.Background(), machine, sc.apiSock)
|
||||
s.setVMHandles(*sc.vm, *sc.live)
|
||||
s.setVMHandles(sc.vm, *sc.live)
|
||||
return err
|
||||
}
|
||||
sc.live.PID = s.net.resolveFirecrackerPID(context.Background(), machine, sc.apiSock)
|
||||
s.setVMHandles(*sc.vm, *sc.live)
|
||||
s.setVMHandles(sc.vm, *sc.live)
|
||||
op.debugStage("firecracker_started", "pid", sc.live.PID)
|
||||
return nil
|
||||
},
|
||||
|
|
|
|||
|
|
@ -175,6 +175,66 @@ func TestReconcileStopsStaleRunningVMAndClearsRuntimeHandles(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestReconcileWithCorruptHandlesFileFallsBackToPersistedRuntimeTeardownState(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx := context.Background()
|
||||
db := openDaemonStore(t)
|
||||
apiSock := filepath.Join(t.TempDir(), "fc.sock")
|
||||
if err := os.WriteFile(apiSock, []byte{}, 0o644); err != nil {
|
||||
t.Fatalf("WriteFile(api sock): %v", err)
|
||||
}
|
||||
vmDir := t.TempDir()
|
||||
vm := testVM("corrupt", "image-corrupt", "172.16.0.10")
|
||||
vm.State = model.VMStateRunning
|
||||
vm.Runtime.State = model.VMStateRunning
|
||||
vm.Runtime.APISockPath = apiSock
|
||||
vm.Runtime.VMDir = vmDir
|
||||
vm.Runtime.DNSName = ""
|
||||
vm.Runtime.TapDevice = "tap-fc-corrupt"
|
||||
vm.Runtime.BaseLoop = "/dev/loop20"
|
||||
vm.Runtime.COWLoop = "/dev/loop21"
|
||||
vm.Runtime.DMName = "fc-rootfs-corrupt"
|
||||
vm.Runtime.DMDev = "/dev/mapper/fc-rootfs-corrupt"
|
||||
upsertDaemonVM(t, ctx, db, vm)
|
||||
|
||||
if err := os.WriteFile(handlesFilePath(vmDir), []byte("{not json"), 0o600); err != nil {
|
||||
t.Fatalf("WriteFile(handles.json): %v", err)
|
||||
}
|
||||
|
||||
runner := &scriptedRunner{
|
||||
t: t,
|
||||
steps: []runnerStep{
|
||||
{call: runnerCall{name: "pgrep", args: []string{"-n", "-f", apiSock}}, err: errors.New("exit status 1")},
|
||||
sudoStep("", nil, "dmsetup", "remove", "fc-rootfs-corrupt"),
|
||||
sudoStep("", nil, "losetup", "-d", "/dev/loop21"),
|
||||
sudoStep("", nil, "losetup", "-d", "/dev/loop20"),
|
||||
sudoStep("", nil, "ip", "link", "del", "tap-fc-corrupt"),
|
||||
},
|
||||
}
|
||||
d := &Daemon{store: db, runner: runner}
|
||||
wireServices(d)
|
||||
|
||||
if err := d.reconcile(ctx); err != nil {
|
||||
t.Fatalf("reconcile: %v", err)
|
||||
}
|
||||
runner.assertExhausted()
|
||||
|
||||
got, err := db.GetVM(ctx, vm.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetVM: %v", err)
|
||||
}
|
||||
if got.State != model.VMStateStopped || got.Runtime.State != model.VMStateStopped {
|
||||
t.Fatalf("vm state after reconcile = %s/%s, want stopped", got.State, got.Runtime.State)
|
||||
}
|
||||
if got.Runtime.TapDevice != "" || got.Runtime.BaseLoop != "" || got.Runtime.COWLoop != "" || got.Runtime.DMName != "" || got.Runtime.DMDev != "" {
|
||||
t.Fatalf("runtime teardown state not cleared after reconcile: %+v", got.Runtime)
|
||||
}
|
||||
if _, err := os.Stat(handlesFilePath(vmDir)); !os.IsNotExist(err) {
|
||||
t.Fatalf("handles.json still present after reconcile: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRebuildDNSIncludesOnlyLiveRunningVMs(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue