package daemon import ( "context" "errors" "fmt" "os" "path" "path/filepath" "strconv" "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) } 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, 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 := expandHostPath(entry.Host, hostHome) 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) } 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 still follow, since the user explicitly named that // path. 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 // 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 } // 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") }