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
This commit is contained in:
tomnewton-mulga
2026-04-09 14:39:56 +10:00
parent 10a2a8116f
commit cbb3e91ada
2 changed files with 170 additions and 51 deletions
+9 -51
View File
@@ -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:-<unknown>}}"
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
+161
View File
@@ -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 = "<unknown>"
}
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 ""
}