banger/internal/config/config.go
Thales Maciel 6b543cb17f
firecracker: adopt firecracker-jailer for VM launch (Phase B)
Each VM's firecracker now runs inside a per-VM chroot dropped to the
registered owner UID via firecracker-jailer. Closes the broad ambient-
sudo escalation surface that survived Phase A: the helper still needs
caps for tap/bridge/dm/loop/iptables, but the VMM itself no longer
runs as root in the host root filesystem.

The host helper stages each chroot up front: hard-links the kernel
and (optional) initrd, mknods block-device drives + /dev/vhost-vsock,
copies in the firecracker binary (jailer opens it O_RDWR so a ro bind
fails with EROFS), and bind-mounts /usr/lib + /lib trees read-only so
the dynamic linker can resolve. Self-binds the chroot first so the
findmnt-guarded cleanup can recurse safely.

AF_UNIX sun_path is 108 bytes; the chroot path easily blows past that.
Daemon-side launch pre-symlinks the short request socket path to the
long chroot socket before Machine.Start so the SDK's poll/connect
sees the short path while the kernel resolves to the chroot socket.
--new-pid-ns is intentionally disabled — jailer's PID-namespace fork
makes the SDK see the parent exit and tear the API socket down too
early.

CapabilityBoundingSet for the helper expands to add CAP_FOWNER,
CAP_KILL, CAP_MKNOD, CAP_SETGID, CAP_SETUID, CAP_SYS_CHROOT alongside
the existing CAP_CHOWN/CAP_DAC_OVERRIDE/CAP_NET_ADMIN/CAP_NET_RAW/
CAP_SYS_ADMIN.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 14:38:07 -03:00

458 lines
16 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"`
JailerBin string `toml:"jailer_bin"`
JailerEnabled *bool `toml:"jailer_enabled"`
JailerChrootBase string `toml:"jailer_chroot_base"`
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,
JailerBin: model.DefaultJailerBinary,
JailerEnabled: true,
// Chroot lives under StateDir (ext4) — not RuntimeDir (tmpfs).
// Hard-linking the kernel and any file-backed drives into the
// chroot requires same-filesystem; images already live under
// StateDir, so colocating the chroot avoids EXDEV.
JailerChrootBase: filepath.Join(layout.StateDir, "jail"),
}
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.JailerBin); value != "" {
cfg.JailerBin = value
}
if file.JailerEnabled != nil {
cfg.JailerEnabled = *file.JailerEnabled
}
if value := strings.TrimSpace(file.JailerChrootBase); value != "" {
cfg.JailerChrootBase = value
}
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)
}