v0.1.4 fixed the binary-level reconcile path for jailer'd VMs but left a hole at the systemd layer: bangerd.service and bangerd-root.service both defaulted to RuntimeDirectoryPreserve=no, so /run/banger was wiped on every daemon stop. The api-sock symlinks the helper creates for live VMs (`/run/banger/fc-<id>.sock` → `<chroot>/firecracker.socket`) went with it, and findByJailerPidfile — which derives the chroot from the symlink target — couldn't resolve them. Reconcile then fell through to "stale_vm" and tore down the surviving FC's dm-snapshot. Add RuntimeDirectoryPreserve=yes to both unit templates so the symlinks survive the restart window. Live-verified end-to-end on the dev host: started a VM under v0.1.5, restarted helper + daemon, confirmed the FC PID was unchanged and `banger vm ssh` returned the same boot_id pre and post. Daemon-lifecycle tests updated to assert the new directive is present in both rendered units so future regressions show up at test time. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
219 lines
6.4 KiB
Go
219 lines
6.4 KiB
Go
package cli
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
|
|
"banger/internal/api"
|
|
"banger/internal/installmeta"
|
|
)
|
|
|
|
func TestEnsureDaemonRequiresSystemInstallWhenMetadataMissing(t *testing.T) {
|
|
t.Setenv("XDG_CONFIG_HOME", filepath.Join(t.TempDir(), "config"))
|
|
t.Setenv("XDG_STATE_HOME", filepath.Join(t.TempDir(), "state"))
|
|
t.Setenv("XDG_CACHE_HOME", filepath.Join(t.TempDir(), "cache"))
|
|
t.Setenv("XDG_RUNTIME_DIR", filepath.Join(t.TempDir(), "run"))
|
|
|
|
restoreLoad := loadInstallMetadata
|
|
restoreUID := currentUID
|
|
t.Cleanup(func() {
|
|
loadInstallMetadata = restoreLoad
|
|
currentUID = restoreUID
|
|
})
|
|
|
|
loadInstallMetadata = func() (installmeta.Metadata, error) {
|
|
return installmeta.Metadata{}, os.ErrNotExist
|
|
}
|
|
currentUID = os.Getuid
|
|
|
|
d := defaultDeps()
|
|
d.daemonPing = func(context.Context, string) (api.PingResult, error) {
|
|
return api.PingResult{}, errors.New("dial unix /run/banger/bangerd.sock: no such file")
|
|
}
|
|
|
|
_, _, err := d.ensureDaemon(context.Background())
|
|
if err == nil || !strings.Contains(err.Error(), "sudo banger system install") {
|
|
t.Fatalf("ensureDaemon error = %v, want install guidance", err)
|
|
}
|
|
}
|
|
|
|
func TestEnsureDaemonSuggestsRestartWhenInstalledButUnavailable(t *testing.T) {
|
|
t.Setenv("XDG_CONFIG_HOME", filepath.Join(t.TempDir(), "config"))
|
|
t.Setenv("XDG_STATE_HOME", filepath.Join(t.TempDir(), "state"))
|
|
t.Setenv("XDG_CACHE_HOME", filepath.Join(t.TempDir(), "cache"))
|
|
t.Setenv("XDG_RUNTIME_DIR", filepath.Join(t.TempDir(), "run"))
|
|
|
|
restoreLoad := loadInstallMetadata
|
|
restoreUID := currentUID
|
|
t.Cleanup(func() {
|
|
loadInstallMetadata = restoreLoad
|
|
currentUID = restoreUID
|
|
})
|
|
|
|
loadInstallMetadata = func() (installmeta.Metadata, error) {
|
|
return installmeta.Metadata{
|
|
OwnerUser: "tester",
|
|
OwnerUID: os.Getuid(),
|
|
OwnerGID: os.Getgid(),
|
|
OwnerHome: t.TempDir(),
|
|
}, nil
|
|
}
|
|
currentUID = os.Getuid
|
|
|
|
d := defaultDeps()
|
|
d.daemonPing = func(context.Context, string) (api.PingResult, error) {
|
|
return api.PingResult{}, errors.New("dial unix /run/banger/bangerd.sock: connection refused")
|
|
}
|
|
|
|
_, _, err := d.ensureDaemon(context.Background())
|
|
if err == nil || !strings.Contains(err.Error(), "sudo banger system restart") {
|
|
t.Fatalf("ensureDaemon error = %v, want restart guidance", err)
|
|
}
|
|
}
|
|
|
|
func TestEnsureDaemonRejectsNonOwnerUser(t *testing.T) {
|
|
restoreLoad := loadInstallMetadata
|
|
restoreUID := currentUID
|
|
t.Cleanup(func() {
|
|
loadInstallMetadata = restoreLoad
|
|
currentUID = restoreUID
|
|
})
|
|
|
|
loadInstallMetadata = func() (installmeta.Metadata, error) {
|
|
return installmeta.Metadata{
|
|
OwnerUser: "alice",
|
|
OwnerUID: os.Getuid() + 1,
|
|
OwnerGID: os.Getgid(),
|
|
OwnerHome: t.TempDir(),
|
|
}, nil
|
|
}
|
|
currentUID = os.Getuid
|
|
|
|
d := defaultDeps()
|
|
d.daemonPing = func(context.Context, string) (api.PingResult, error) {
|
|
t.Fatal("daemonPing should not be called for a non-owner user")
|
|
return api.PingResult{}, nil
|
|
}
|
|
|
|
_, _, err := d.ensureDaemon(context.Background())
|
|
if err == nil || !strings.Contains(err.Error(), "installed for alice") {
|
|
t.Fatalf("ensureDaemon error = %v, want owner mismatch guidance", err)
|
|
}
|
|
}
|
|
|
|
func TestSystemSubcommandFlagsAreScoped(t *testing.T) {
|
|
root := NewBangerCommand()
|
|
|
|
systemCmd, _, err := root.Find([]string{"system"})
|
|
if err != nil {
|
|
t.Fatalf("find system: %v", err)
|
|
}
|
|
installCmd, _, err := systemCmd.Find([]string{"install"})
|
|
if err != nil {
|
|
t.Fatalf("find system install: %v", err)
|
|
}
|
|
uninstallCmd, _, err := systemCmd.Find([]string{"uninstall"})
|
|
if err != nil {
|
|
t.Fatalf("find system uninstall: %v", err)
|
|
}
|
|
if installCmd.Flags().Lookup("owner") == nil {
|
|
t.Fatal("system install is missing --owner")
|
|
}
|
|
if uninstallCmd.Flags().Lookup("purge") == nil {
|
|
t.Fatal("system uninstall is missing --purge")
|
|
}
|
|
}
|
|
|
|
func TestRenderSystemdUnitIncludesHardeningDirectives(t *testing.T) {
|
|
unit := renderSystemdUnit(installmeta.Metadata{
|
|
OwnerUser: "alice",
|
|
OwnerUID: 1000,
|
|
OwnerGID: 1000,
|
|
OwnerHome: "/home/alice/dev home",
|
|
})
|
|
|
|
for _, want := range []string{
|
|
"ExecStart=/usr/local/bin/bangerd --system",
|
|
"User=alice",
|
|
"Wants=network-online.target bangerd-root.service",
|
|
"After=bangerd-root.service",
|
|
"Requires=bangerd-root.service",
|
|
"KillMode=process",
|
|
"UMask=0077",
|
|
"Environment=TMPDIR=/run/banger",
|
|
"NoNewPrivileges=yes",
|
|
"PrivateMounts=yes",
|
|
"ProtectSystem=strict",
|
|
"ProtectHome=read-only",
|
|
"ProtectControlGroups=yes",
|
|
"ProtectKernelLogs=yes",
|
|
"ProtectKernelModules=yes",
|
|
"ProtectClock=yes",
|
|
"ProtectHostname=yes",
|
|
"RestrictSUIDSGID=yes",
|
|
"LockPersonality=yes",
|
|
"SystemCallArchitectures=native",
|
|
"RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 AF_NETLINK AF_VSOCK",
|
|
"StateDirectory=banger",
|
|
"StateDirectoryMode=0700",
|
|
"CacheDirectory=banger",
|
|
"CacheDirectoryMode=0700",
|
|
"RuntimeDirectory=banger",
|
|
"RuntimeDirectoryMode=0700",
|
|
"RuntimeDirectoryPreserve=yes",
|
|
`ReadOnlyPaths="/home/alice/dev home"`,
|
|
} {
|
|
if !strings.Contains(unit, want) {
|
|
t.Fatalf("unit = %q, want %q", unit, want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestRenderRootHelperSystemdUnitIncludesRequiredCapabilities(t *testing.T) {
|
|
unit := renderRootHelperSystemdUnit()
|
|
|
|
for _, want := range []string{
|
|
"ExecStart=/usr/local/bin/bangerd --root-helper",
|
|
"KillMode=process",
|
|
"Environment=TMPDIR=/run/banger-root",
|
|
"NoNewPrivileges=yes",
|
|
"PrivateTmp=yes",
|
|
"PrivateMounts=yes",
|
|
"ProtectSystem=strict",
|
|
"ProtectHome=yes",
|
|
"RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 AF_NETLINK AF_VSOCK",
|
|
"CapabilityBoundingSet=CAP_CHOWN CAP_DAC_OVERRIDE CAP_FOWNER CAP_KILL CAP_MKNOD CAP_NET_ADMIN CAP_NET_RAW CAP_SETGID CAP_SETUID CAP_SYS_ADMIN CAP_SYS_CHROOT",
|
|
"ReadWritePaths=/var/lib/banger",
|
|
"RuntimeDirectory=banger-root",
|
|
"RuntimeDirectoryMode=0711",
|
|
"RuntimeDirectoryPreserve=yes",
|
|
} {
|
|
if !strings.Contains(unit, want) {
|
|
t.Fatalf("unit = %q, want %q", unit, want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestRenderSystemdUnitsIncludeOptionalCoverageEnv(t *testing.T) {
|
|
t.Setenv(systemCoverDirEnv, "/var/lib/banger")
|
|
t.Setenv(rootCoverDirEnv, "/var/lib/banger")
|
|
|
|
userUnit := renderSystemdUnit(installmeta.Metadata{
|
|
OwnerUser: "alice",
|
|
OwnerUID: 1000,
|
|
OwnerGID: 1000,
|
|
OwnerHome: "/home/alice",
|
|
})
|
|
if !strings.Contains(userUnit, `Environment=GOCOVERDIR="/var/lib/banger"`) {
|
|
t.Fatalf("user unit = %q, want GOCOVERDIR env", userUnit)
|
|
}
|
|
|
|
rootUnit := renderRootHelperSystemdUnit()
|
|
if !strings.Contains(rootUnit, `Environment=GOCOVERDIR="/var/lib/banger"`) {
|
|
t.Fatalf("root unit = %q, want GOCOVERDIR env", rootUnit)
|
|
}
|
|
}
|