mirror of
https://github.com/mulgadc/spinifex.git
synced 2026-10-15 17:11:30 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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 ""
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user