banger/internal/daemon/vm_authsync.go
Thales Maciel 59e48e830b
daemon: split owner daemon from root helper
Move the supported systemd path to two services: an owner-user bangerd for
orchestration and a narrow root helper for bridge/tap, NAT/resolver, dm/loop,
and Firecracker ownership. This removes repeated sudo from daily vm and image
flows without leaving the general daemon running as root.

Add install metadata, system install/status/restart/uninstall commands, and a
system-owned runtime layout. Keep user SSH/config material in the owner home,
lock file_sync to the owner home, and move daemon known_hosts handling out of
the old root-owned control path.

Route privileged lifecycle steps through typed privilegedOps calls, harden the
two systemd units, and rewrite smoke plus docs around the supported service
model.

Verified with make build, make test, make lint, and make smoke on the
supported systemd host path.
2026-04-26 12:43:17 -03:00

395 lines
13 KiB
Go

package daemon
import (
"context"
"errors"
"fmt"
"os"
"path"
"path/filepath"
"strconv"
"strings"
"banger/internal/config"
"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)
}
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")
return writeGitIdentity(ctx, runner, vm.Runtime.WorkDiskPath, "/"+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).
//
// Operates directly on the ext4 image via the sudoless toolkit — no
// mount, no privileged install(1). Every write lands as root:root;
// file modes come from the [[file_sync]] entry (default 0600),
// directory modes from the source on the host.
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 := strings.TrimSpace(s.config.HostHomeDir)
if hostHome == "" {
var err error
hostHome, err = os.UserHomeDir()
if err != nil {
return fmt.Errorf("resolve host user home: %w", err)
}
}
workDisk := vm.Runtime.WorkDiskPath
for _, entry := range s.config.FileSync {
hostPath, err := config.ResolveFileSyncHostPath(entry.Host, hostHome)
if err != nil {
return fmt.Errorf("file_sync: %w", err)
}
guestRel := guestPathRelativeToRoot(entry.Guest)
guestImagePath := "/" + guestRel
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)
}
hostPath, err = config.ResolveExistingFileSyncHostPath(entry.Host, hostHome)
if err != nil {
return fmt.Errorf("file_sync: %w", err)
}
vmCreateStage(ctx, "prepare_work_disk", "file sync: "+entry.Host+" → "+entry.Guest)
parent := path.Dir(guestImagePath)
if parent != "/" && parent != "." {
if err := system.MkdirAllExt4(ctx, runner, workDisk, parent, 0o755, 0, 0); err != nil {
return fmt.Errorf("file_sync: mkdir %s: %w", parent, err)
}
}
if info.IsDir() {
if err := s.copyHostDir(ctx, *vm, runner, workDisk, hostPath, guestImagePath); err != nil {
return fmt.Errorf("file_sync: copy directory %s → %s: %w", hostPath, guestImagePath, err)
}
continue
}
mode, err := parseFileSyncMode(entry.Mode)
if err != nil {
return fmt.Errorf("file_sync: %s: %w", entry.Host, err)
}
data, err := os.ReadFile(hostPath)
if err != nil {
return fmt.Errorf("file_sync: read %s: %w", hostPath, err)
}
if err := system.WriteExt4FileOwned(ctx, runner, workDisk, guestImagePath, mode, 0, 0, data); err != nil {
return fmt.Errorf("file_sync: write %s → %s: %w", hostPath, guestImagePath, err)
}
}
return nil
}
// copyHostDir recursively copies hostDir into guestTarget on the
// ext4 image via the sudoless toolkit. Each file's source permissions
// are preserved; directories get 0755; ownership is forced to
// root:root. Symlinks are SKIPPED with a warning — os.Lstat identifies
// the entry itself as 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 may still follow, but only when the resolved target
// stays under the configured owner home.
func (s *WorkspaceService) copyHostDir(ctx context.Context, vm model.VMRecord, runner system.CommandRunner, imagePath, hostDir, guestTarget string) error {
if err := system.MkdirExt4(ctx, runner, imagePath, guestTarget, 0o755, 0, 0); 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 := path.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, imagePath, hostChild, guestChild); err != nil {
return err
}
case info.Mode().IsRegular():
data, err := os.ReadFile(hostChild)
if err != nil {
return err
}
if err := system.WriteExt4FileOwned(ctx, runner, imagePath, guestChild, info.Mode().Perm(), 0, 0, data); err != nil {
return err
}
}
}
return nil
}
// parseFileSyncMode parses the [[file_sync]] mode field (octal string,
// default "0600"). Returns the parsed FileMode with only the permission
// bits set; callers OR in S_IFREG via WriteExt4FileOwned.
func parseFileSyncMode(raw string) (os.FileMode, error) {
raw = strings.TrimSpace(raw)
if raw == "" {
raw = "0600"
}
v, err := strconv.ParseUint(raw, 8, 32)
if err != nil {
return 0, fmt.Errorf("parse mode %q: %w", raw, err)
}
return os.FileMode(v) & os.ModePerm, nil
}
// expandHostPath expands a leading "~/" against the host user's
// 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
}
// writeGitIdentity merges user.name + user.email into the on-image
// gitconfig at guestPath. Reads the existing bytes via the ext4
// toolkit (no-op to empty if absent), edits via `git config --file`
// on a host tempfile so any pre-existing unrelated sections are
// preserved verbatim, then writes back through WriteExt4FileOwned
// at 0644 root:root.
func writeGitIdentity(ctx context.Context, runner system.CommandRunner, imagePath, guestPath string, identity gitIdentity) error {
var existing []byte
exists, err := system.Ext4PathExists(ctx, runner, imagePath, guestPath)
if err != nil {
return err
}
if exists {
existing, err = system.ReadExt4File(ctx, runner, imagePath, guestPath)
if err != nil {
return err
}
}
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
}
merged, err := os.ReadFile(tmpPath)
if err != nil {
return err
}
return system.WriteExt4FileOwned(ctx, runner, imagePath, guestPath, 0o644, 0, 0, merged)
}
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")
}