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 }