package daemon import ( "context" "errors" "fmt" "os" "path/filepath" "strings" "banger/internal/guest" "banger/internal/model" "banger/internal/system" ) const ( workDiskGitConfigRelativePath = ".gitconfig" hostGlobalGitIdentitySource = "git config --global" ) type gitIdentity struct { Name string Email string } func (s *WorkspaceService) ensureAuthorizedKeyOnWorkDisk(ctx context.Context, vm *model.VMRecord, image model.Image, prep workDiskPreparation) error { fingerprint, err := guest.AuthorizedPublicKeyFingerprint(s.config.SSHKeyPath) if err != nil { return fmt.Errorf("derive authorized ssh key fingerprint: %w", err) } if prep.ClonedFromSeed && image.SeededSSHPublicKeyFingerprint != "" && image.SeededSSHPublicKeyFingerprint == fingerprint { vmCreateStage(ctx, "prepare_work_disk", "using seeded SSH access") return nil } publicKey, err := guest.AuthorizedPublicKey(s.config.SSHKeyPath) if err != nil { return fmt.Errorf("derive authorized ssh key: %w", err) } vmCreateStage(ctx, "prepare_work_disk", "provisioning SSH access on work disk") workMount, cleanupWork, err := system.MountTempDir(ctx, s.runner, vm.Runtime.WorkDiskPath, false) if err != nil { return err } defer cleanupWork() if err := flattenNestedWorkHome(ctx, s.runner, workMount); err != nil { return err } // Normalise the work-disk filesystem root: inside the guest this // mounts at /root, which sshd inspects when StrictModes is on (the // default after the hardening drop-in). Any drift — owner != root, // group/other-writable — would make sshd silently reject the key. if err := normaliseHomeDirPerms(ctx, s.runner, workMount); err != nil { return err } sshDir := filepath.Join(workMount, ".ssh") if _, err := s.runner.RunSudo(ctx, "mkdir", "-p", sshDir); err != nil { return err } if _, err := s.runner.RunSudo(ctx, "chmod", "700", sshDir); err != nil { return err } if _, err := s.runner.RunSudo(ctx, "chown", "0:0", sshDir); err != nil { return err } authorizedKeysPath := filepath.Join(sshDir, "authorized_keys") existing, err := s.runner.RunSudo(ctx, "cat", authorizedKeysPath) if err != nil { existing = nil } merged := mergeAuthorizedKey(existing, publicKey) tmpFile, err := os.CreateTemp("", "banger-authorized-keys-*") if err != nil { return err } tmpPath := tmpFile.Name() if _, err := tmpFile.Write(merged); err != nil { _ = tmpFile.Close() _ = os.Remove(tmpPath) return err } if err := tmpFile.Close(); err != nil { _ = os.Remove(tmpPath) return err } defer os.Remove(tmpPath) if _, err := s.runner.RunSudo(ctx, "install", "-m", "600", tmpPath, authorizedKeysPath); err != nil { return err } if prep.ClonedFromSeed && image.Managed { vmCreateStage(ctx, "prepare_work_disk", "refreshing managed work seed") if err := s.imageWorkSeed(ctx, image, fingerprint); err != nil { return err } } return nil } // normaliseHomeDirPerms forces the home-directory mount point to // 0755 root:root. sshd's StrictModes (the default, re-enabled after // banger stopped shipping "StrictModes no") rejects authorized_keys // if the user's HOME — here the work-disk filesystem root — is // group/other-writable or owned by anyone other than root. mkfs.ext4 // normally creates an ext4 root dir at 0755 root:root, but older // work-seed images may have drifted, and `cp -a` on a non-standard // source can carry weird bits forward. Forcing a known-good state // here is cheap insurance. func normaliseHomeDirPerms(ctx context.Context, runner system.CommandRunner, workMount string) error { if _, err := runner.RunSudo(ctx, "chown", "0:0", workMount); err != nil { return err } if _, err := runner.RunSudo(ctx, "chmod", "0755", workMount); err != nil { return err } return nil } func (s *WorkspaceService) ensureGitIdentityOnWorkDisk(ctx context.Context, vm *model.VMRecord) error { runner := s.runner if runner == nil { runner = system.NewRunner() } identity, err := resolveHostGlobalGitIdentity(ctx, runner) if err != nil { s.warnGitIdentitySyncSkipped(*vm, hostGlobalGitIdentitySource, err) return nil } vmCreateStage(ctx, "prepare_work_disk", "syncing git identity") workMount, cleanupWork, err := system.MountTempDir(ctx, runner, vm.Runtime.WorkDiskPath, false) if err != nil { return err } defer cleanupWork() if err := flattenNestedWorkHome(ctx, s.runner, workMount); err != nil { return err } return writeGitIdentity(ctx, runner, filepath.Join(workMount, workDiskGitConfigRelativePath), identity) } // runFileSync applies every [[file_sync]] entry from the daemon config // to the VM's work disk. Missing host paths are skipped with a warn. // Other errors abort the VM create (since the user explicitly asked // for the sync). // // File entries: `install -o 0 -g 0 -m ` (mode defaults to 0600). // Directory entries: walked in Go — each file is installed with its // source permissions, each subdir is mkdir'd. The entry's `mode` // field is only honoured for file entries. func (s *WorkspaceService) runFileSync(ctx context.Context, vm *model.VMRecord) error { if len(s.config.FileSync) == 0 { return nil } runner := s.runner if runner == nil { runner = system.NewRunner() } hostHome, err := os.UserHomeDir() if err != nil { return fmt.Errorf("resolve host user home: %w", err) } // Mount the work disk once and reuse for every entry. var workMount string var cleanupWork func() error ensureMount := func() (string, error) { if workMount != "" { return workMount, nil } m, c, err := system.MountTempDir(ctx, runner, vm.Runtime.WorkDiskPath, false) if err != nil { return "", err } workMount = m cleanupWork = c if err := flattenNestedWorkHome(ctx, s.runner, workMount); err != nil { return "", err } return workMount, nil } defer func() { if cleanupWork != nil { cleanupWork() } }() for _, entry := range s.config.FileSync { hostPath := expandHostPath(entry.Host, hostHome) guestRel := guestPathRelativeToRoot(entry.Guest) info, err := os.Stat(hostPath) if err != nil { if os.IsNotExist(err) { s.warnFileSyncSkipped(*vm, hostPath, err) continue } return fmt.Errorf("file_sync: stat %s: %w", hostPath, err) } mount, err := ensureMount() if err != nil { return err } vmCreateStage(ctx, "prepare_work_disk", "file sync: "+entry.Host+" → "+entry.Guest) target := filepath.Join(mount, guestRel) if _, err := runner.RunSudo(ctx, "mkdir", "-p", filepath.Dir(target)); err != nil { return fmt.Errorf("file_sync: mkdir %s: %w", filepath.Dir(target), err) } if info.IsDir() { if err := s.copyHostDir(ctx, *vm, runner, hostPath, target); err != nil { return fmt.Errorf("file_sync: copy directory %s → %s: %w", hostPath, target, err) } continue } mode := entry.Mode if mode == "" { mode = "0600" } if _, err := runner.RunSudo(ctx, "install", "-o", "0", "-g", "0", "-m", mode, hostPath, target); err != nil { return fmt.Errorf("file_sync: install %s → %s: %w", hostPath, target, err) } } return nil } // copyHostDir recursively copies hostDir into guestTarget using only // `mkdir` (for subdirs) and `install` (for files). Each file's source // permissions are preserved; ownership is forced to root:root via // `install -o 0 -g 0`. Symlinks encountered during recursion are // SKIPPED with a warning — `os.Lstat` tells us the entry itself is a // link without resolving it, so a symlink inside ~/.aws that points // at ~/secrets can't leak out of the tree the user named. Other // special types (devices, FIFOs) are skipped silently. Top-level // host paths go through `os.Stat` back in runFileSync and still // follow, since the user explicitly named that path. func (s *WorkspaceService) copyHostDir(ctx context.Context, vm model.VMRecord, runner system.CommandRunner, hostDir, guestTarget string) error { if _, err := runner.RunSudo(ctx, "mkdir", "-p", guestTarget); err != nil { return err } entries, err := os.ReadDir(hostDir) if err != nil { return err } for _, entry := range entries { hostChild := filepath.Join(hostDir, entry.Name()) guestChild := filepath.Join(guestTarget, entry.Name()) info, err := os.Lstat(hostChild) if err != nil { return err } switch { case info.Mode()&os.ModeSymlink != 0: s.warnFileSyncSymlinkSkipped(vm, hostChild) case info.IsDir(): if err := s.copyHostDir(ctx, vm, runner, hostChild, guestChild); err != nil { return err } case info.Mode().IsRegular(): mode := fmt.Sprintf("%04o", info.Mode().Perm()) if _, err := runner.RunSudo(ctx, "install", "-o", "0", "-g", "0", "-m", mode, hostChild, guestChild); err != nil { return err } } } return nil } // expandHostPath expands a leading "~/" against the host user's // home. Already-absolute paths pass through unchanged. func expandHostPath(raw, home string) string { raw = strings.TrimSpace(raw) if strings.HasPrefix(raw, "~/") { return filepath.Join(home, strings.TrimPrefix(raw, "~/")) } return raw } // guestPathRelativeToRoot returns the guest path as a relative path // under /root (banger's work disk is mounted at /root in the guest, // so everything syncable lives there). "~/foo" and "/root/foo" both // return "foo"; config validation rejects anything outside that // scope, so the string prefixes are the only forms we see here. func guestPathRelativeToRoot(raw string) string { raw = strings.TrimSpace(raw) switch { case raw == "~" || raw == "/root": return "" case strings.HasPrefix(raw, "~/"): return strings.TrimPrefix(raw, "~/") case strings.HasPrefix(raw, "/root/"): return strings.TrimPrefix(raw, "/root/") } return raw } func resolveHostGlobalGitIdentity(ctx context.Context, runner system.CommandRunner) (gitIdentity, error) { name, err := gitConfigValue(ctx, runner, nil, "user.name") if err != nil { return gitIdentity{}, err } if name == "" { return gitIdentity{}, errors.New("host git user.name is empty") } email, err := gitConfigValue(ctx, runner, nil, "user.email") if err != nil { return gitIdentity{}, err } if email == "" { return gitIdentity{}, errors.New("host git user.email is empty") } return gitIdentity{Name: name, Email: email}, nil } func gitConfigValue(ctx context.Context, runner system.CommandRunner, extraArgs []string, key string) (string, error) { args := []string{"config"} args = append(args, extraArgs...) args = append(args, "--default", "", "--get", key) out, err := runner.Run(ctx, "git", args...) if err != nil { return "", err } return strings.TrimSpace(string(out)), nil } func writeGitIdentity(ctx context.Context, runner system.CommandRunner, gitConfigPath string, identity gitIdentity) error { existing, err := runner.RunSudo(ctx, "cat", gitConfigPath) if err != nil { existing = nil } tmpFile, err := os.CreateTemp("", "banger-gitconfig-*") if err != nil { return err } tmpPath := tmpFile.Name() if _, err := tmpFile.Write(existing); err != nil { _ = tmpFile.Close() _ = os.Remove(tmpPath) return err } if err := tmpFile.Close(); err != nil { _ = os.Remove(tmpPath) return err } defer os.Remove(tmpPath) if _, err := runner.Run(ctx, "git", "config", "--file", tmpPath, "user.name", identity.Name); err != nil { return err } if _, err := runner.Run(ctx, "git", "config", "--file", tmpPath, "user.email", identity.Email); err != nil { return err } _, err = runner.RunSudo(ctx, "install", "-m", "644", tmpPath, gitConfigPath) return err } func (s *WorkspaceService) warnFileSyncSkipped(vm model.VMRecord, hostPath string, err error) { if s.logger == nil || err == nil { return } s.logger.Warn("file_sync skipped", append(vmLogAttrs(vm), "host_path", hostPath, "error", err.Error())...) } // warnFileSyncSymlinkSkipped surfaces a skipped nested symlink to the // user through the daemon log. Skipping is deliberate — see // copyHostDir's docstring — but invisible skips would hide a // "why did my file not show up in the guest?" debugging trail. func (s *WorkspaceService) warnFileSyncSymlinkSkipped(vm model.VMRecord, hostPath string) { if s.logger == nil { return } s.logger.Warn("file_sync skipped symlink (would escape the requested tree)", append(vmLogAttrs(vm), "host_path", hostPath)...) } func (s *WorkspaceService) warnGitIdentitySyncSkipped(vm model.VMRecord, source string, err error) { if s.logger == nil || err == nil { return } s.logger.Warn("guest git identity sync skipped", append(vmLogAttrs(vm), "source", source, "error", err.Error())...) } func mergeAuthorizedKey(existing, managed []byte) []byte { managedLine := strings.TrimSpace(string(managed)) if managedLine == "" { return append([]byte(nil), existing...) } lines := strings.Split(strings.ReplaceAll(string(existing), "\r\n", "\n"), "\n") out := make([]string, 0, len(lines)+1) found := false for _, line := range lines { line = strings.TrimRight(line, "\r") trimmed := strings.TrimSpace(line) if trimmed == "" { continue } if trimmed == managedLine { found = true } out = append(out, line) } if !found { out = append(out, managedLine) } return []byte(strings.Join(out, "\n") + "\n") }