Replaces the static model.Default* constants that drove --vcpu / --memory / --disk-size with a three-layer resolver: 1. [vm_defaults] in ~/.config/banger/config.toml (if set) 2. host-derived heuristics (cpus/4 capped at 4; ram/8 capped at 8 GiB) 3. baked-in constants (floor) Visibility: - Every `vm run` / `vm create` prints a `spec:` line before progress begins: `spec: 4 vcpu · 8192 MiB · 8G disk`. Matches the VM that actually gets created because the CLI is now the single source of truth — it resolves, populates the flag defaults, and forwards the explicit values to the daemon. - `banger doctor` adds a "vm defaults" check showing per-field provenance (config|auto|builtin) and the config file path for overrides. - `--help` shows the resolved defaults (e.g. `--vcpu int (default 4)` on an 8-core host). No `banger config init` command, no first-run side effects, no writes to the user's filesystem behind their back. Users who want explicit control set the keys; everyone else gets sensible numbers that track their hardware.
324 lines
10 KiB
Go
324 lines
10 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"`
|
|
WebListenAddr *string `toml:"web_listen_addr"`
|
|
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"`
|
|
MetricsPoll string `toml:"metrics_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) {
|
|
cfg := model.DaemonConfig{
|
|
LogLevel: "info",
|
|
// Experimental web UI is opt-in: users set web_listen_addr in
|
|
// config.toml (e.g. "127.0.0.1:7777") to enable it.
|
|
WebListenAddr: "",
|
|
AutoStopStaleAfter: 0,
|
|
StatsPollInterval: model.DefaultStatsPollInterval,
|
|
MetricsPollInterval: model.DefaultMetricsPollInterval,
|
|
BridgeName: model.DefaultBridgeName,
|
|
BridgeIP: model.DefaultBridgeIP,
|
|
CIDR: model.DefaultCIDR,
|
|
TapPoolSize: 4,
|
|
DefaultDNS: model.DefaultDNS,
|
|
DefaultImageName: "debian-bookworm",
|
|
}
|
|
|
|
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 file.WebListenAddr != nil {
|
|
cfg.WebListenAddr = strings.TrimSpace(*file.WebListenAddr)
|
|
}
|
|
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(file.MetricsPoll); value != "" {
|
|
duration, err := time.ParseDuration(value)
|
|
if err != nil {
|
|
return cfg, err
|
|
}
|
|
cfg.MetricsPollInterval = duration
|
|
}
|
|
if value := strings.TrimSpace(os.Getenv("BANGER_LOG_LEVEL")); value != "" {
|
|
cfg.LogLevel = value
|
|
}
|
|
|
|
sshKeyPath, err := resolveSSHKeyPath(layout, file.SSHKeyPath)
|
|
if err != nil {
|
|
return cfg, err
|
|
}
|
|
cfg.SSHKeyPath = sshKeyPath
|
|
|
|
for i, entry := range file.FileSync {
|
|
validated, err := validateFileSyncEntry(entry)
|
|
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, or
|
|
// non-absolute guest targets.
|
|
func validateFileSyncEntry(entry fileSyncEntryFile) (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 := validateFileSyncPath("host", host, true); 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
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// 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 string) (string, error) {
|
|
configured = strings.TrimSpace(configured)
|
|
if configured != "" {
|
|
return configured, nil
|
|
}
|
|
return ensureDefaultSSHKey(filepath.Join(layout.ConfigDir, "ssh", "id_ed25519"))
|
|
}
|
|
|
|
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)
|
|
}
|