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"` 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", 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 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) }