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.
This commit is contained in:
parent
3edd7c6de7
commit
59e48e830b
53 changed files with 3239 additions and 726 deletions
|
|
@ -34,6 +34,7 @@ func (d *deps) newRootCommand() *cobra.Command {
|
|||
d.newInternalCommand(),
|
||||
d.newKernelCommand(),
|
||||
newSSHConfigCommand(),
|
||||
d.newSystemCommand(),
|
||||
newVersionCommand(),
|
||||
d.newPSCommand(),
|
||||
d.newVMCommand(),
|
||||
|
|
|
|||
|
|
@ -1,12 +1,17 @@
|
|||
package cli
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"banger/internal/daemon"
|
||||
"banger/internal/roothelper"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func NewBangerdCommand() *cobra.Command {
|
||||
var systemMode bool
|
||||
var rootHelperMode bool
|
||||
cmd := &cobra.Command{
|
||||
Use: "bangerd",
|
||||
Short: "Run the banger daemon",
|
||||
|
|
@ -14,7 +19,22 @@ func NewBangerdCommand() *cobra.Command {
|
|||
SilenceErrors: true,
|
||||
Args: noArgsUsage("usage: bangerd"),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
d, err := daemon.Open(cmd.Context())
|
||||
if systemMode && rootHelperMode {
|
||||
return errors.New("choose only one of --system or --root-helper")
|
||||
}
|
||||
if rootHelperMode {
|
||||
server, err := roothelper.Open()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer server.Close()
|
||||
return server.Serve(cmd.Context())
|
||||
}
|
||||
open := daemon.Open
|
||||
if systemMode {
|
||||
open = daemon.OpenSystem
|
||||
}
|
||||
d, err := open(cmd.Context())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
|
@ -22,6 +42,8 @@ func NewBangerdCommand() *cobra.Command {
|
|||
return d.Serve(cmd.Context())
|
||||
},
|
||||
}
|
||||
cmd.Flags().BoolVar(&systemMode, "system", false, "run as the owner-user system service")
|
||||
cmd.Flags().BoolVar(&rootHelperMode, "root-helper", false, "run as the privileged root helper service")
|
||||
cmd.CompletionOptions.DisableDefaultCmd = true
|
||||
return cmd
|
||||
}
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@ func TestNewBangerCommandHasExpectedSubcommands(t *testing.T) {
|
|||
for _, sub := range cmd.Commands() {
|
||||
names = append(names, sub.Name())
|
||||
}
|
||||
want := []string{"daemon", "doctor", "image", "internal", "kernel", "ps", "ssh-config", "version", "vm"}
|
||||
want := []string{"daemon", "doctor", "image", "internal", "kernel", "ps", "ssh-config", "system", "version", "vm"}
|
||||
if !reflect.DeepEqual(names, want) {
|
||||
t.Fatalf("subcommands = %v, want %v", names, want)
|
||||
}
|
||||
|
|
@ -1757,48 +1757,7 @@ func TestNewBangerdCommandRejectsArgs(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestDaemonOutdated(t *testing.T) {
|
||||
d := defaultDeps()
|
||||
dir := t.TempDir()
|
||||
current := filepath.Join(dir, "bangerd-current")
|
||||
same := filepath.Join(dir, "bangerd-same")
|
||||
stale := filepath.Join(dir, "bangerd-stale")
|
||||
if err := os.WriteFile(current, []byte("current"), 0o755); err != nil {
|
||||
t.Fatalf("write current: %v", err)
|
||||
}
|
||||
if err := os.Link(current, same); err != nil {
|
||||
t.Fatalf("hard link: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(stale, []byte("stale"), 0o755); err != nil {
|
||||
t.Fatalf("write stale: %v", err)
|
||||
}
|
||||
|
||||
d.bangerdPath = func() (string, error) {
|
||||
return current, nil
|
||||
}
|
||||
d.daemonExePath = func(pid int) string {
|
||||
if pid == 1 {
|
||||
return same
|
||||
}
|
||||
return stale
|
||||
}
|
||||
|
||||
if d.daemonOutdated(1) {
|
||||
t.Fatal("expected matching daemon executable to be current")
|
||||
}
|
||||
if !d.daemonOutdated(2) {
|
||||
t.Fatal("expected replaced daemon executable to be outdated")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDaemonStatusIncludesLogPathWhenStopped(t *testing.T) {
|
||||
configHome := filepath.Join(t.TempDir(), "config")
|
||||
stateHome := filepath.Join(t.TempDir(), "state")
|
||||
runtimeHome := filepath.Join(t.TempDir(), "runtime")
|
||||
t.Setenv("XDG_CONFIG_HOME", configHome)
|
||||
t.Setenv("XDG_STATE_HOME", stateHome)
|
||||
t.Setenv("XDG_RUNTIME_DIR", runtimeHome)
|
||||
|
||||
cmd := NewBangerCommand()
|
||||
var stdout bytes.Buffer
|
||||
cmd.SetOut(&stdout)
|
||||
|
|
@ -1809,27 +1768,20 @@ func TestDaemonStatusIncludesLogPathWhenStopped(t *testing.T) {
|
|||
}
|
||||
|
||||
output := stdout.String()
|
||||
if !strings.Contains(output, "stopped\n") {
|
||||
t.Fatalf("output = %q, want stopped status", output)
|
||||
}
|
||||
if !strings.Contains(output, "log: "+filepath.Join(stateHome, "banger", "bangerd.log")) {
|
||||
t.Fatalf("output = %q, want daemon log path", output)
|
||||
}
|
||||
if !strings.Contains(output, "dns: 127.0.0.1:42069") {
|
||||
t.Fatalf("output = %q, want dns listener", output)
|
||||
for _, want := range []string{
|
||||
"service: bangerd.service",
|
||||
"socket: /run/banger/bangerd.sock",
|
||||
"log: journalctl -u bangerd.service",
|
||||
} {
|
||||
if !strings.Contains(output, want) {
|
||||
t.Fatalf("output = %q, want %q", output, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDaemonStatusIncludesDaemonBuildInfoWhenRunning(t *testing.T) {
|
||||
d := defaultDeps()
|
||||
|
||||
configHome := filepath.Join(t.TempDir(), "config")
|
||||
stateHome := filepath.Join(t.TempDir(), "state")
|
||||
runtimeHome := filepath.Join(t.TempDir(), "runtime")
|
||||
t.Setenv("XDG_CONFIG_HOME", configHome)
|
||||
t.Setenv("XDG_STATE_HOME", stateHome)
|
||||
t.Setenv("XDG_RUNTIME_DIR", runtimeHome)
|
||||
|
||||
d.daemonPing = func(context.Context, string) (api.PingResult, error) {
|
||||
return api.PingResult{
|
||||
Status: "ok",
|
||||
|
|
@ -1851,12 +1803,13 @@ func TestDaemonStatusIncludesDaemonBuildInfoWhenRunning(t *testing.T) {
|
|||
|
||||
output := stdout.String()
|
||||
for _, want := range []string{
|
||||
"running\n",
|
||||
"service: bangerd.service",
|
||||
"socket: /run/banger/bangerd.sock",
|
||||
"log: journalctl -u bangerd.service",
|
||||
"pid: 42",
|
||||
"version: v1.2.3",
|
||||
"commit: abc123",
|
||||
"built_at: 2026-03-22T12:00:00Z",
|
||||
"log: " + filepath.Join(stateHome, "banger", "bangerd.log"),
|
||||
} {
|
||||
if !strings.Contains(output, want) {
|
||||
t.Fatalf("output = %q, want %q", output, want)
|
||||
|
|
@ -1864,17 +1817,6 @@ func TestDaemonStatusIncludesDaemonBuildInfoWhenRunning(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestBuildDaemonCommandIsDetachedFromCallerContext(t *testing.T) {
|
||||
cmd := buildDaemonCommand("/tmp/bangerd")
|
||||
|
||||
if cmd.Path != "/tmp/bangerd" {
|
||||
t.Fatalf("command path = %q", cmd.Path)
|
||||
}
|
||||
if cmd.Cancel != nil {
|
||||
t.Fatal("daemon process should not be tied to a CLI request context")
|
||||
}
|
||||
}
|
||||
|
||||
func testCLIResolvedVM(id, name string) model.VMRecord {
|
||||
return model.VMRecord{ID: id, Name: name}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,15 +2,9 @@ package cli
|
|||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"banger/internal/api"
|
||||
"banger/internal/buildinfo"
|
||||
"banger/internal/installmeta"
|
||||
"banger/internal/paths"
|
||||
"banger/internal/rpc"
|
||||
"banger/internal/system"
|
||||
"banger/internal/vmdns"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
|
@ -18,50 +12,30 @@ import (
|
|||
func (d *deps) newDaemonCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "daemon",
|
||||
Short: "Manage the banger daemon",
|
||||
Short: "Manage the installed banger services",
|
||||
RunE: helpNoArgs,
|
||||
}
|
||||
cmd.AddCommand(
|
||||
&cobra.Command{
|
||||
Use: "status",
|
||||
Short: "Show daemon status",
|
||||
Short: "Show owner-daemon and root-helper status",
|
||||
Args: noArgsUsage("usage: banger daemon status"),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
layout, err := paths.Resolve()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ping, pingErr := d.daemonPing(cmd.Context(), layout.SocketPath)
|
||||
if pingErr != nil {
|
||||
_, err = fmt.Fprintf(cmd.OutOrStdout(), "stopped\nsocket: %s\nlog: %s\ndns: %s\n", layout.SocketPath, layout.DaemonLog, vmdns.DefaultListenAddr)
|
||||
return err
|
||||
}
|
||||
info := buildinfo.Normalize(ping.Version, ping.Commit, ping.BuiltAt)
|
||||
_, err = fmt.Fprintf(cmd.OutOrStdout(), "running\npid: %d\n%ssocket: %s\nlog: %s\ndns: %s\n", ping.PID, formatBuildInfoBlock(info), layout.SocketPath, layout.DaemonLog, vmdns.DefaultListenAddr)
|
||||
return err
|
||||
return d.runSystemStatus(cmd.Context(), cmd.OutOrStdout())
|
||||
},
|
||||
},
|
||||
&cobra.Command{
|
||||
Use: "stop",
|
||||
Short: "Stop the daemon",
|
||||
Short: "Stop the installed banger services",
|
||||
Args: noArgsUsage("usage: banger daemon stop"),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if err := system.EnsureSudo(cmd.Context()); err != nil {
|
||||
if err := requireRoot(); err != nil {
|
||||
return err
|
||||
}
|
||||
layout, err := paths.Resolve()
|
||||
if err != nil {
|
||||
if err := d.runSystemctl(cmd.Context(), "stop", installmeta.DefaultService, installmeta.DefaultRootHelperService); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = rpc.Call[api.ShutdownResult](cmd.Context(), layout.SocketPath, "shutdown", api.Empty{})
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) || strings.Contains(err.Error(), "connect") {
|
||||
_, writeErr := fmt.Fprintln(cmd.OutOrStdout(), "daemon not running")
|
||||
return writeErr
|
||||
}
|
||||
return err
|
||||
}
|
||||
_, err = fmt.Fprintln(cmd.OutOrStdout(), "stopping")
|
||||
_, err := fmt.Fprintln(cmd.OutOrStdout(), "stopped")
|
||||
return err
|
||||
},
|
||||
},
|
||||
|
|
@ -70,10 +44,8 @@ func (d *deps) newDaemonCommand() *cobra.Command {
|
|||
Short: "Print the daemon socket path",
|
||||
Args: noArgsUsage("usage: banger daemon socket"),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
layout, err := paths.Resolve()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
layout := paths.ResolveSystem()
|
||||
var err error
|
||||
_, err = fmt.Fprintln(cmd.OutOrStdout(), layout.SocketPath)
|
||||
return err
|
||||
},
|
||||
|
|
|
|||
|
|
@ -8,7 +8,6 @@ import (
|
|||
"banger/internal/api"
|
||||
"banger/internal/model"
|
||||
"banger/internal/rpc"
|
||||
"banger/internal/system"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
|
@ -43,9 +42,6 @@ func (d *deps) newImageRegisterCommand() *cobra.Command {
|
|||
if err := absolutizeImageRegisterPaths(¶ms); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := system.EnsureSudo(cmd.Context()); err != nil {
|
||||
return err
|
||||
}
|
||||
layout, _, err := d.ensureDaemon(cmd.Context())
|
||||
if err != nil {
|
||||
return err
|
||||
|
|
@ -114,9 +110,6 @@ subcommand lands).
|
|||
if err := absolutizePaths(¶ms.KernelPath, ¶ms.InitrdPath, ¶ms.ModulesDir); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := system.EnsureSudo(cmd.Context()); err != nil {
|
||||
return err
|
||||
}
|
||||
layout, _, err := d.ensureDaemon(cmd.Context())
|
||||
if err != nil {
|
||||
return err
|
||||
|
|
@ -150,9 +143,6 @@ func (d *deps) newImagePromoteCommand() *cobra.Command {
|
|||
Args: exactArgsUsage(1, "usage: banger image promote <id-or-name>"),
|
||||
ValidArgsFunction: d.completeImageNameOnlyAtPos0,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if err := system.EnsureSudo(cmd.Context()); err != nil {
|
||||
return err
|
||||
}
|
||||
layout, _, err := d.ensureDaemon(cmd.Context())
|
||||
if err != nil {
|
||||
return err
|
||||
|
|
@ -214,9 +204,6 @@ func (d *deps) newImageDeleteCommand() *cobra.Command {
|
|||
Args: exactArgsUsage(1, "usage: banger image delete <id-or-name>"),
|
||||
ValidArgsFunction: d.completeImageNameOnlyAtPos0,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if err := system.EnsureSudo(cmd.Context()); err != nil {
|
||||
return err
|
||||
}
|
||||
layout, _, err := d.ensureDaemon(cmd.Context())
|
||||
if err != nil {
|
||||
return err
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ package cli
|
|||
import (
|
||||
"fmt"
|
||||
|
||||
"banger/internal/config"
|
||||
"banger/internal/daemon"
|
||||
"banger/internal/paths"
|
||||
|
||||
|
|
@ -39,6 +40,13 @@ terminal, bypassing 'banger vm ssh':
|
|||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cfg, err := config.Load(layout)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := daemon.SyncVMSSHClientConfig(layout, cfg.SSHKeyPath); err != nil {
|
||||
return err
|
||||
}
|
||||
bangerConfig := daemon.BangerSSHConfigPath(layout)
|
||||
switch {
|
||||
case install:
|
||||
|
|
|
|||
385
internal/cli/commands_system.go
Normal file
385
internal/cli/commands_system.go
Normal file
|
|
@ -0,0 +1,385 @@
|
|||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"banger/internal/buildinfo"
|
||||
"banger/internal/installmeta"
|
||||
"banger/internal/model"
|
||||
"banger/internal/paths"
|
||||
"banger/internal/system"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
const (
|
||||
systemBangerBin = "/usr/local/bin/banger"
|
||||
systemBangerdBin = "/usr/local/bin/bangerd"
|
||||
systemCompanionDir = "/usr/local/lib/banger"
|
||||
systemCompanionAgent = systemCompanionDir + "/banger-vsock-agent"
|
||||
systemdUserUnitPath = "/etc/systemd/system/" + installmeta.DefaultService
|
||||
systemdRootUnitPath = "/etc/systemd/system/" + installmeta.DefaultRootHelperService
|
||||
systemCoverDirEnv = "BANGER_SYSTEM_GOCOVERDIR"
|
||||
rootCoverDirEnv = "BANGER_ROOT_HELPER_GOCOVERDIR"
|
||||
)
|
||||
|
||||
func (d *deps) newSystemCommand() *cobra.Command {
|
||||
var owner string
|
||||
var purge bool
|
||||
cmd := &cobra.Command{
|
||||
Use: "system",
|
||||
Short: "Install and manage banger's system services",
|
||||
RunE: helpNoArgs,
|
||||
}
|
||||
installCmd := &cobra.Command{
|
||||
Use: "install",
|
||||
Short: "Install or refresh the owner daemon and root helper",
|
||||
Args: noArgsUsage("usage: banger system install [--owner USER]"),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return d.runSystemInstall(cmd.Context(), cmd.OutOrStdout(), owner)
|
||||
},
|
||||
}
|
||||
installCmd.Flags().StringVar(&owner, "owner", "", "login user who will operate banger day-to-day")
|
||||
|
||||
statusCmd := &cobra.Command{
|
||||
Use: "status",
|
||||
Short: "Show owner-daemon and root-helper status",
|
||||
Args: noArgsUsage("usage: banger system status"),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return d.runSystemStatus(cmd.Context(), cmd.OutOrStdout())
|
||||
},
|
||||
}
|
||||
|
||||
restartCmd := &cobra.Command{
|
||||
Use: "restart",
|
||||
Short: "Restart the installed banger services",
|
||||
Args: noArgsUsage("usage: banger system restart"),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if err := requireRoot(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := d.runSystemctl(cmd.Context(), "restart", installmeta.DefaultRootHelperService); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := d.runSystemctl(cmd.Context(), "restart", installmeta.DefaultService); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := fmt.Fprintln(cmd.OutOrStdout(), "restarted")
|
||||
return err
|
||||
},
|
||||
}
|
||||
|
||||
uninstallCmd := &cobra.Command{
|
||||
Use: "uninstall",
|
||||
Short: "Remove the installed banger services",
|
||||
Args: noArgsUsage("usage: banger system uninstall [--purge]"),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return d.runSystemUninstall(cmd.Context(), cmd.OutOrStdout(), purge)
|
||||
},
|
||||
}
|
||||
uninstallCmd.Flags().BoolVar(&purge, "purge", false, "also delete system-owned banger state and cache")
|
||||
|
||||
cmd.AddCommand(installCmd, statusCmd, restartCmd, uninstallCmd)
|
||||
return cmd
|
||||
}
|
||||
|
||||
func (d *deps) runSystemInstall(ctx context.Context, out io.Writer, ownerFlag string) error {
|
||||
if err := requireRoot(); err != nil {
|
||||
return err
|
||||
}
|
||||
meta, err := resolveInstallOwner(ownerFlag)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
info := buildinfo.Current()
|
||||
meta.Version = info.Version
|
||||
meta.Commit = info.Commit
|
||||
meta.BuiltAt = info.BuiltAt
|
||||
meta.InstalledAt = model.Now()
|
||||
|
||||
bangerBin, err := paths.BangerPath()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
bangerdBin, err := paths.BangerdPath()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
agentBin, err := paths.CompanionBinaryPath("banger-vsock-agent")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(systemBangerBin), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.MkdirAll(systemCompanionDir, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := installFile(bangerBin, systemBangerBin, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := installFile(bangerdBin, systemBangerdBin, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := installFile(agentBin, systemCompanionAgent, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := installmeta.Save(installmeta.DefaultPath, meta); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := paths.EnsureSystem(paths.ResolveSystem()); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.WriteFile(systemdRootUnitPath, []byte(renderRootHelperSystemdUnit()), 0o644); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.WriteFile(systemdUserUnitPath, []byte(renderSystemdUnit(meta)), 0o644); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := d.runSystemctl(ctx, "daemon-reload"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := d.runSystemctl(ctx, "enable", installmeta.DefaultRootHelperService); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := d.runSystemctl(ctx, "enable", installmeta.DefaultService); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := d.runSystemctl(ctx, "restart", installmeta.DefaultRootHelperService); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := d.runSystemctl(ctx, "restart", installmeta.DefaultService); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = fmt.Fprintf(out, "installed\nowner: %s\nsocket: %s\nhelper_socket: %s\nservice: %s\nhelper_service: %s\n", meta.OwnerUser, installmeta.DefaultSocketPath, installmeta.DefaultRootHelperSocketPath, installmeta.DefaultService, installmeta.DefaultRootHelperService)
|
||||
return err
|
||||
}
|
||||
|
||||
func (d *deps) runSystemStatus(ctx context.Context, out io.Writer) error {
|
||||
layout := paths.ResolveSystem()
|
||||
active := d.systemctlQuery(ctx, "is-active", installmeta.DefaultService)
|
||||
if active == "" {
|
||||
active = "unknown"
|
||||
}
|
||||
enabled := d.systemctlQuery(ctx, "is-enabled", installmeta.DefaultService)
|
||||
if enabled == "" {
|
||||
enabled = "unknown"
|
||||
}
|
||||
helperActive := d.systemctlQuery(ctx, "is-active", installmeta.DefaultRootHelperService)
|
||||
if helperActive == "" {
|
||||
helperActive = "unknown"
|
||||
}
|
||||
helperEnabled := d.systemctlQuery(ctx, "is-enabled", installmeta.DefaultRootHelperService)
|
||||
if helperEnabled == "" {
|
||||
helperEnabled = "unknown"
|
||||
}
|
||||
fmt.Fprintf(out, "service: %s\nenabled: %s\nactive: %s\nhelper_service: %s\nhelper_enabled: %s\nhelper_active: %s\nsocket: %s\nhelper_socket: %s\nlog: journalctl -u %s -u %s\n",
|
||||
installmeta.DefaultService, enabled, active,
|
||||
installmeta.DefaultRootHelperService, helperEnabled, helperActive,
|
||||
layout.SocketPath, installmeta.DefaultRootHelperSocketPath,
|
||||
installmeta.DefaultService, installmeta.DefaultRootHelperService)
|
||||
if ping, err := d.daemonPing(ctx, layout.SocketPath); err == nil {
|
||||
info := buildinfo.Normalize(ping.Version, ping.Commit, ping.BuiltAt)
|
||||
_, err = fmt.Fprintf(out, "pid: %d\n%s", ping.PID, formatBuildInfoBlock(info))
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *deps) runSystemUninstall(ctx context.Context, out io.Writer, purge bool) error {
|
||||
if err := requireRoot(); err != nil {
|
||||
return err
|
||||
}
|
||||
_ = d.runSystemctl(ctx, "disable", "--now", installmeta.DefaultService, installmeta.DefaultRootHelperService)
|
||||
_ = os.Remove(systemdUserUnitPath)
|
||||
_ = os.Remove(systemdRootUnitPath)
|
||||
_ = os.Remove(installmeta.DefaultPath)
|
||||
_ = os.Remove(installmeta.DefaultDir)
|
||||
_ = d.runSystemctl(ctx, "daemon-reload")
|
||||
_ = os.Remove(systemBangerdBin)
|
||||
_ = os.Remove(systemBangerBin)
|
||||
_ = os.RemoveAll(systemCompanionDir)
|
||||
if purge {
|
||||
_ = os.RemoveAll(paths.ResolveSystem().StateDir)
|
||||
_ = os.RemoveAll(paths.ResolveSystem().CacheDir)
|
||||
_ = os.RemoveAll(paths.ResolveSystem().RuntimeDir)
|
||||
}
|
||||
msg := "uninstalled"
|
||||
if purge {
|
||||
msg += " (purged state)"
|
||||
}
|
||||
_, err := fmt.Fprintln(out, msg)
|
||||
return err
|
||||
}
|
||||
|
||||
func resolveInstallOwner(ownerFlag string) (installmeta.Metadata, error) {
|
||||
owner := strings.TrimSpace(ownerFlag)
|
||||
if owner == "" {
|
||||
owner = strings.TrimSpace(os.Getenv("SUDO_USER"))
|
||||
}
|
||||
if owner == "" {
|
||||
return installmeta.Metadata{}, errors.New("owner is required; pass --owner USER when installing without sudo")
|
||||
}
|
||||
if owner == "root" {
|
||||
return installmeta.Metadata{}, errors.New("refusing to install with root as the banger owner")
|
||||
}
|
||||
return installmeta.LookupOwner(owner)
|
||||
}
|
||||
|
||||
func renderSystemdUnit(meta installmeta.Metadata) string {
|
||||
lines := []string{
|
||||
"[Unit]",
|
||||
"Description=banger daemon",
|
||||
"After=network-online.target",
|
||||
"Wants=network-online.target " + installmeta.DefaultRootHelperService,
|
||||
"After=" + installmeta.DefaultRootHelperService,
|
||||
"Requires=" + installmeta.DefaultRootHelperService,
|
||||
"",
|
||||
"[Service]",
|
||||
"Type=simple",
|
||||
"User=" + meta.OwnerUser,
|
||||
"ExecStart=" + systemBangerdBin + " --system",
|
||||
"Restart=on-failure",
|
||||
"RestartSec=1s",
|
||||
"Environment=PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
|
||||
"Environment=TMPDIR=/run/banger",
|
||||
"UMask=0077",
|
||||
"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",
|
||||
}
|
||||
if coverDir := strings.TrimSpace(os.Getenv(systemCoverDirEnv)); coverDir != "" {
|
||||
lines = append(lines, "Environment=GOCOVERDIR="+systemdQuote(coverDir))
|
||||
}
|
||||
if home := strings.TrimSpace(meta.OwnerHome); home != "" {
|
||||
lines = append(lines, "ReadOnlyPaths="+systemdQuote(home))
|
||||
}
|
||||
lines = append(lines,
|
||||
"",
|
||||
"[Install]",
|
||||
"WantedBy=multi-user.target",
|
||||
"",
|
||||
)
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
func renderRootHelperSystemdUnit() string {
|
||||
lines := []string{
|
||||
"[Unit]",
|
||||
"Description=banger root helper",
|
||||
"After=network-online.target",
|
||||
"Wants=network-online.target",
|
||||
"",
|
||||
"[Service]",
|
||||
"Type=simple",
|
||||
"ExecStart=" + systemBangerdBin + " --root-helper",
|
||||
"Restart=on-failure",
|
||||
"RestartSec=1s",
|
||||
"Environment=PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
|
||||
"Environment=TMPDIR=" + installmeta.DefaultRootHelperRuntimeDir,
|
||||
"UMask=0077",
|
||||
"NoNewPrivileges=yes",
|
||||
"PrivateTmp=yes",
|
||||
"PrivateMounts=yes",
|
||||
"ProtectSystem=strict",
|
||||
"ProtectHome=yes",
|
||||
"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",
|
||||
"CapabilityBoundingSet=CAP_CHOWN CAP_DAC_OVERRIDE CAP_NET_ADMIN CAP_NET_RAW CAP_SYS_ADMIN",
|
||||
"ReadWritePaths=/var/lib/banger",
|
||||
"RuntimeDirectory=banger-root",
|
||||
"RuntimeDirectoryMode=0711",
|
||||
}
|
||||
if coverDir := strings.TrimSpace(os.Getenv(rootCoverDirEnv)); coverDir != "" {
|
||||
lines = append(lines, "Environment=GOCOVERDIR="+systemdQuote(coverDir))
|
||||
}
|
||||
lines = append(lines,
|
||||
"",
|
||||
"[Install]",
|
||||
"WantedBy=multi-user.target",
|
||||
"",
|
||||
)
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
func systemdQuote(value string) string {
|
||||
return strconv.Quote(strings.TrimSpace(value))
|
||||
}
|
||||
|
||||
func installFile(sourcePath, targetPath string, mode os.FileMode) error {
|
||||
if err := os.MkdirAll(filepath.Dir(targetPath), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
tempPath := targetPath + ".tmp"
|
||||
_ = os.Remove(tempPath)
|
||||
if err := system.CopyFilePreferClone(sourcePath, tempPath); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.Chmod(tempPath, mode); err != nil {
|
||||
_ = os.Remove(tempPath)
|
||||
return err
|
||||
}
|
||||
if err := os.Rename(tempPath, targetPath); err != nil {
|
||||
_ = os.Remove(tempPath)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func requireRoot() error {
|
||||
if os.Geteuid() == 0 {
|
||||
return nil
|
||||
}
|
||||
return errors.New("this command requires root; run it with sudo")
|
||||
}
|
||||
|
||||
func (d *deps) runSystemctl(ctx context.Context, args ...string) error {
|
||||
_, err := d.hostCommandOutput(ctx, "systemctl", args...)
|
||||
return err
|
||||
}
|
||||
|
||||
func (d *deps) systemctlQuery(ctx context.Context, args ...string) string {
|
||||
output, err := d.hostCommandOutput(ctx, "systemctl", args...)
|
||||
if err == nil {
|
||||
return strings.TrimSpace(string(output))
|
||||
}
|
||||
msg := strings.TrimSpace(string(output))
|
||||
if msg != "" {
|
||||
return msg
|
||||
}
|
||||
msg = strings.TrimSpace(err.Error())
|
||||
if idx := strings.LastIndex(msg, ": "); idx >= 0 {
|
||||
return strings.TrimSpace(msg[idx+2:])
|
||||
}
|
||||
return msg
|
||||
}
|
||||
|
|
@ -37,7 +37,7 @@ func (d *deps) newVMCommand() *cobra.Command {
|
|||
d.newVMActionCommand("stop", "Stop a VM", "vm.stop"),
|
||||
d.newVMKillCommand(),
|
||||
d.newVMActionCommand("restart", "Restart a VM", "vm.restart"),
|
||||
d.newVMActionCommand("delete", "Delete a VM", "vm.delete", "rm"),
|
||||
d.newVMDeleteCommand(),
|
||||
d.newVMPruneCommand(),
|
||||
d.newVMSetCommand(),
|
||||
d.newVMSSHCommand(),
|
||||
|
|
@ -143,9 +143,6 @@ Three modes:
|
|||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := system.EnsureSudo(cmd.Context()); err != nil {
|
||||
return err
|
||||
}
|
||||
layout, cfg, err = d.ensureDaemon(cmd.Context())
|
||||
if err != nil {
|
||||
return err
|
||||
|
|
@ -177,9 +174,6 @@ func (d *deps) newVMKillCommand() *cobra.Command {
|
|||
Args: minArgsUsage(1, "usage: banger vm kill [--signal SIGTERM|SIGKILL|...] <id-or-name>..."),
|
||||
ValidArgsFunction: d.completeVMNames,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if err := system.EnsureSudo(cmd.Context()); err != nil {
|
||||
return err
|
||||
}
|
||||
layout, _, err := d.ensureDaemon(cmd.Context())
|
||||
if err != nil {
|
||||
return err
|
||||
|
|
@ -222,9 +216,6 @@ func (d *deps) newVMPruneCommand() *cobra.Command {
|
|||
Long: "Scan for VMs in state other than 'running' (stopped, created, error) and delete them after confirmation. Use -f to skip the prompt.",
|
||||
Args: noArgsUsage("usage: banger vm prune [-f|--force]"),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if err := system.EnsureSudo(cmd.Context()); err != nil {
|
||||
return err
|
||||
}
|
||||
layout, _, err := d.ensureDaemon(cmd.Context())
|
||||
if err != nil {
|
||||
return err
|
||||
|
|
@ -288,6 +279,9 @@ func (d *deps) runVMPrune(cmd *cobra.Command, socketPath string, force bool) err
|
|||
failed++
|
||||
continue
|
||||
}
|
||||
if err := removeUserKnownHosts(vm); err != nil {
|
||||
fmt.Fprintf(stderr, "known_hosts cleanup %s: %v\n", ref, err)
|
||||
}
|
||||
fmt.Fprintln(stdout, "deleted", ref)
|
||||
}
|
||||
if failed > 0 {
|
||||
|
|
@ -333,9 +327,6 @@ func (d *deps) newVMCreateCommand() *cobra.Command {
|
|||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := system.EnsureSudo(cmd.Context()); err != nil {
|
||||
return err
|
||||
}
|
||||
layout, _, err := d.ensureDaemon(cmd.Context())
|
||||
if err != nil {
|
||||
return err
|
||||
|
|
@ -462,9 +453,6 @@ func (d *deps) newVMActionCommand(use, short, method string, aliases ...string)
|
|||
Args: minArgsUsage(1, fmt.Sprintf("usage: banger vm %s <id-or-name>...", use)),
|
||||
ValidArgsFunction: d.completeVMNames,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if err := system.EnsureSudo(cmd.Context()); err != nil {
|
||||
return err
|
||||
}
|
||||
layout, _, err := d.ensureDaemon(cmd.Context())
|
||||
if err != nil {
|
||||
return err
|
||||
|
|
@ -487,6 +475,40 @@ func (d *deps) newVMActionCommand(use, short, method string, aliases ...string)
|
|||
}
|
||||
}
|
||||
|
||||
func (d *deps) newVMDeleteCommand() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "delete <id-or-name>...",
|
||||
Aliases: []string{"rm"},
|
||||
Short: "Delete a VM",
|
||||
Args: minArgsUsage(1, "usage: banger vm delete <id-or-name>..."),
|
||||
ValidArgsFunction: d.completeVMNames,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
layout, _, err := d.ensureDaemon(cmd.Context())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
deleteOne := func(ctx context.Context, id string) (model.VMRecord, error) {
|
||||
result, err := rpc.Call[api.VMShowResult](ctx, layout.SocketPath, "vm.delete", api.VMRefParams{IDOrName: id})
|
||||
if err != nil {
|
||||
return model.VMRecord{}, err
|
||||
}
|
||||
if err := removeUserKnownHosts(result.VM); err != nil {
|
||||
_, _ = fmt.Fprintf(cmd.ErrOrStderr(), "known_hosts cleanup for %s: %v\n", id, err)
|
||||
}
|
||||
return result.VM, nil
|
||||
}
|
||||
if len(args) > 1 {
|
||||
return runVMBatchAction(cmd, layout.SocketPath, args, deleteOne)
|
||||
}
|
||||
vm, err := deleteOne(cmd.Context(), args[0])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return printVMSummary(cmd.OutOrStdout(), vm)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (d *deps) newVMSetCommand() *cobra.Command {
|
||||
var (
|
||||
vcpu int
|
||||
|
|
@ -505,9 +527,6 @@ func (d *deps) newVMSetCommand() *cobra.Command {
|
|||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := system.EnsureSudo(cmd.Context()); err != nil {
|
||||
return err
|
||||
}
|
||||
layout, _, err := d.ensureDaemon(cmd.Context())
|
||||
if err != nil {
|
||||
return err
|
||||
|
|
|
|||
|
|
@ -70,10 +70,7 @@ func defaultCompletionLister(ctx context.Context, socketPath, method string) ([]
|
|||
// already running. Returns "", false when no daemon is up — completion
|
||||
// callers use this as the bail signal.
|
||||
func (d *deps) daemonSocketForCompletion(ctx context.Context) (string, bool) {
|
||||
layout, err := paths.Resolve()
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
layout := paths.ResolveSystem()
|
||||
if _, err := d.daemonPing(ctx, layout.SocketPath); err != nil {
|
||||
return "", false
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,137 +2,60 @@ package cli
|
|||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"syscall"
|
||||
"time"
|
||||
"strings"
|
||||
|
||||
"banger/internal/api"
|
||||
"banger/internal/config"
|
||||
"banger/internal/installmeta"
|
||||
"banger/internal/model"
|
||||
"banger/internal/paths"
|
||||
"banger/internal/rpc"
|
||||
)
|
||||
|
||||
// ensureDaemon pings the socket; on miss it auto-starts bangerd, on
|
||||
// version mismatch it restarts. Every CLI command that needs to talk
|
||||
// to the daemon routes through here.
|
||||
var (
|
||||
loadInstallMetadata = func() (installmeta.Metadata, error) {
|
||||
return installmeta.Load(installmeta.DefaultPath)
|
||||
}
|
||||
currentUID = os.Getuid
|
||||
)
|
||||
|
||||
// ensureDaemon validates that the current CLI user matches the
|
||||
// installed banger owner, then pings the system socket. Every CLI
|
||||
// command that needs to talk to the daemon routes through here.
|
||||
func (d *deps) ensureDaemon(ctx context.Context) (paths.Layout, model.DaemonConfig, error) {
|
||||
layout, err := paths.Resolve()
|
||||
meta, metaErr := loadInstallMetadata()
|
||||
if metaErr == nil && currentUID() != meta.OwnerUID {
|
||||
return paths.Layout{}, model.DaemonConfig{}, fmt.Errorf("banger is installed for %s; switch to that user or reinstall with `sudo banger system install --owner %s`", meta.OwnerUser, userHint())
|
||||
}
|
||||
if metaErr != nil && !errors.Is(metaErr, os.ErrNotExist) {
|
||||
return paths.Layout{}, model.DaemonConfig{}, fmt.Errorf("load %s: %w", installmeta.DefaultPath, metaErr)
|
||||
}
|
||||
|
||||
userLayout, err := paths.Resolve()
|
||||
if err != nil {
|
||||
return paths.Layout{}, model.DaemonConfig{}, err
|
||||
}
|
||||
cfg, err := config.Load(layout)
|
||||
cfg, err := config.Load(userLayout)
|
||||
if err != nil {
|
||||
return paths.Layout{}, model.DaemonConfig{}, err
|
||||
}
|
||||
if ping, err := d.daemonPing(ctx, layout.SocketPath); err == nil {
|
||||
if d.daemonOutdated(ping.PID) {
|
||||
if err := d.restartDaemon(ctx, layout, ping.PID); err != nil {
|
||||
return paths.Layout{}, model.DaemonConfig{}, err
|
||||
}
|
||||
return layout, cfg, nil
|
||||
}
|
||||
layout := paths.ResolveSystem()
|
||||
if _, err := d.daemonPing(ctx, layout.SocketPath); err == nil {
|
||||
return layout, cfg, nil
|
||||
}
|
||||
if err := d.startDaemon(ctx, layout); err != nil {
|
||||
return paths.Layout{}, model.DaemonConfig{}, err
|
||||
if metaErr == nil {
|
||||
return paths.Layout{}, model.DaemonConfig{}, fmt.Errorf("banger service not reachable at %s; run `sudo banger system restart`", layout.SocketPath)
|
||||
}
|
||||
return layout, cfg, nil
|
||||
return paths.Layout{}, model.DaemonConfig{}, fmt.Errorf("banger service not running at %s; run `sudo banger system install`", layout.SocketPath)
|
||||
}
|
||||
|
||||
// daemonOutdated reports whether the running daemon binary differs
|
||||
// from the one on disk — useful after `make install` when the user's
|
||||
// session still holds a handle to an old daemon. os.SameFile compares
|
||||
// inode + dev, so a fresh binary at the same path registers as
|
||||
// different.
|
||||
func (d *deps) daemonOutdated(pid int) bool {
|
||||
if pid <= 0 {
|
||||
return false
|
||||
func userHint() string {
|
||||
if sudoUser := strings.TrimSpace(os.Getenv("SUDO_USER")); sudoUser != "" {
|
||||
return sudoUser
|
||||
}
|
||||
daemonBin, err := d.bangerdPath()
|
||||
if err != nil {
|
||||
return false
|
||||
if user := strings.TrimSpace(os.Getenv("USER")); user != "" {
|
||||
return user
|
||||
}
|
||||
currentInfo, err := os.Stat(daemonBin)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
runningInfo, err := os.Stat(d.daemonExePath(pid))
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return !os.SameFile(currentInfo, runningInfo)
|
||||
}
|
||||
|
||||
func (d *deps) restartDaemon(ctx context.Context, layout paths.Layout, pid int) error {
|
||||
stopCtx, cancel := context.WithTimeout(ctx, 2*time.Second)
|
||||
defer cancel()
|
||||
|
||||
_, _ = rpc.Call[api.ShutdownResult](stopCtx, layout.SocketPath, "shutdown", api.Empty{})
|
||||
if waitForPIDExit(pid, 2*time.Second) {
|
||||
return d.startDaemon(ctx, layout)
|
||||
}
|
||||
if proc, err := os.FindProcess(pid); err == nil {
|
||||
_ = proc.Signal(syscall.SIGTERM)
|
||||
}
|
||||
if !waitForPIDExit(pid, 2*time.Second) {
|
||||
return fmt.Errorf("timed out restarting stale daemon pid %d", pid)
|
||||
}
|
||||
return d.startDaemon(ctx, layout)
|
||||
}
|
||||
|
||||
func waitForPIDExit(pid int, timeout time.Duration) bool {
|
||||
deadline := time.Now().Add(timeout)
|
||||
for time.Now().Before(deadline) {
|
||||
if !pidRunning(pid) {
|
||||
return true
|
||||
}
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
}
|
||||
return !pidRunning(pid)
|
||||
}
|
||||
|
||||
func pidRunning(pid int) bool {
|
||||
if pid <= 0 {
|
||||
return false
|
||||
}
|
||||
proc, err := os.FindProcess(pid)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return proc.Signal(syscall.Signal(0)) == nil
|
||||
}
|
||||
|
||||
func (d *deps) startDaemon(ctx context.Context, layout paths.Layout) error {
|
||||
if err := paths.Ensure(layout); err != nil {
|
||||
return err
|
||||
}
|
||||
logFile, err := os.OpenFile(layout.DaemonLog, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer logFile.Close()
|
||||
|
||||
daemonBin, err := paths.BangerdPath()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cmd := buildDaemonCommand(daemonBin)
|
||||
cmd.Stdout = logFile
|
||||
cmd.Stderr = logFile
|
||||
cmd.Stdin = nil
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
|
||||
if err := cmd.Start(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := rpc.WaitForSocket(layout.SocketPath, 5*time.Second); err != nil {
|
||||
return fmt.Errorf("daemon failed to start; inspect %s: %w", layout.DaemonLog, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func buildDaemonCommand(daemonBin string) *exec.Cmd {
|
||||
return exec.Command(daemonBin)
|
||||
return "<user>"
|
||||
}
|
||||
|
|
|
|||
215
internal/cli/daemon_lifecycle_test.go
Normal file
215
internal/cli/daemon_lifecycle_test.go
Normal file
|
|
@ -0,0 +1,215 @@
|
|||
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",
|
||||
"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",
|
||||
`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",
|
||||
"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_NET_ADMIN CAP_NET_RAW CAP_SYS_ADMIN",
|
||||
"ReadWritePaths=/var/lib/banger",
|
||||
"RuntimeDirectory=banger-root",
|
||||
"RuntimeDirectoryMode=0711",
|
||||
} {
|
||||
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)
|
||||
}
|
||||
}
|
||||
26
internal/cli/known_hosts.go
Normal file
26
internal/cli/known_hosts.go
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
package cli
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"banger/internal/guest"
|
||||
"banger/internal/model"
|
||||
)
|
||||
|
||||
func removeUserKnownHosts(vm model.VMRecord) error {
|
||||
knownHostsPath, err := bangerKnownHostsPath()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var hosts []string
|
||||
if ip := strings.TrimSpace(vm.Runtime.GuestIP); ip != "" {
|
||||
hosts = append(hosts, ip)
|
||||
}
|
||||
if dns := strings.TrimSpace(vm.Runtime.DNSName); dns != "" {
|
||||
hosts = append(hosts, dns)
|
||||
}
|
||||
if len(hosts) == 0 {
|
||||
return nil
|
||||
}
|
||||
return guest.RemoveKnownHosts(knownHostsPath, hosts...)
|
||||
}
|
||||
|
|
@ -158,6 +158,8 @@ func (d *deps) runVMRun(ctx context.Context, socketPath string, cfg model.Daemon
|
|||
defer cancel()
|
||||
if err := d.vmDelete(cleanupCtx, socketPath, vmRef); err != nil {
|
||||
printVMRunWarning(stderr, fmt.Sprintf("--rm cleanup failed: %v (leaked vm %q; delete manually)", err, vmRef))
|
||||
} else if err := removeUserKnownHosts(vm); err != nil {
|
||||
printVMRunWarning(stderr, fmt.Sprintf("known_hosts cleanup failed: %v", err))
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue