systemd's Type=simple reports a unit "active" the moment its ExecStart binary is exec()'d, which for bangerd happens well before the daemon has read its config and bound /run/banger/bangerd.sock. 'banger system install' and 'banger system restart' both returned inside that window, so the very next 'banger ...' command would hit ensureDaemon, miss on a single ping, and exit with "service not reachable; run sudo banger system restart" — the same restart that had just succeeded. Smoke tripped over this on every run. Add waitForDaemonReady: poll daemonPing for up to 15s after the restart returns. Both the system install and restart paths now block until the daemon is genuinely accepting RPCs, so the next CLI invocation can talk to it without retrying. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
417 lines
13 KiB
Go
417 lines
13 KiB
Go
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 banger's owner-daemon and root-helper systemd units",
|
|
Long: strings.TrimSpace(`
|
|
Banger ships as two services: an owner-user daemon for
|
|
orchestration and a narrow root helper for bridge/tap, NAT, and
|
|
Firecracker launch. 'banger system' installs, restarts, inspects,
|
|
and removes them.
|
|
|
|
First-run flow (must be run as root):
|
|
|
|
sudo banger system install --owner $USER install both services
|
|
banger system status confirm they're up
|
|
banger doctor check host readiness
|
|
|
|
After 'install', the owner user can run 'banger ...' day to day
|
|
without sudo. Subsequent invocations:
|
|
|
|
sudo banger system restart bounce both services
|
|
sudo banger system uninstall remove services + binaries
|
|
sudo banger system uninstall --purge also delete /var/lib/banger
|
|
|
|
See docs/privileges.md for the full trust model.
|
|
`),
|
|
Example: strings.TrimSpace(`
|
|
sudo banger system install --owner alice
|
|
banger system status
|
|
sudo banger system uninstall --purge
|
|
`),
|
|
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
|
|
}
|
|
if err := d.waitForDaemonReady(cmd.Context(), paths.ResolveSystem().SocketPath); 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
|
|
}
|
|
if err := d.waitForDaemonReady(ctx, installmeta.DefaultSocketPath); 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
|
|
}
|