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.
284 lines
8.8 KiB
Go
284 lines
8.8 KiB
Go
package daemon
|
|
|
|
import (
|
|
"fmt"
|
|
"log/slog"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
|
|
"banger/internal/guest"
|
|
"banger/internal/model"
|
|
"banger/internal/paths"
|
|
)
|
|
|
|
// Marker sentinels that fence the `Include` block banger writes into
|
|
// ~/.ssh/config when the user runs `banger ssh-config --install`.
|
|
const (
|
|
bangerSSHIncludeBegin = "# BEGIN BANGER SSH INCLUDE"
|
|
bangerSSHIncludeEnd = "# END BANGER SSH INCLUDE"
|
|
)
|
|
|
|
// removeVMKnownHosts drops every host-key pin for vm from the
|
|
// banger-owned known_hosts. Best-effort — a failure here only
|
|
// matters if the same IP/name is reused by a fresh VM before the
|
|
// next daemon restart, and even then it just causes a
|
|
// TOFU-mismatch error that the user can clear manually. Logged at
|
|
// warn so it shows up if it ever actually breaks things.
|
|
func removeVMKnownHosts(knownHostsPath string, vm model.VMRecord, logger *slog.Logger) {
|
|
if strings.TrimSpace(knownHostsPath) == "" {
|
|
return
|
|
}
|
|
var hosts []string
|
|
if ip := strings.TrimSpace(vm.Runtime.GuestIP); ip != "" {
|
|
hosts = append(hosts, ip)
|
|
}
|
|
if dns := strings.TrimSpace(vm.Runtime.DNSName); dns != "" {
|
|
hosts = append(hosts, dns)
|
|
}
|
|
if len(hosts) == 0 {
|
|
return
|
|
}
|
|
if err := guest.RemoveKnownHosts(knownHostsPath, hosts...); err != nil && logger != nil {
|
|
logger.Warn("remove known_hosts entries", "vm_id", vm.ID, "error", err.Error())
|
|
}
|
|
}
|
|
|
|
// BangerSSHConfigPath is the file banger owns and keeps in sync with
|
|
// the current default key + known_hosts locations. Users who want the
|
|
// `ssh <name>.vm` shortcut opt in via `banger ssh-config --install`,
|
|
// which adds an Include line to ~/.ssh/config pointing at this file.
|
|
// The daemon never touches ~/.ssh/config on its own.
|
|
func BangerSSHConfigPath(layout paths.Layout) string {
|
|
if strings.TrimSpace(layout.ConfigDir) == "" {
|
|
return ""
|
|
}
|
|
return filepath.Join(layout.ConfigDir, "ssh_config")
|
|
}
|
|
|
|
func (d *Daemon) ensureVMSSHClientConfig() {
|
|
if err := SyncVMSSHClientConfig(d.userLayout, d.config.SSHKeyPath); err != nil && d.logger != nil {
|
|
d.logger.Warn("vm ssh client config sync failed", "error", err.Error())
|
|
}
|
|
}
|
|
|
|
// syncVMSSHClientConfig writes banger's own ssh_config file with the
|
|
// current `Host *.vm` stanza. It does NOT touch ~/.ssh/config; that's
|
|
// the job of `banger ssh-config --install` (user-initiated).
|
|
//
|
|
// The file lives in the banger config dir so users who manage their
|
|
// SSH config declaratively can decide how (or whether) to pull it in.
|
|
func SyncVMSSHClientConfig(layout paths.Layout, keyPath string) error {
|
|
keyPath = strings.TrimSpace(keyPath)
|
|
if keyPath == "" {
|
|
return nil
|
|
}
|
|
target := BangerSSHConfigPath(layout)
|
|
if target == "" {
|
|
return nil
|
|
}
|
|
if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
|
|
return err
|
|
}
|
|
block := renderManagedVMSSHBlock(keyPath, layout.KnownHostsPath)
|
|
return writeTextFileIfChanged(target, block, 0o644)
|
|
}
|
|
|
|
// InstallUserSSHInclude adds an `Include <bangerSSHConfigPath>` line
|
|
// to ~/.ssh/config inside a banger-owned marker block. Idempotent:
|
|
// running it twice leaves a single block.
|
|
func InstallUserSSHInclude(layout paths.Layout) error {
|
|
bangerConfig := BangerSSHConfigPath(layout)
|
|
if bangerConfig == "" {
|
|
return fmt.Errorf("banger config dir is not configured")
|
|
}
|
|
userConfigPath, err := userSSHConfigPath()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
existing, err := readTextFileIfExists(userConfigPath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
block := renderBangerSSHIncludeBlock(bangerConfig)
|
|
updated, err := upsertManagedBlock(existing, bangerSSHIncludeBegin, bangerSSHIncludeEnd, block)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return writeTextFileIfChanged(userConfigPath, updated, 0o600)
|
|
}
|
|
|
|
// UninstallUserSSHInclude removes the Include block from
|
|
// ~/.ssh/config. Idempotent: missing file or missing block is a
|
|
// no-op.
|
|
func UninstallUserSSHInclude() error {
|
|
userConfigPath, err := userSSHConfigPath()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
existing, err := readTextFileIfExists(userConfigPath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if existing == "" {
|
|
return nil
|
|
}
|
|
updated, err := removeManagedBlock(existing, bangerSSHIncludeBegin, bangerSSHIncludeEnd)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return writeTextFileIfChanged(userConfigPath, updated, 0o600)
|
|
}
|
|
|
|
// UserSSHIncludeInstalled reports whether ~/.ssh/config contains the
|
|
// banger Include block. Used by `ssh-config` (status readout) and
|
|
// `doctor`.
|
|
func UserSSHIncludeInstalled() (bool, error) {
|
|
userConfigPath, err := userSSHConfigPath()
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
existing, err := readTextFileIfExists(userConfigPath)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
return strings.Contains(existing, bangerSSHIncludeBegin), nil
|
|
}
|
|
|
|
func userSSHConfigPath() (string, error) {
|
|
home, err := os.UserHomeDir()
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return filepath.Join(home, ".ssh", "config"), nil
|
|
}
|
|
|
|
// renderManagedVMSSHBlock produces the body banger writes into its
|
|
// own ssh_config file. Host-key verification uses the banger-owned
|
|
// known_hosts — NOT the user's ~/.ssh/known_hosts, and NOT /dev/null.
|
|
// `accept-new` means first contact pins the key; any later mismatch
|
|
// fails the connect.
|
|
func renderManagedVMSSHBlock(keyPath, knownHostsPath string) string {
|
|
keyPath = strings.TrimSpace(keyPath)
|
|
knownHostsPath = strings.TrimSpace(knownHostsPath)
|
|
lines := []string{
|
|
"# Generated by banger. Edits will be overwritten on daemon start.",
|
|
"# Enable the `ssh <name>.vm` shortcut via `banger ssh-config --install`.",
|
|
"Host *.vm",
|
|
" User root",
|
|
" IdentityFile " + keyPath,
|
|
" IdentitiesOnly yes",
|
|
" BatchMode yes",
|
|
" PreferredAuthentications publickey",
|
|
" PasswordAuthentication no",
|
|
" KbdInteractiveAuthentication no",
|
|
}
|
|
if knownHostsPath != "" {
|
|
lines = append(lines,
|
|
" UserKnownHostsFile "+knownHostsPath,
|
|
" StrictHostKeyChecking accept-new",
|
|
)
|
|
} else {
|
|
// Missing known_hosts path is a configuration anomaly — fail
|
|
// closed rather than silently disable verification.
|
|
lines = append(lines, " StrictHostKeyChecking yes")
|
|
}
|
|
lines = append(lines, " LogLevel ERROR", "")
|
|
return strings.Join(lines, "\n")
|
|
}
|
|
|
|
// renderBangerSSHIncludeBlock returns the marker-fenced block that
|
|
// `ssh-config --install` writes into ~/.ssh/config.
|
|
func renderBangerSSHIncludeBlock(bangerConfigPath string) string {
|
|
lines := []string{
|
|
bangerSSHIncludeBegin,
|
|
"# Added by `banger ssh-config --install`. Remove with",
|
|
"# `banger ssh-config --uninstall`, or delete the whole block.",
|
|
"Include " + bangerConfigPath,
|
|
bangerSSHIncludeEnd,
|
|
"",
|
|
}
|
|
return strings.Join(lines, "\n")
|
|
}
|
|
|
|
// upsertManagedBlock replaces an existing marker-fenced block with
|
|
// `block` (including the begin/end markers), or appends `block` if
|
|
// no such block exists. `block` must contain the markers itself.
|
|
func upsertManagedBlock(existing, beginMarker, endMarker, block string) (string, error) {
|
|
existing = normalizeConfigText(existing)
|
|
block = normalizeConfigText(block)
|
|
|
|
start := strings.Index(existing, beginMarker)
|
|
if start >= 0 {
|
|
end := strings.Index(existing[start:], endMarker)
|
|
if end < 0 {
|
|
return "", fmt.Errorf("managed block %q is missing end marker %q", beginMarker, endMarker)
|
|
}
|
|
end += start + len(endMarker)
|
|
for end < len(existing) && existing[end] == '\n' {
|
|
end++
|
|
}
|
|
existing = strings.TrimRight(existing[:start]+existing[end:], "\n")
|
|
}
|
|
|
|
if strings.TrimSpace(existing) == "" {
|
|
return block, nil
|
|
}
|
|
return strings.TrimRight(existing, "\n") + "\n\n" + block, nil
|
|
}
|
|
|
|
// removeManagedBlock strips a marker-fenced block from existing text
|
|
// and returns the result (unchanged if no block is present). Missing
|
|
// end marker with present begin marker is treated as corruption.
|
|
func removeManagedBlock(existing, beginMarker, endMarker string) (string, error) {
|
|
existing = normalizeConfigText(existing)
|
|
start := strings.Index(existing, beginMarker)
|
|
if start < 0 {
|
|
return existing, nil
|
|
}
|
|
end := strings.Index(existing[start:], endMarker)
|
|
if end < 0 {
|
|
return "", fmt.Errorf("managed block %q is missing end marker %q", beginMarker, endMarker)
|
|
}
|
|
end += start + len(endMarker)
|
|
for end < len(existing) && existing[end] == '\n' {
|
|
end++
|
|
}
|
|
stripped := strings.TrimRight(existing[:start]+existing[end:], "\n")
|
|
return normalizeConfigText(stripped), nil
|
|
}
|
|
|
|
func normalizeConfigText(text string) string {
|
|
text = strings.ReplaceAll(text, "\r\n", "\n")
|
|
text = strings.TrimRight(text, "\n")
|
|
if text == "" {
|
|
return ""
|
|
}
|
|
return text + "\n"
|
|
}
|
|
|
|
func readTextFileIfExists(path string) (string, error) {
|
|
data, err := os.ReadFile(path)
|
|
if err == nil {
|
|
return string(data), nil
|
|
}
|
|
if os.IsNotExist(err) {
|
|
return "", nil
|
|
}
|
|
return "", err
|
|
}
|
|
|
|
func writeTextFileIfChanged(path, content string, mode os.FileMode) error {
|
|
content = normalizeConfigText(content)
|
|
existing, err := readTextFileIfExists(path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if existing == content {
|
|
return nil
|
|
}
|
|
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
|
|
return err
|
|
}
|
|
return os.WriteFile(path, []byte(content), mode)
|
|
}
|