banger/internal/config/config.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

439 lines
15 KiB
Go

package config
import (
"crypto/ed25519"
"crypto/rand"
"crypto/x509"
"encoding/pem"
"fmt"
"os"
"path/filepath"
"strings"
"time"
toml "github.com/pelletier/go-toml"
"golang.org/x/crypto/ssh"
"banger/internal/model"
"banger/internal/paths"
"banger/internal/system"
)
type fileConfig struct {
LogLevel string `toml:"log_level"`
FirecrackerBin string `toml:"firecracker_bin"`
SSHKeyPath string `toml:"ssh_key_path"`
DefaultImageName string `toml:"default_image_name"`
AutoStopStaleAfter string `toml:"auto_stop_stale_after"`
StatsPollInterval string `toml:"stats_poll_interval"`
BridgeName string `toml:"bridge_name"`
BridgeIP string `toml:"bridge_ip"`
CIDR string `toml:"cidr"`
TapPoolSize int `toml:"tap_pool_size"`
DefaultDNS string `toml:"default_dns"`
FileSync []fileSyncEntryFile `toml:"file_sync"`
VMDefaults *vmDefaultsFile `toml:"vm_defaults"`
}
type fileSyncEntryFile struct {
Host string `toml:"host"`
Guest string `toml:"guest"`
Mode string `toml:"mode"`
}
// vmDefaultsFile mirrors the optional `[vm_defaults]` block. All
// fields are zero-valued when omitted; the resolver treats zero as
// "not set, compute from host or fall back to builtin constants."
type vmDefaultsFile struct {
VCPUCount int `toml:"vcpu"`
MemoryMiB int `toml:"memory_mib"`
DiskSize string `toml:"disk_size"`
SystemOverlaySize string `toml:"system_overlay_size"`
}
func Load(layout paths.Layout) (model.DaemonConfig, error) {
home, err := os.UserHomeDir()
if err != nil {
return model.DaemonConfig{}, err
}
return load(layout, home, true)
}
func LoadDaemon(layout paths.Layout, ownerHome string) (model.DaemonConfig, error) {
return load(layout, ownerHome, false)
}
func load(layout paths.Layout, home string, ensureDefaultSSHKey bool) (model.DaemonConfig, error) {
cfg := model.DaemonConfig{
LogLevel: "info",
AutoStopStaleAfter: 0,
StatsPollInterval: model.DefaultStatsPollInterval,
BridgeName: model.DefaultBridgeName,
BridgeIP: model.DefaultBridgeIP,
CIDR: model.DefaultCIDR,
TapPoolSize: 4,
DefaultDNS: model.DefaultDNS,
DefaultImageName: "debian-bookworm",
HostHomeDir: home,
}
var file fileConfig
configPath := filepath.Join(layout.ConfigDir, "config.toml")
if info, err := os.Stat(configPath); err == nil && !info.IsDir() {
data, err := os.ReadFile(configPath)
if err != nil {
return cfg, err
}
if err := toml.Unmarshal(data, &file); err != nil {
return cfg, err
}
} else if err != nil && !os.IsNotExist(err) {
return cfg, err
}
if value := strings.TrimSpace(file.LogLevel); value != "" {
cfg.LogLevel = value
}
if value := strings.TrimSpace(file.FirecrackerBin); value != "" {
cfg.FirecrackerBin = value
} else if path, err := system.LookupExecutable("firecracker"); err == nil {
cfg.FirecrackerBin = path
}
if value := strings.TrimSpace(file.DefaultImageName); value != "" {
cfg.DefaultImageName = value
}
if value := strings.TrimSpace(file.BridgeName); value != "" {
cfg.BridgeName = value
}
if value := strings.TrimSpace(file.BridgeIP); value != "" {
cfg.BridgeIP = value
}
if value := strings.TrimSpace(file.CIDR); value != "" {
cfg.CIDR = value
}
if file.TapPoolSize > 0 {
cfg.TapPoolSize = file.TapPoolSize
}
if value := strings.TrimSpace(file.DefaultDNS); value != "" {
cfg.DefaultDNS = value
}
if value := strings.TrimSpace(file.AutoStopStaleAfter); value != "" {
duration, err := time.ParseDuration(value)
if err != nil {
return cfg, err
}
cfg.AutoStopStaleAfter = duration
}
if value := strings.TrimSpace(file.StatsPollInterval); value != "" {
duration, err := time.ParseDuration(value)
if err != nil {
return cfg, err
}
cfg.StatsPollInterval = duration
}
if value := strings.TrimSpace(os.Getenv("BANGER_LOG_LEVEL")); value != "" {
cfg.LogLevel = value
}
sshKeyPath, err := resolveSSHKeyPath(layout, file.SSHKeyPath, home, ensureDefaultSSHKey)
if err != nil {
return cfg, err
}
cfg.SSHKeyPath = sshKeyPath
for i, entry := range file.FileSync {
validated, err := validateFileSyncEntry(entry, home)
if err != nil {
return cfg, fmt.Errorf("file_sync[%d]: %w", i, err)
}
cfg.FileSync = append(cfg.FileSync, validated)
}
if file.VMDefaults != nil {
override, err := parseVMDefaults(*file.VMDefaults)
if err != nil {
return cfg, fmt.Errorf("vm_defaults: %w", err)
}
cfg.VMDefaults = override
}
return cfg, nil
}
// parseVMDefaults validates and translates the TOML block into the
// model-level override struct. Negative values are rejected outright;
// zero means "not set."
func parseVMDefaults(file vmDefaultsFile) (model.VMDefaultsOverride, error) {
override := model.VMDefaultsOverride{
VCPUCount: file.VCPUCount,
MemoryMiB: file.MemoryMiB,
}
if override.VCPUCount < 0 {
return model.VMDefaultsOverride{}, fmt.Errorf("vcpu must be >= 0 (got %d)", override.VCPUCount)
}
if override.MemoryMiB < 0 {
return model.VMDefaultsOverride{}, fmt.Errorf("memory_mib must be >= 0 (got %d)", override.MemoryMiB)
}
if value := strings.TrimSpace(file.DiskSize); value != "" {
bytes, err := model.ParseSize(value)
if err != nil {
return model.VMDefaultsOverride{}, fmt.Errorf("disk_size: %w", err)
}
override.WorkDiskSizeBytes = bytes
}
if value := strings.TrimSpace(file.SystemOverlaySize); value != "" {
bytes, err := model.ParseSize(value)
if err != nil {
return model.VMDefaultsOverride{}, fmt.Errorf("system_overlay_size: %w", err)
}
override.SystemOverlaySizeByte = bytes
}
return override, nil
}
// validateFileSyncEntry normalises a single `[[file_sync]]` entry
// and rejects anything the operator would regret later: empty
// paths, unsupported leading characters, path traversal, host paths
// outside the owner home, or non-absolute guest targets.
func validateFileSyncEntry(entry fileSyncEntryFile, home string) (model.FileSyncEntry, error) {
host := strings.TrimSpace(entry.Host)
guest := strings.TrimSpace(entry.Guest)
if host == "" {
return model.FileSyncEntry{}, fmt.Errorf("host path is required")
}
if guest == "" {
return model.FileSyncEntry{}, fmt.Errorf("guest path is required")
}
if _, err := ResolveFileSyncHostPath(host, home); err != nil {
return model.FileSyncEntry{}, err
}
if err := validateFileSyncPath("guest", guest, true); err != nil {
return model.FileSyncEntry{}, err
}
// Guest paths must resolve under /root — that's where banger mounts
// the work disk. Syncing to /etc, /var, etc. would require writing
// to the rootfs snapshot, which file_sync deliberately doesn't do.
if !strings.HasPrefix(guest, "~/") && !strings.HasPrefix(guest, "/root/") && guest != "~" && guest != "/root" {
return model.FileSyncEntry{}, fmt.Errorf("guest path %q: must be under /root or ~/ (the work disk is mounted at /root)", guest)
}
mode := strings.TrimSpace(entry.Mode)
if mode != "" {
if err := validateFileSyncMode(mode); err != nil {
return model.FileSyncEntry{}, err
}
}
return model.FileSyncEntry{Host: host, Guest: guest, Mode: mode}, nil
}
// ResolveFileSyncHostPath expands a configured [[file_sync]].host path
// against the owner home and rejects anything that lands outside that
// home. Both config.Load and the root daemon use this so policy cannot
// drift between startup-time validation and runtime file reads.
func ResolveFileSyncHostPath(raw, home string) (string, error) {
raw = strings.TrimSpace(raw)
if err := validateFileSyncPath("host", raw, true); err != nil {
return "", err
}
home = strings.TrimSpace(home)
if home == "" {
return "", fmt.Errorf("host path %q: owner home is required", raw)
}
if !filepath.IsAbs(home) {
return "", fmt.Errorf("host path %q: owner home %q must be absolute", raw, home)
}
candidate := raw
if strings.HasPrefix(raw, "~/") {
candidate = filepath.Join(home, strings.TrimPrefix(raw, "~/"))
}
candidate = filepath.Clean(candidate)
if !filepath.IsAbs(candidate) {
return "", fmt.Errorf("host path %q: resolved path %q must be absolute", raw, candidate)
}
if err := ensurePathWithinRoot(candidate, home); err != nil {
return "", fmt.Errorf("host path %q: %w", raw, err)
}
return candidate, nil
}
// ResolveExistingFileSyncHostPath resolves a configured
// [[file_sync]].host path to its real on-disk target. This is the
// runtime companion to ResolveFileSyncHostPath: once os.Stat succeeds,
// the daemon uses this to ensure a top-level symlink still points
// inside the owner home before it reads from the path as root.
func ResolveExistingFileSyncHostPath(raw, home string) (string, error) {
candidate, err := ResolveFileSyncHostPath(raw, home)
if err != nil {
return "", err
}
resolved, err := filepath.EvalSymlinks(candidate)
if err != nil {
return "", fmt.Errorf("host path %q: resolve symlinks: %w", raw, err)
}
resolved = filepath.Clean(resolved)
if err := ensurePathWithinRoot(resolved, home); err != nil {
return "", fmt.Errorf("host path %q: resolved symlink target %q: %w", raw, resolved, err)
}
return resolved, nil
}
// validateFileSyncPath rejects relative paths (other than a leading
// "~/"), "..", empty segments, and "~user/..." forms banger doesn't
// expand. Absolute paths and home-anchored paths pass through — the
// actual expansion happens at sync time.
func validateFileSyncPath(label, raw string, allowHome bool) error {
if raw == "~" {
return fmt.Errorf("%s path %q: bare '~' is not supported, point at a file or directory under it", label, raw)
}
// "~user/..." must be rejected specifically — catch it before the
// generic "must be absolute" message so the error names the real
// problem.
if strings.HasPrefix(raw, "~") && !strings.HasPrefix(raw, "~/") {
return fmt.Errorf("%s path %q: only '~/' is expanded, not '~user/'", label, raw)
}
if strings.HasPrefix(raw, "~/") {
if !allowHome {
return fmt.Errorf("%s path %q: home-relative paths are not supported here", label, raw)
}
} else if !strings.HasPrefix(raw, "/") {
return fmt.Errorf("%s path %q: must be absolute (start with '/') or home-anchored (start with '~/')", label, raw)
}
for _, segment := range strings.Split(raw, "/") {
if segment == ".." {
return fmt.Errorf("%s path %q: '..' segments are not allowed", label, raw)
}
}
return nil
}
func ensurePathWithinRoot(candidate, root string) error {
root = filepath.Clean(strings.TrimSpace(root))
candidate = filepath.Clean(strings.TrimSpace(candidate))
rel, err := filepath.Rel(root, candidate)
if err != nil {
return fmt.Errorf("compare against owner home %q: %w", root, err)
}
if rel == ".." || strings.HasPrefix(rel, ".."+string(os.PathSeparator)) {
return fmt.Errorf("must stay under owner home %q", root)
}
return nil
}
// validateFileSyncMode accepts three- or four-digit octal strings.
// Three-digit modes like "600" are auto-prefixed with a leading 0
// when parsed by the consumer.
func validateFileSyncMode(mode string) error {
if len(mode) < 3 || len(mode) > 4 {
return fmt.Errorf("mode %q: must be a 3- or 4-digit octal string", mode)
}
for _, r := range mode {
if r < '0' || r > '7' {
return fmt.Errorf("mode %q: must be octal (digits 0-7)", mode)
}
}
return nil
}
func resolveSSHKeyPath(layout paths.Layout, configured, home string, ensureDefault bool) (string, error) {
configured = strings.TrimSpace(configured)
if configured != "" {
return normalizeSSHKeyPath(configured, home)
}
// Key lives under the state dir, not the config dir. The daemon's
// ensureVMSSHClientConfig scrubs ConfigDir/ssh on every Open as
// part of migrating off the pre-state-dir layout — putting the
// default key there would race with that cleanup (create → delete
// → next VM create fails to read the key).
sshDir := strings.TrimSpace(layout.SSHDir)
if sshDir == "" {
sshDir = filepath.Join(layout.StateDir, "ssh")
}
if !filepath.IsAbs(sshDir) {
return "", fmt.Errorf("ssh key dir must be absolute; got %q (check paths.Resolve populated SSHDir / StateDir)", sshDir)
}
defaultPath := filepath.Join(sshDir, "id_ed25519")
if ensureDefault {
return ensureDefaultSSHKey(defaultPath)
}
return defaultPath, nil
}
// normalizeSSHKeyPath validates and canonicalises a user-configured
// ssh_key_path. Accepts:
//
// - absolute paths ("/home/me/keys/id_ed25519")
// - home-anchored paths ("~/keys/id_ed25519") — expanded against $HOME
//
// Rejects:
//
// - bare "~" (ambiguous — expand to what?)
// - "~other/foo" (we only expand the current user's home)
// - relative paths ("id_ed25519", "./keys/id_ed25519") — these are
// ambiguous because the daemon's cwd isn't the user's shell cwd,
// and readers in internal/guest + internal/cli do raw os.ReadFile
// on the path without re-resolving against a known anchor
func normalizeSSHKeyPath(raw, home string) (string, error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return "", nil
}
if raw == "~" {
return "", fmt.Errorf("ssh_key_path %q: bare '~' is not supported, point at a specific key file", raw)
}
if strings.HasPrefix(raw, "~") && !strings.HasPrefix(raw, "~/") {
return "", fmt.Errorf("ssh_key_path %q: only '~/' is expanded, not '~user/'", raw)
}
if strings.HasPrefix(raw, "~/") {
home = strings.TrimSpace(home)
if home == "" {
return "", fmt.Errorf("ssh_key_path %q: no home directory available for ~ expansion", raw)
}
raw = filepath.Join(home, strings.TrimPrefix(raw, "~/"))
}
if !filepath.IsAbs(raw) {
return "", fmt.Errorf("ssh_key_path %q: must be absolute (start with '/') or home-anchored (start with '~/')", raw)
}
return filepath.Clean(raw), nil
}
func ensureDefaultSSHKey(path string) (string, error) {
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
return "", err
}
if _, err := os.Stat(path); err == nil {
if err := ensurePublicKeyFile(path); err != nil {
return "", err
}
return path, nil
} else if !os.IsNotExist(err) {
return "", err
}
_, privateKey, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
return "", err
}
pkcs8, err := x509.MarshalPKCS8PrivateKey(privateKey)
if err != nil {
return "", err
}
privatePEM := pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: pkcs8})
if err := os.WriteFile(path, privatePEM, 0o600); err != nil {
return "", err
}
if err := ensurePublicKeyFile(path); err != nil {
return "", err
}
return path, nil
}
func ensurePublicKeyFile(privateKeyPath string) error {
data, err := os.ReadFile(privateKeyPath)
if err != nil {
return err
}
signer, err := ssh.ParsePrivateKey(data)
if err != nil {
return err
}
publicKey := ssh.MarshalAuthorizedKey(signer.PublicKey())
return os.WriteFile(privateKeyPath+".pub", publicKey, 0o644)
}