From cbb3e91ada517733f4a97e7294441a927c0f72fa Mon Sep 17 00:00:00 2001 From: tomnewton-mulga Date: Thu, 9 Apr 2026 14:39:56 +1000 Subject: [PATCH] feat: add spx admin banner --boot-check, replace bash banner script (mulga-866) - add `spx admin banner --boot-check` subcommand to spx CLI - reads management IP from the NIC at runtime (not from node.conf) - if IP has changed, updates /etc/spinifex/node.conf and restarts spinifex.target - writes banner to /etc/issue (pre-login console) and /etc/motd (SSH MOTD) - banner instructs user to cat ~/.aws/credentials (standard AWS CLI location) - update firstboot.go to use spx-based banner unit; remove writeBannerScript and old spinifex-banner.sh entirely - new banner unit runs Before=spinifex.target so IP is current before services start --- cmd/installer/firstboot/firstboot.go | 60 ++-------- cmd/spinifex/cmd/admin.go | 161 +++++++++++++++++++++++++++ 2 files changed, 170 insertions(+), 51 deletions(-) diff --git a/cmd/installer/firstboot/firstboot.go b/cmd/installer/firstboot/firstboot.go index 370ba52ac..09f0b7c60 100644 --- a/cmd/installer/firstboot/firstboot.go +++ b/cmd/installer/firstboot/firstboot.go @@ -48,9 +48,6 @@ func Write(root string, cfg Config) error { if err := enableUnit(root); err != nil { return err } - if err := writeBannerScript(root, cfg); err != nil { - return fmt.Errorf("banner script: %w", err) - } if err := writeBannerUnit(root); err != nil { return fmt.Errorf("banner unit: %w", err) } @@ -139,59 +136,20 @@ func buildClusterCmd(cfg Config) string { } } -func writeBannerScript(root string, _ Config) error { - // The banner reads IP and interface at runtime from /etc/spinifex/node.conf - // so it stays correct if the operator changes the IP after install. - script := `#!/bin/bash -# Spinifex console banner — printed to tty1 on every boot after services start. - -# Load node config written by the installer. -# shellcheck source=/etc/spinifex/node.conf -. /etc/spinifex/node.conf 2>/dev/null || true - -# Resolve the current IP from the management interface at runtime. -IP="" -if [ -n "${MANAGEMENT_IFACE:-}" ]; then - IP=$(ip -4 addr show "$MANAGEMENT_IFACE" 2>/dev/null \ - | awk '/inet /{gsub(/\/.*/, "", $2); print $2; exit}') -fi -# Fall back to the IP recorded at install time. -IP="${IP:-${MANAGEMENT_IP:-}}" -HOST="${NODE_HOSTNAME:-$(hostname)}" - -{ - echo "" - echo " +----------------------------------------------------+" - echo " | Spinifex — Mulga Defense Corporation |" - echo " +----------------------------------------------------+" - printf " | Node: %-40s|\n" "$HOST" - printf " | Dashboard: %-40s|\n" "https://$IP:3000" - printf " | API: %-40s|\n" "https://$IP:9999" - printf " | SSH: %-40s|\n" "root@$IP" - echo " +----------------------------------------------------+" - echo " | Login credentials (AWS Access Key + Secret Key) |" - echo " | cat /root/spinifex-credentials.txt |" - if [ ! -f /root/spinifex-credentials.txt ]; then - echo " | (credentials file not found — check firstboot) |" - fi - echo " +----------------------------------------------------+" - echo "" -} | tee /dev/tty1 > /etc/motd -` - - path := filepath.Join(root, "usr/local/bin/spinifex-banner.sh") - return os.WriteFile(path, []byte(script), 0o755) -} - +// writeBannerUnit writes the spinifex-banner.service unit that runs +// `spx admin banner --boot-check` on every boot. This replaces the old +// spinifex-banner.sh bash script with the CLI command, which also handles +// management IP change detection. func writeBannerUnit(root string) error { unit := `[Unit] -Description=Spinifex console banner -After=spinifex.target -Wants=spinifex.target +Description=Spinifex console banner and boot health check +After=network-online.target +Before=spinifex.target +Wants=network-online.target [Service] Type=oneshot -ExecStart=/usr/local/bin/spinifex-banner.sh +ExecStart=/usr/local/bin/spx admin banner --boot-check RemainAfterExit=yes StandardOutput=journal StandardError=journal diff --git a/cmd/spinifex/cmd/admin.go b/cmd/spinifex/cmd/admin.go index 3491aa349..d03e63ff6 100644 --- a/cmd/spinifex/cmd/admin.go +++ b/cmd/spinifex/cmd/admin.go @@ -25,6 +25,7 @@ import ( "encoding/json" "fmt" "io" + "log/slog" "net" "net/http" "os" @@ -160,6 +161,17 @@ var accountListCmd = &cobra.Command{ Run: runAccountList, } +var adminBannerCmd = &cobra.Command{ + Use: "banner", + Short: "Write the Spinifex console banner to /etc/issue and /etc/motd", + Long: `Writes the node information banner to /etc/issue (shown before login on +physical/serial console) and /etc/motd (shown after SSH login). + +With --boot-check, also detects if the management IP has changed since last +boot and updates /etc/spinifex/node.conf accordingly.`, + Run: runAdminBanner, +} + var certCmd = &cobra.Command{ Use: "cert", Short: "Manage TLS certificates", @@ -214,6 +226,9 @@ func init() { accountCmd.AddCommand(accountCreateCmd) accountCmd.AddCommand(accountListCmd) + adminCmd.AddCommand(adminBannerCmd) + adminBannerCmd.Flags().Bool("boot-check", false, "Check for management IP change and update node.conf if needed") + adminCmd.AddCommand(certCmd) certCmd.AddCommand(certRenewCmd) certRenewCmd.Flags().StringSlice("extra-ip", nil, "Additional IP addresses to include in SANs") @@ -2084,3 +2099,149 @@ func installCACertificate(caPemPath string) { fmt.Printf("✅ CA certificate installed to system trust store\n") } + +// runAdminBanner writes the Spinifex console banner to /etc/issue and /etc/motd. +// With --boot-check it also detects management IP changes and updates node.conf. +func runAdminBanner(cmd *cobra.Command, _ []string) { + bootCheck, _ := cmd.Flags().GetBool("boot-check") + + const nodeConf = "/etc/spinifex/node.conf" + + // Parse /etc/spinifex/node.conf (KEY=VALUE shell format). + conf := parseNodeConf(nodeConf) + iface := conf["MANAGEMENT_IFACE"] + recordedIP := conf["MANAGEMENT_IP"] + hostname := conf["NODE_HOSTNAME"] + + if hostname == "" { + if h, err := os.Hostname(); err == nil { + hostname = h + } + } + + // Resolve current IP from the management interface. + currentIP := resolveIfaceIP(iface) + if currentIP == "" { + currentIP = recordedIP // fall back to recorded value + } + if currentIP == "" { + currentIP = "" + } + + if bootCheck && iface != "" && currentIP != recordedIP && recordedIP != "" { + slog.Info("Management IP changed", "old", recordedIP, "new", currentIP) + conf["MANAGEMENT_IP"] = currentIP + if err := writeNodeConf(nodeConf, conf); err != nil { + slog.Warn("Failed to update node.conf with new IP", "err", err) + } else { + slog.Info("Updated node.conf with new management IP", "ip", currentIP) + } + // Restart services that bind to the management IP. + restartCmd := exec.Command("systemctl", "restart", "spinifex.target") + restartCmd.Stdout = os.Stdout + restartCmd.Stderr = os.Stderr + if err := restartCmd.Run(); err != nil { + slog.Warn("Failed to restart spinifex.target after IP change", "err", err) + } + } + + banner := fmt.Sprintf(` + +----------------------------------------------------+ + | Spinifex — Mulga Defense Corporation | + +----------------------------------------------------+ + | Node: %-40s| + | Dashboard: %-40s| + | API: %-40s| + | SSH: %-40s| + +----------------------------------------------------+ + | AWS credentials: cat ~/.aws/credentials | + +----------------------------------------------------+ + +`, + hostname, + "https://"+currentIP+":3000", + "https://"+currentIP+":9999", + "root@"+currentIP, + ) + + // Write to /etc/issue (shown before login on physical/serial console). + if err := os.WriteFile("/etc/issue", []byte(banner), 0o644); err != nil { + slog.Warn("Failed to write /etc/issue", "err", err) + } + // Write to /etc/motd (shown after SSH login). + if err := os.WriteFile("/etc/motd", []byte(banner), 0o644); err != nil { + slog.Warn("Failed to write /etc/motd", "err", err) + } +} + +// parseNodeConf reads a KEY=VALUE shell-format file and returns a map. +// Lines starting with # and blank lines are ignored. +func parseNodeConf(path string) map[string]string { + result := make(map[string]string) + data, err := os.ReadFile(path) + if err != nil { + return result + } + for line := range strings.SplitSeq(string(data), "\n") { + line = strings.TrimSpace(line) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + k, v, ok := strings.Cut(line, "=") + if !ok { + continue + } + result[strings.TrimSpace(k)] = strings.TrimSpace(v) + } + return result +} + +// writeNodeConf serialises a KEY=VALUE map back to the node.conf file. +// Only writes keys that were present in the original (preserves order via known keys). +func writeNodeConf(path string, conf map[string]string) error { + // Write in a stable order matching what the installer creates. + keys := []string{"MANAGEMENT_IP", "MANAGEMENT_IFACE", "NODE_HOSTNAME"} + var b strings.Builder + written := make(map[string]bool) + for _, k := range keys { + if v, ok := conf[k]; ok { + fmt.Fprintf(&b, "%s=%s\n", k, v) + written[k] = true + } + } + // Append any extra keys not in the known list. + for k, v := range conf { + if !written[k] { + fmt.Fprintf(&b, "%s=%s\n", k, v) + } + } + return os.WriteFile(path, []byte(b.String()), 0o644) +} + +// resolveIfaceIP returns the first IPv4 address assigned to iface, or "". +func resolveIfaceIP(iface string) string { + if iface == "" { + return "" + } + netIface, err := net.InterfaceByName(iface) + if err != nil { + return "" + } + addrs, err := netIface.Addrs() + if err != nil { + return "" + } + for _, addr := range addrs { + var ip net.IP + switch v := addr.(type) { + case *net.IPNet: + ip = v.IP + case *net.IPAddr: + ip = v.IP + } + if ip != nil && ip.To4() != nil { + return ip.String() + } + } + return "" +}