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

495 lines
15 KiB
Go

package config
import (
"os"
"path/filepath"
"strings"
"testing"
"time"
"banger/internal/paths"
)
func TestLoadDefaultsResolveFirecrackerAndGenerateSSHKey(t *testing.T) {
configDir := t.TempDir()
sshDir := t.TempDir()
binDir := t.TempDir()
firecrackerPath := filepath.Join(binDir, "firecracker")
if err := os.WriteFile(firecrackerPath, []byte("#!/bin/sh\nexit 0\n"), 0o755); err != nil {
t.Fatalf("write firecracker: %v", err)
}
t.Setenv("PATH", binDir)
cfg, err := Load(paths.Layout{ConfigDir: configDir, SSHDir: sshDir})
if err != nil {
t.Fatalf("Load: %v", err)
}
if cfg.FirecrackerBin != firecrackerPath {
t.Fatalf("FirecrackerBin = %q, want %q", cfg.FirecrackerBin, firecrackerPath)
}
// Default key lives under SSHDir (state dir), NOT ConfigDir/ssh.
// ConfigDir/ssh gets scrubbed by ensureVMSSHClientConfig on every
// daemon Open, so regression-guard that the generator never picks
// that path again.
wantKey := filepath.Join(sshDir, "id_ed25519")
if cfg.SSHKeyPath != wantKey {
t.Fatalf("SSHKeyPath = %q, want %q", cfg.SSHKeyPath, wantKey)
}
for _, path := range []string{wantKey, wantKey + ".pub"} {
if _, err := os.Stat(path); err != nil {
t.Fatalf("stat %s: %v", path, err)
}
}
forbiddenKey := filepath.Join(configDir, "ssh", "id_ed25519")
if _, err := os.Stat(forbiddenKey); err == nil {
t.Fatalf("key was also generated at %s; config.Load must not write under ConfigDir/ssh", forbiddenKey)
}
if cfg.DefaultImageName != "debian-bookworm" {
t.Fatalf("DefaultImageName = %q, want debian-bookworm", cfg.DefaultImageName)
}
}
func TestLoadSSHKeyPathExpandsHomeAnchored(t *testing.T) {
homeDir := t.TempDir()
t.Setenv("HOME", homeDir)
configDir := t.TempDir()
data := []byte("ssh_key_path = \"~/mykeys/id_ed25519\"\n")
if err := os.WriteFile(filepath.Join(configDir, "config.toml"), data, 0o644); err != nil {
t.Fatalf("write config.toml: %v", err)
}
cfg, err := Load(paths.Layout{ConfigDir: configDir, SSHDir: t.TempDir()})
if err != nil {
t.Fatalf("Load: %v", err)
}
want := filepath.Join(homeDir, "mykeys", "id_ed25519")
if cfg.SSHKeyPath != want {
t.Fatalf("SSHKeyPath = %q, want %q", cfg.SSHKeyPath, want)
}
}
func TestLoadDaemonDoesNotGenerateDefaultSSHKey(t *testing.T) {
ownerHome := t.TempDir()
sshDir := filepath.Join(t.TempDir(), "daemon-ssh")
cfg, err := LoadDaemon(paths.Layout{ConfigDir: t.TempDir(), SSHDir: sshDir}, ownerHome)
if err != nil {
t.Fatalf("LoadDaemon: %v", err)
}
wantKey := filepath.Join(sshDir, "id_ed25519")
if cfg.SSHKeyPath != wantKey {
t.Fatalf("SSHKeyPath = %q, want %q", cfg.SSHKeyPath, wantKey)
}
if cfg.HostHomeDir != ownerHome {
t.Fatalf("HostHomeDir = %q, want %q", cfg.HostHomeDir, ownerHome)
}
if _, err := os.Stat(wantKey); !os.IsNotExist(err) {
t.Fatalf("LoadDaemon created %s, want no key material on daemon config load", wantKey)
}
}
// TestLoadNormalizesAbsoluteSSHKeyPath pins filepath.Clean behaviour
// for configured paths: trailing slashes and duplicate slashes are
// flattened so downstream path comparisons don't see two spellings
// for the same path.
func TestLoadNormalizesAbsoluteSSHKeyPath(t *testing.T) {
cases := []struct {
name string
raw string
want string
}{
{"trailing slash collapsed", "/tmp/keys/id_ed25519/", "/tmp/keys/id_ed25519"},
{"duplicate slashes collapsed", "/tmp//keys///id_ed25519", "/tmp/keys/id_ed25519"},
{"dot segments resolved", "/tmp/keys/./id_ed25519", "/tmp/keys/id_ed25519"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
configDir := t.TempDir()
data := []byte("ssh_key_path = \"" + tc.raw + "\"\n")
if err := os.WriteFile(filepath.Join(configDir, "config.toml"), data, 0o644); err != nil {
t.Fatalf("write config.toml: %v", err)
}
cfg, err := Load(paths.Layout{ConfigDir: configDir, SSHDir: t.TempDir()})
if err != nil {
t.Fatalf("Load %q: %v", tc.raw, err)
}
if cfg.SSHKeyPath != tc.want {
t.Fatalf("SSHKeyPath = %q, want %q", cfg.SSHKeyPath, tc.want)
}
})
}
}
// TestEnsureDefaultSSHKeyRejectsCorruptExistingFile pins the
// "don't silently overwrite" contract: if someone wrote garbage to
// the default key path (or the key was truncated mid-write by a
// previous crash), config.Load must surface the parse error instead
// of pretending the file is usable. The regression we care about is
// a future refactor that adds "regenerate if invalid" silently —
// that would nuke a real user key on every daemon Open.
func TestEnsureDefaultSSHKeyRejectsCorruptExistingFile(t *testing.T) {
sshDir := t.TempDir()
corruptKey := filepath.Join(sshDir, "id_ed25519")
if err := os.WriteFile(corruptKey, []byte("not a pem private key"), 0o600); err != nil {
t.Fatalf("write corrupt key: %v", err)
}
_, err := Load(paths.Layout{ConfigDir: t.TempDir(), SSHDir: sshDir})
if err == nil {
t.Fatal("Load: want error when existing key file is not a valid private key")
}
// The error should mention the parse failure, not "regenerated".
if strings.Contains(err.Error(), "regenerat") {
t.Fatalf("Load silently regenerated: %v", err)
}
// Original garbage must still be there — the invariant is "don't
// touch files you can't parse".
data, readErr := os.ReadFile(corruptKey)
if readErr != nil {
t.Fatalf("ReadFile: %v", readErr)
}
if string(data) != "not a pem private key" {
t.Fatalf("key content = %q, want the original garbage", string(data))
}
}
// TestResolveSSHKeyPathRejectsEmptySSHDirAndStateDir pins the
// guard in resolveSSHKeyPath: if a caller builds a layout without
// SSHDir and StateDir, they shouldn't get a key generated in cwd.
// The guard existed before (added after a test scribbled into
// internal/config/ssh/); this test prevents it from going away.
func TestResolveSSHKeyPathRejectsEmptySSHDirAndStateDir(t *testing.T) {
_, err := Load(paths.Layout{ConfigDir: t.TempDir()})
if err == nil {
t.Fatal("Load: want error when neither SSHDir nor StateDir is set")
}
if !strings.Contains(err.Error(), "must be absolute") {
t.Fatalf("Load error = %v, want 'must be absolute' diagnostic", err)
}
}
func TestLoadRejectsInvalidSSHKeyPath(t *testing.T) {
cases := []struct {
name string
raw string
want string
}{
{"relative bare", "id_ed25519", "must be absolute"},
{"relative with dot", "./keys/id_ed25519", "must be absolute"},
{"bare tilde", "~", "bare '~' is not supported"},
{"user-tilde", "~other/id_ed25519", "only '~/' is expanded"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
configDir := t.TempDir()
data := []byte("ssh_key_path = \"" + tc.raw + "\"\n")
if err := os.WriteFile(filepath.Join(configDir, "config.toml"), data, 0o644); err != nil {
t.Fatalf("write config.toml: %v", err)
}
_, err := Load(paths.Layout{ConfigDir: configDir, SSHDir: t.TempDir()})
if err == nil {
t.Fatalf("Load %q: want error containing %q", tc.raw, tc.want)
}
if !strings.Contains(err.Error(), tc.want) {
t.Fatalf("Load %q: error = %v, want contains %q", tc.raw, err, tc.want)
}
})
}
}
func TestLoadAppliesConfigOverrides(t *testing.T) {
configDir := t.TempDir()
data := []byte(`
log_level = "debug"
firecracker_bin = "/opt/firecracker"
ssh_key_path = "/tmp/custom-key"
default_image_name = "void"
auto_stop_stale_after = "1h"
stats_poll_interval = "15s"
bridge_name = "br-test"
bridge_ip = "10.0.0.1"
cidr = "25"
tap_pool_size = 8
default_dns = "9.9.9.9"
`)
if err := os.WriteFile(filepath.Join(configDir, "config.toml"), data, 0o644); err != nil {
t.Fatalf("write config.toml: %v", err)
}
cfg, err := Load(paths.Layout{ConfigDir: configDir, SSHDir: t.TempDir()})
if err != nil {
t.Fatalf("Load: %v", err)
}
if cfg.LogLevel != "debug" {
t.Fatalf("LogLevel = %q", cfg.LogLevel)
}
if cfg.FirecrackerBin != "/opt/firecracker" {
t.Fatalf("FirecrackerBin = %q", cfg.FirecrackerBin)
}
if cfg.SSHKeyPath != "/tmp/custom-key" {
t.Fatalf("SSHKeyPath = %q", cfg.SSHKeyPath)
}
if cfg.DefaultImageName != "void" {
t.Fatalf("DefaultImageName = %q", cfg.DefaultImageName)
}
if cfg.AutoStopStaleAfter != time.Hour {
t.Fatalf("AutoStopStaleAfter = %s", cfg.AutoStopStaleAfter)
}
if cfg.StatsPollInterval != 15*time.Second {
t.Fatalf("StatsPollInterval = %s", cfg.StatsPollInterval)
}
if cfg.BridgeName != "br-test" || cfg.BridgeIP != "10.0.0.1" || cfg.CIDR != "25" {
t.Fatalf("bridge config = %+v", cfg)
}
if cfg.TapPoolSize != 8 {
t.Fatalf("TapPoolSize = %d", cfg.TapPoolSize)
}
if cfg.DefaultDNS != "9.9.9.9" {
t.Fatalf("DefaultDNS = %q", cfg.DefaultDNS)
}
}
func TestLoadAppliesLogLevelEnvOverride(t *testing.T) {
t.Setenv("BANGER_LOG_LEVEL", "warn")
cfg, err := Load(paths.Layout{ConfigDir: t.TempDir(), SSHDir: t.TempDir()})
if err != nil {
t.Fatalf("Load: %v", err)
}
if cfg.LogLevel != "warn" {
t.Fatalf("LogLevel = %q, want warn", cfg.LogLevel)
}
}
func TestLoadAcceptsFileSyncEntries(t *testing.T) {
homeDir := t.TempDir()
t.Setenv("HOME", homeDir)
configDir := t.TempDir()
hostsFile := filepath.Join(homeDir, ".config", "gh", "hosts.yml")
data := []byte(`
[[file_sync]]
host = "~/.aws"
guest = "~/.aws"
[[file_sync]]
host = "` + hostsFile + `"
guest = "/root/.config/gh/hosts.yml"
mode = "0644"
`)
if err := os.WriteFile(filepath.Join(configDir, "config.toml"), data, 0o644); err != nil {
t.Fatal(err)
}
cfg, err := Load(paths.Layout{ConfigDir: configDir, SSHDir: t.TempDir()})
if err != nil {
t.Fatalf("Load: %v", err)
}
if len(cfg.FileSync) != 2 {
t.Fatalf("FileSync = %+v", cfg.FileSync)
}
if cfg.FileSync[0].Host != "~/.aws" || cfg.FileSync[0].Guest != "~/.aws" {
t.Fatalf("entry[0] = %+v", cfg.FileSync[0])
}
if cfg.FileSync[1].Host != hostsFile || cfg.FileSync[1].Guest != "/root/.config/gh/hosts.yml" {
t.Fatalf("entry[1] = %+v", cfg.FileSync[1])
}
if cfg.FileSync[1].Mode != "0644" {
t.Fatalf("entry[1] mode = %q", cfg.FileSync[1].Mode)
}
}
func TestLoadDaemonAcceptsFileSyncPathUnderOwnerHome(t *testing.T) {
ownerHome := t.TempDir()
t.Setenv("HOME", t.TempDir())
configDir := t.TempDir()
allowed := filepath.Join(ownerHome, ".config", "gh", "hosts.yml")
data := []byte(`
[[file_sync]]
host = "` + allowed + `"
guest = "~/.config/gh/hosts.yml"
`)
if err := os.WriteFile(filepath.Join(configDir, "config.toml"), data, 0o644); err != nil {
t.Fatal(err)
}
cfg, err := LoadDaemon(paths.Layout{ConfigDir: configDir, SSHDir: t.TempDir()}, ownerHome)
if err != nil {
t.Fatalf("LoadDaemon: %v", err)
}
got, err := ResolveFileSyncHostPath(cfg.FileSync[0].Host, cfg.HostHomeDir)
if err != nil {
t.Fatalf("ResolveFileSyncHostPath: %v", err)
}
if got != allowed {
t.Fatalf("resolved host path = %q, want %q", got, allowed)
}
}
func TestLoadRejectsInvalidFileSyncEntries(t *testing.T) {
cases := []struct {
name string
toml string
want string
}{
{
"empty host",
`[[file_sync]]` + "\n" + `host = ""` + "\n" + `guest = "~/foo"`,
"host path is required",
},
{
"empty guest",
`[[file_sync]]` + "\n" + `host = "~/foo"` + "\n" + `guest = ""`,
"guest path is required",
},
{
"relative host",
`[[file_sync]]` + "\n" + `host = "foo/bar"` + "\n" + `guest = "~/foo"`,
"must be absolute",
},
{
"guest outside /root",
`[[file_sync]]` + "\n" + `host = "~/x"` + "\n" + `guest = "/etc/resolv.conf"`,
"must be under /root or ~/",
},
{
"path traversal",
`[[file_sync]]` + "\n" + `host = "~/../secrets"` + "\n" + `guest = "~/secrets"`,
"'..' segments",
},
{
"tilde user",
`[[file_sync]]` + "\n" + `host = "~other/foo"` + "\n" + `guest = "~/foo"`,
"only '~/' is expanded",
},
{
"invalid mode",
`[[file_sync]]` + "\n" + `host = "~/x"` + "\n" + `guest = "~/x"` + "\n" + `mode = "rwx"`,
"must be octal",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
configDir := t.TempDir()
if err := os.WriteFile(filepath.Join(configDir, "config.toml"), []byte(tc.toml+"\n"), 0o644); err != nil {
t.Fatal(err)
}
_, err := Load(paths.Layout{ConfigDir: configDir, SSHDir: t.TempDir()})
if err == nil {
t.Fatalf("Load: want error containing %q", tc.want)
}
if !strings.Contains(err.Error(), tc.want) {
t.Fatalf("Load error = %v, want contains %q", err, tc.want)
}
})
}
}
func TestLoadRejectsFileSyncHostOutsideHome(t *testing.T) {
homeDir := t.TempDir()
t.Setenv("HOME", homeDir)
configDir := t.TempDir()
data := []byte(`
[[file_sync]]
host = "/etc/resolv.conf"
guest = "~/resolv.conf"
`)
if err := os.WriteFile(filepath.Join(configDir, "config.toml"), data, 0o644); err != nil {
t.Fatal(err)
}
_, err := Load(paths.Layout{ConfigDir: configDir, SSHDir: t.TempDir()})
if err == nil {
t.Fatal("Load: want error for host path outside home")
}
if !strings.Contains(err.Error(), "owner home") {
t.Fatalf("Load error = %v, want owner-home diagnostic", err)
}
}
func TestLoadDaemonRejectsFileSyncHostOutsideOwnerHome(t *testing.T) {
ownerHome := t.TempDir()
t.Setenv("HOME", t.TempDir())
configDir := t.TempDir()
outside := filepath.Join(t.TempDir(), "secret.txt")
data := []byte(`
[[file_sync]]
host = "` + outside + `"
guest = "~/secret.txt"
`)
if err := os.WriteFile(filepath.Join(configDir, "config.toml"), data, 0o644); err != nil {
t.Fatal(err)
}
_, err := LoadDaemon(paths.Layout{ConfigDir: configDir, SSHDir: t.TempDir()}, ownerHome)
if err == nil {
t.Fatal("LoadDaemon: want error for host path outside owner home")
}
if !strings.Contains(err.Error(), "owner home") {
t.Fatalf("LoadDaemon error = %v, want owner-home diagnostic", err)
}
}
func TestLoadAcceptsVMDefaults(t *testing.T) {
configDir := t.TempDir()
data := []byte(`
[vm_defaults]
vcpu = 4
memory_mib = 4096
disk_size = "16G"
system_overlay_size = "12G"
`)
if err := os.WriteFile(filepath.Join(configDir, "config.toml"), data, 0o644); err != nil {
t.Fatal(err)
}
cfg, err := Load(paths.Layout{ConfigDir: configDir, SSHDir: t.TempDir()})
if err != nil {
t.Fatalf("Load: %v", err)
}
if cfg.VMDefaults.VCPUCount != 4 {
t.Errorf("VCPUCount = %d, want 4", cfg.VMDefaults.VCPUCount)
}
if cfg.VMDefaults.MemoryMiB != 4096 {
t.Errorf("MemoryMiB = %d, want 4096", cfg.VMDefaults.MemoryMiB)
}
if cfg.VMDefaults.WorkDiskSizeBytes != 16*1024*1024*1024 {
t.Errorf("WorkDiskSizeBytes = %d, want 16 GiB", cfg.VMDefaults.WorkDiskSizeBytes)
}
if cfg.VMDefaults.SystemOverlaySizeByte != 12*1024*1024*1024 {
t.Errorf("SystemOverlaySizeByte = %d, want 12 GiB", cfg.VMDefaults.SystemOverlaySizeByte)
}
}
func TestLoadEmptyVMDefaultsLeavesZeros(t *testing.T) {
// No [vm_defaults] block → cfg.VMDefaults is the zero value,
// which the resolver will map to auto or builtin.
cfg, err := Load(paths.Layout{ConfigDir: t.TempDir(), SSHDir: t.TempDir()})
if err != nil {
t.Fatalf("Load: %v", err)
}
if cfg.VMDefaults.VCPUCount != 0 || cfg.VMDefaults.MemoryMiB != 0 {
t.Errorf("VMDefaults = %+v, want zeroed", cfg.VMDefaults)
}
}
func TestLoadRejectsNegativeVMDefaults(t *testing.T) {
cases := map[string]string{
"vcpu": `[vm_defaults]` + "\n" + `vcpu = -1`,
"memory": `[vm_defaults]` + "\n" + `memory_mib = -1`,
"disk_size": `[vm_defaults]` + "\n" + `disk_size = "banana"`,
"overlay": `[vm_defaults]` + "\n" + `system_overlay_size = "banana"`,
}
for name, body := range cases {
t.Run(name, func(t *testing.T) {
configDir := t.TempDir()
if err := os.WriteFile(filepath.Join(configDir, "config.toml"), []byte(body+"\n"), 0o644); err != nil {
t.Fatal(err)
}
if _, err := Load(paths.Layout{ConfigDir: configDir, SSHDir: t.TempDir()}); err == nil {
t.Fatal("expected error")
}
})
}
}