banger/internal/daemon/vm_authsync.go
Thales Maciel f0685366ec
daemon: rewrite authsync + image seeding on ext4 toolkit
ensureAuthorizedKeyOnWorkDisk and seedAuthorizedKeyOnExt4Image both
drove mount + sudo mkdir/chmod/chown/cat/install to patch
/.ssh/authorized_keys into a work disk or work-seed. Both now delegate
to a shared provisionAuthorizedKey helper that uses the ext4 toolkit
introduced in 7704396 — EnsureExt4RootPerms + MkdirExt4 +
Ext4PathExists/ReadExt4File + WriteExt4FileOwned. No mount, no sudo,
no host-path staging.

Drops ~10 sudo call sites from the VM create and image pull flows
and deletes the TestEnsureAuthorizedKeyOnWorkDiskRepairsNestedRootLayout
premise (flattenNestedWorkHome will disappear entirely in the next
commit — the no-seed path no longer copies /root, and the work-seed
path produces flat seeds).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 18:21:50 -03:00

405 lines
13 KiB
Go

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")
workDisk := vm.Runtime.WorkDiskPath
if err := provisionAuthorizedKey(ctx, s.runner, workDisk, publicKey); 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
}
// provisionAuthorizedKey writes the managed SSH key into
// /.ssh/authorized_keys on an ext4 image via the sudoless toolkit.
// Shared between work-disk and image-seed paths — both need the same
// sequence: normalise fs-root perms, create /.ssh, merge against any
// existing authorized_keys, rewrite with root:root:0600.
//
// The fs root doubles as /root inside the guest, which sshd walks
// under StrictModes; forcing 0755 root:root here keeps a drifted
// seed image from silently rejecting the key at login time.
func provisionAuthorizedKey(ctx context.Context, runner system.CommandRunner, imagePath string, publicKey []byte) error {
if err := system.EnsureExt4RootPerms(ctx, runner, imagePath, 0o755, 0, 0); err != nil {
return err
}
if err := system.MkdirExt4(ctx, runner, imagePath, "/.ssh", 0o700, 0, 0); err != nil {
return err
}
var existing []byte
exists, err := system.Ext4PathExists(ctx, runner, imagePath, "/.ssh/authorized_keys")
if err != nil {
return err
}
if exists {
existing, err = system.ReadExt4File(ctx, runner, imagePath, "/.ssh/authorized_keys")
if err != nil {
return err
}
}
merged := mergeAuthorizedKey(existing, publicKey)
return system.WriteExt4FileOwned(ctx, runner, imagePath, "/.ssh/authorized_keys", 0o600, 0, 0, merged)
}
// 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>` (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")
}