package daemon import ( "context" "fmt" "os" "path/filepath" "strconv" "strings" "banger/internal/guestconfig" "banger/internal/guestnet" "banger/internal/model" "banger/internal/system" ) type workDiskPreparation struct { ClonedFromSeed bool } func (s *VMService) ensureSystemOverlay(ctx context.Context, vm *model.VMRecord) error { if exists(vm.Runtime.SystemOverlay) { return nil } _, err := s.runner.Run(ctx, "truncate", "-s", strconv.FormatInt(vm.Spec.SystemOverlaySizeByte, 10), vm.Runtime.SystemOverlay) return err } // patchRootOverlay writes the per-VM config files (resolv.conf, // hostname, hosts, sshd drop-in, network bootstrap, fstab) into the // rootfs overlay. Reads the DM device path from the handle cache, // which the start flow populates before calling this. func (s *VMService) patchRootOverlay(ctx context.Context, vm model.VMRecord, image model.Image) error { dmDev := s.vmHandles(vm.ID).DMDev if dmDev == "" { return fmt.Errorf("vm %q: DM device not in handle cache — start flow out of order?", vm.ID) } resolv := []byte(fmt.Sprintf("nameserver %s\n", s.config.DefaultDNS)) hostname := []byte(vm.Name + "\n") hosts := []byte(fmt.Sprintf("127.0.0.1 localhost\n127.0.1.1 %s\n", vm.Name)) sshdConfig := []byte(sshdGuestConfig()) fstab, err := system.ReadDebugFSText(ctx, s.runner, dmDev, "/etc/fstab") if err != nil { fstab = "" } builder := guestconfig.NewBuilder() builder.WriteFile("/etc/resolv.conf", resolv) builder.WriteFile("/etc/hostname", hostname) builder.WriteFile("/etc/hosts", hosts) builder.WriteFile(guestnet.ConfigPath, guestnet.ConfigFile(vm.Runtime.GuestIP, s.config.BridgeIP, s.config.DefaultDNS)) builder.WriteFile(guestnet.GuestScriptPath, []byte(guestnet.BootstrapScript())) builder.WriteFile("/etc/ssh/sshd_config.d/99-banger.conf", sshdConfig) builder.DropMountTarget("/home") builder.DropMountTarget("/var") builder.AddMount(guestconfig.MountSpec{ Source: "tmpfs", Target: "/run", FSType: "tmpfs", Options: []string{"defaults", "nodev", "nosuid", "mode=0755"}, Dump: 0, Pass: 0, }) builder.AddMount(guestconfig.MountSpec{ Source: "tmpfs", Target: "/tmp", FSType: "tmpfs", Options: []string{"defaults", "nodev", "nosuid", "mode=1777"}, Dump: 0, Pass: 0, }) s.capHooks.contributeGuest(builder, vm, image) builder.WriteFile("/etc/fstab", []byte(builder.RenderFSTab(fstab))) files := builder.Files() for _, guestPath := range builder.FilePaths() { data := files[guestPath] if guestPath == guestnet.GuestScriptPath { if err := system.WriteExt4FileMode(ctx, s.runner, dmDev, guestPath, 0o755, data); err != nil { return err } continue } if err := system.WriteExt4File(ctx, s.runner, dmDev, guestPath, data); err != nil { return err } } return nil } func (s *VMService) ensureWorkDisk(ctx context.Context, vm *model.VMRecord, image model.Image) (workDiskPreparation, error) { if exists(vm.Runtime.WorkDiskPath) { return workDiskPreparation{}, nil } if exists(image.WorkSeedPath) { vmCreateStage(ctx, "prepare_work_disk", "cloning work seed") if err := system.CopyFilePreferClone(image.WorkSeedPath, vm.Runtime.WorkDiskPath); err != nil { return workDiskPreparation{}, err } seedInfo, err := os.Stat(image.WorkSeedPath) if err != nil { return workDiskPreparation{}, err } if vm.Spec.WorkDiskSizeBytes < seedInfo.Size() { return workDiskPreparation{}, fmt.Errorf("requested work disk size %d is smaller than seed image %d", vm.Spec.WorkDiskSizeBytes, seedInfo.Size()) } if vm.Spec.WorkDiskSizeBytes > seedInfo.Size() { vmCreateStage(ctx, "prepare_work_disk", "resizing work disk") if err := system.ResizeExt4Image(ctx, s.runner, vm.Runtime.WorkDiskPath, vm.Spec.WorkDiskSizeBytes); err != nil { return workDiskPreparation{}, err } } return workDiskPreparation{ClonedFromSeed: true}, nil } // No seed: build an empty work disk. `-E root_owner=0:0` stamps // inode 2 (the fs root, which becomes /root inside the guest) as // root:root:0755 up front. sshd's StrictModes walks that dir's // ownership and mode, so getting it right from mkfs means the // authsync step can just write authorized_keys without any // repair pass. // // Unlike the pre-refactor flow there is no "copy /root from the // base rootfs" step. The no-seed path is the degraded fallback // (the common case has a work-seed artifact and hits the branch // above). Dropping the copy eliminates 4 sudo call sites — mount // base ro, mount work rw, sudo cp -a, flattenNestedWorkHome — // at the cost of losing default distro dotfiles on no-seed VMs. // Users who need those should either rebuild the image with a // work-seed (the documented path) or land them via [[file_sync]]. vmCreateStage(ctx, "prepare_work_disk", "creating empty work disk") if _, err := s.runner.Run(ctx, "truncate", "-s", strconv.FormatInt(vm.Spec.WorkDiskSizeBytes, 10), vm.Runtime.WorkDiskPath); err != nil { return workDiskPreparation{}, err } if _, err := s.runner.Run(ctx, "mkfs.ext4", "-F", "-E", "root_owner=0:0", vm.Runtime.WorkDiskPath); err != nil { return workDiskPreparation{}, err } return workDiskPreparation{}, nil } // sshdGuestConfig is the banger-authored drop-in that lands at // /etc/ssh/sshd_config.d/99-banger.conf inside every guest. // // Banger VMs are single-user root sandboxes reachable only through the // host bridge (default 172.16.0.0/24). The drop-in sets the minimum // needed to make that usable while keeping the posture tight enough // that a misconfigured host bridge does not immediately hand over an // unauthenticated root shell. // // Why each line is here: // // - PermitRootLogin prohibit-password // The guest IS root — there's no other account. prohibit-password // allows pubkey login and blocks password auth at the source even // if some future config flips PasswordAuthentication on. // // - PubkeyAuthentication yes // The only auth method we expect. Explicit in case a future // Debian default or distro package flips it off. // // - PasswordAuthentication no // // - KbdInteractiveAuthentication no // Belt-and-braces: every interactive auth path is off, not just // the PermitRootLogin path. These are already Debian defaults but // stating them here means the drop-in documents the intent. // // - AuthorizedKeysFile /root/.ssh/authorized_keys // Pins the lookup path so the banger-written file always wins, // regardless of distro default ($HOME/.ssh/authorized_keys) and // regardless of any per-image weirdness. func sshdGuestConfig() string { return strings.Join([]string{ "PermitRootLogin prohibit-password", "PubkeyAuthentication yes", "PasswordAuthentication no", "KbdInteractiveAuthentication no", "AuthorizedKeysFile /root/.ssh/authorized_keys", "", }, "\n") } // flattenNestedWorkHome is a package-level helper used by the image, // workspace-sync, and VM-disk paths, so it takes the runner explicitly // rather than belonging to any one service struct. func flattenNestedWorkHome(ctx context.Context, runner system.CommandRunner, workMount string) error { nestedHome := filepath.Join(workMount, "root") if !exists(nestedHome) { return nil } if _, err := runner.RunSudo(ctx, "chmod", "755", nestedHome); err != nil { return err } entries, err := os.ReadDir(nestedHome) if err != nil { return err } for _, entry := range entries { sourcePath := filepath.Join(nestedHome, entry.Name()) if _, err := runner.RunSudo(ctx, "cp", "-a", sourcePath, workMount+"/"); err != nil { return err } } _, err = runner.RunSudo(ctx, "rm", "-rf", nestedHome) return err }