From ca63011f25e3e5aa2b09b9a3c49d9ef7e2ac8f1a Mon Sep 17 00:00:00 2001 From: tomnewton-mulga Date: Mon, 13 Apr 2026 15:37:16 +1000 Subject: [PATCH] feat: GRUB env var headless install, disk selection modes, banner MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - autoinstall: replace file scanner with env var reader (SPINIFEX_AUTO, SPINIFEX_HOSTNAME, etc.) set by spinifex-init.sh from kernel cmdline - autoinstall: disk selection modes — auto (single or fail), largest, smallest, nvme, or exact path; clear error listing available disks when auto/nvme finds multiple candidates - admin banner: add Login row showing spinifex account name - admin banner: write to /etc/issue (pre-login console) as well as /etc/motd (post-login SSH); fix SSH line root@ → spinifex@ --- cmd/installer/autoinstall/autoinstall.go | 421 +++++++++-------------- cmd/spinifex/cmd/admin.go | 16 +- 2 files changed, 173 insertions(+), 264 deletions(-) diff --git a/cmd/installer/autoinstall/autoinstall.go b/cmd/installer/autoinstall/autoinstall.go index 9df4cc4ba..f302f86e3 100644 --- a/cmd/installer/autoinstall/autoinstall.go +++ b/cmd/installer/autoinstall/autoinstall.go @@ -15,9 +15,9 @@ You should have received a copy of the GNU Affero General Public License along with this program. If not, see . */ -// Package autoinstall scans boot media for an autoinstall.toml and, when -// headless mode is enabled, converts it into an install.Config that the -// installer can run without any user interaction. +// Package autoinstall reads headless install parameters from environment +// variables exported by spinifex-init.sh from the kernel cmdline. +// Parameters are set in the GRUB "Headless" menu entry in grub.cfg. package autoinstall import ( @@ -26,112 +26,36 @@ import ( "net" "os" "os/exec" - "path/filepath" "sort" "strconv" "strings" "time" "github.com/mulgadc/spinifex/cmd/installer/install" - "github.com/pelletier/go-toml/v2" ) -const configFileName = "autoinstall.toml" - -// fileConfig mirrors the structure of autoinstall.toml. -type fileConfig struct { - Autoinstall struct { - Enabled bool `toml:"enabled"` - } `toml:"autoinstall"` - - Node struct { - Hostname string `toml:"hostname"` - Password string `toml:"password"` - } `toml:"node"` - - Disk struct { - Target string `toml:"target"` - } `toml:"disk"` - - Network struct { - WAN ifaceConfig `toml:"wan"` - LAN ifaceConfig `toml:"lan"` - } `toml:"network"` - - Cluster struct { - Role string `toml:"role"` - JoinAddr string `toml:"join_addr"` - } `toml:"cluster"` -} - -type ifaceConfig struct { - Interface string `toml:"interface"` - Mode string `toml:"mode"` // "dhcp" or "static" - Address string `toml:"address"` // static only - Mask string `toml:"mask"` // static only - Gateway string `toml:"gateway"` // static only, WAN only - DNS []string `toml:"dns"` // static only -} - -// Load scans boot media for an autoinstall.toml. Returns nil if the file is -// not found or enabled = false. Returns an error only if a config was found -// but could not be parsed or is invalid. +// Load returns an install.Config built from SPINIFEX_* environment variables, +// or nil if SPINIFEX_AUTO is not set to "1" (interactive mode). func Load() (*install.Config, error) { - path, mountpoint, err := findConfigFile() - if err != nil { - slog.Debug("autoinstall scan failed", "err", err) - return nil, nil - } - if path == "" { - slog.Debug("autoinstall: no config found, using interactive mode") + if os.Getenv("SPINIFEX_AUTO") != "1" { return nil, nil } - data, err := os.ReadFile(path) + slog.Info("autoinstall: headless mode enabled via kernel cmdline") + + cfg, err := buildConfig() if err != nil { - return nil, fmt.Errorf("read autoinstall config: %w", err) + return nil, fmt.Errorf("autoinstall config: %w", err) } - - var fc fileConfig - if err := toml.Unmarshal(data, &fc); err != nil { - return nil, fmt.Errorf("parse autoinstall config: %w", err) - } - - if !fc.Autoinstall.Enabled { - slog.Info("autoinstall: config found but enabled = false, using interactive mode") - return nil, nil - } - - slog.Info("autoinstall: headless mode enabled", "config", path) - - cfg, err := toInstallConfig(fc) - if err != nil { - return nil, fmt.Errorf("autoinstall config invalid: %w", err) - } - - // Persist the mountpoint so EjectAndReboot knows which device to eject. - if mountpoint != "" { - _ = os.WriteFile("/run/spinifex-autoinstall-src", []byte(mountpoint), 0o600) - } - return cfg, nil } -// EjectAndReboot ejects the USB that provided the autoinstall config (if -// identifiable) then reboots. Call this after a successful headless install to -// prevent the node looping back into the installer on next boot. +// EjectAndReboot ejects the boot device (best-effort) then reboots. func EjectAndReboot() { - mp, _ := os.ReadFile("/run/spinifex-autoinstall-src") - mountpoint := strings.TrimSpace(string(mp)) - if mountpoint != "" { - if dev := deviceForMountpoint(mountpoint); dev != "" { - // Walk up from partition (/dev/sdb1) to the whole disk (/dev/sdb). - disk := strings.TrimRight(dev, "0123456789") - slog.Info("autoinstall: ejecting source device", "disk", disk) - _ = exec.Command("eject", disk).Run() - } - _ = exec.Command("umount", mountpoint).Run() - _ = os.Remove(mountpoint) + srcDev, _ := os.ReadFile("/run/iso-dev") + if disk := strings.TrimRight(strings.TrimSpace(string(srcDev)), "0123456789"); disk != "" { + slog.Info("autoinstall: ejecting boot device", "device", disk) + _ = exec.Command("eject", disk).Run() } fmt.Println() @@ -142,218 +66,123 @@ func EjectAndReboot() { _ = exec.Command("reboot").Run() } -// findConfigFile returns the path of autoinstall.toml and its mountpoint (if -// the scanner had to mount a partition to find it). Skips iso9660 mounts so -// the read-only reference copy bundled in the ISO never triggers headless mode. -func findConfigFile() (path, mountpoint string, err error) { - // Prefer a file on a writable (non-iso9660) already-mounted filesystem. - mounts, _ := os.ReadFile("/proc/mounts") - for line := range strings.SplitSeq(string(mounts), "\n") { - fields := strings.Fields(line) - if len(fields) < 3 { - continue - } - fstype := fields[2] - mp := fields[1] - // Skip virtual and read-only filesystems. - switch fstype { - case "iso9660", "squashfs", "tmpfs", "proc", "sysfs", "devtmpfs", "devpts": - continue - } - candidate := filepath.Join(mp, configFileName) - if _, statErr := os.Stat(candidate); statErr == nil { - return candidate, "", nil - } +func buildConfig() (*install.Config, error) { + password := os.Getenv("SPINIFEX_PASSWORD") + if password == "" { + return nil, fmt.Errorf("SPINIFEX_PASSWORD is required") } - // Fall back to scanning the USB that the ISO was loaded from. - srcDev, _ := os.ReadFile("/run/iso-dev") - baseDev := strings.TrimSpace(string(srcDev)) - if baseDev == "" { - return "", "", nil - } - // Strip partition suffix to get the disk name (e.g. /dev/sdb1 → sdb). - diskName := filepath.Base(strings.TrimRight(baseDev, "0123456789")) - - partEntries, readErr := os.ReadDir("/sys/block/" + diskName) - if readErr != nil { - return "", "", nil - } - for _, pe := range partEntries { - partName := pe.Name() - if !strings.HasPrefix(partName, diskName) { - continue - } - partDev := "/dev/" + partName - - // Skip if already mounted as iso9660. - if mountedAs(partDev) == "iso9660" { - continue - } - - mp, err := mountReadOnly(partDev) - if err != nil { - continue - } - - candidate := filepath.Join(mp, configFileName) - if _, statErr := os.Stat(candidate); statErr == nil { - return candidate, mp, nil - } - - // Not here — unmount and try next partition. - _ = exec.Command("umount", mp).Run() - _ = os.Remove(mp) - } - - return "", "", nil -} - -// mountReadOnly mounts partDev read-only under a temporary directory, trying -// vfat (EFI) then ext4 then a generic auto-detect. Returns the mountpoint. -func mountReadOnly(partDev string) (string, error) { - mp := "/tmp/spinifex-cfg-" + filepath.Base(partDev) - if err := os.MkdirAll(mp, 0o700); err != nil { - return "", err - } - for _, fstype := range []string{"vfat", "ext4", "auto"} { - args := []string{"-t", fstype, "-o", "ro", partDev, mp} - if fstype == "auto" { - args = []string{"-o", "ro", partDev, mp} - } - if exec.Command("mount", args...).Run() == nil { - return mp, nil - } - } - _ = os.Remove(mp) - return "", fmt.Errorf("could not mount %s", partDev) -} - -// mountedAs returns the filesystem type that partDev is currently mounted with, -// or an empty string if it is not mounted. -func mountedAs(partDev string) string { - data, _ := os.ReadFile("/proc/mounts") - for line := range strings.SplitSeq(string(data), "\n") { - fields := strings.Fields(line) - if len(fields) >= 3 && fields[0] == partDev { - return fields[2] - } - } - return "" -} - -// deviceForMountpoint returns the block device mounted at mp, or empty string. -func deviceForMountpoint(mp string) string { - data, _ := os.ReadFile("/proc/mounts") - for line := range strings.SplitSeq(string(data), "\n") { - fields := strings.Fields(line) - if len(fields) >= 2 && fields[1] == mp { - return fields[0] - } - } - return "" -} - -// toInstallConfig converts a parsed fileConfig into an install.Config, -// applying defaults and validating required fields. -func toInstallConfig(fc fileConfig) (*install.Config, error) { - if fc.Node.Password == "" { - return nil, fmt.Errorf("node.password is required") - } - - hostname := fc.Node.Hostname + hostname := os.Getenv("SPINIFEX_HOSTNAME") if hostname == "" { hostname = "spinifex-node" } - disk, err := resolveDisk(fc.Disk.Target) + disk, err := resolveDisk(os.Getenv("SPINIFEX_DISK")) if err != nil { return nil, fmt.Errorf("disk: %w", err) } - wanNIC, err := resolveNIC(fc.Network.WAN.Interface, "") + wanIface, err := resolveNIC(os.Getenv("SPINIFEX_WAN_IFACE"), "") if err != nil { - return nil, fmt.Errorf("network.wan.interface: %w", err) + return nil, fmt.Errorf("WAN NIC: %w", err) } cfg := &install.Config{ Disk: disk, Hostname: hostname, - RootPassword: fc.Node.Password, - WANInterface: wanNIC, - WANDHCPMode: strings.ToLower(fc.Network.WAN.Mode) != "static", + RootPassword: password, + WANInterface: wanIface, + WANDHCPMode: strings.ToLower(os.Getenv("SPINIFEX_WAN_MODE")) != "static", } if !cfg.WANDHCPMode { - if fc.Network.WAN.Address == "" || fc.Network.WAN.Mask == "" || fc.Network.WAN.Gateway == "" { - return nil, fmt.Errorf("network.wan: address, mask, and gateway are required for mode = \"static\"") + ip := os.Getenv("SPINIFEX_WAN_IP") + mask := os.Getenv("SPINIFEX_WAN_MASK") + gw := os.Getenv("SPINIFEX_WAN_GW") + if ip == "" || mask == "" || gw == "" { + return nil, fmt.Errorf("SPINIFEX_WAN_IP, SPINIFEX_WAN_MASK, SPINIFEX_WAN_GW required for static mode") + } + cfg.WANAddress = ip + cfg.WANMask = mask + cfg.WANGateway = gw + if dns := os.Getenv("SPINIFEX_WAN_DNS"); dns != "" { + cfg.WANDNS = strings.Split(dns, ",") } - cfg.WANAddress = fc.Network.WAN.Address - cfg.WANMask = fc.Network.WAN.Mask - cfg.WANGateway = fc.Network.WAN.Gateway - cfg.WANDNS = fc.Network.WAN.DNS } - // LAN is optional — only configured when interface is specified. - if fc.Network.LAN.Interface != "" { - lanNIC, err := resolveNIC(fc.Network.LAN.Interface, wanNIC) + if lanIface := os.Getenv("SPINIFEX_LAN_IFACE"); lanIface != "" { + lan, err := resolveNIC(lanIface, wanIface) if err != nil { - return nil, fmt.Errorf("network.lan.interface: %w", err) + return nil, fmt.Errorf("LAN NIC: %w", err) } - cfg.LANInterface = lanNIC - cfg.LANDHCPMode = strings.ToLower(fc.Network.LAN.Mode) != "static" + cfg.LANInterface = lan + cfg.LANDHCPMode = strings.ToLower(os.Getenv("SPINIFEX_LAN_MODE")) != "static" if !cfg.LANDHCPMode { - if fc.Network.LAN.Address == "" || fc.Network.LAN.Mask == "" { - return nil, fmt.Errorf("network.lan: address and mask are required for mode = \"static\"") + cfg.LANAddress = os.Getenv("SPINIFEX_LAN_IP") + cfg.LANMask = os.Getenv("SPINIFEX_LAN_MASK") + if dns := os.Getenv("SPINIFEX_LAN_DNS"); dns != "" { + cfg.LANDNS = strings.Split(dns, ",") } - cfg.LANAddress = fc.Network.LAN.Address - cfg.LANMask = fc.Network.LAN.Mask - cfg.LANDNS = fc.Network.LAN.DNS } } - role := strings.ToLower(fc.Cluster.Role) + role := strings.ToLower(os.Getenv("SPINIFEX_ROLE")) if role == "" { role = "init" } cfg.ClusterRole = role if role == "join" { - if fc.Cluster.JoinAddr == "" { - return nil, fmt.Errorf("cluster.join_addr is required when role = \"join\"") + joinAddr := os.Getenv("SPINIFEX_JOIN_ADDR") + if joinAddr == "" { + return nil, fmt.Errorf("SPINIFEX_JOIN_ADDR required when SPINIFEX_ROLE=join") } - cfg.JoinAddr = fc.Cluster.JoinAddr + cfg.JoinAddr = joinAddr } return cfg, nil } -// resolveDisk returns the block device path to install onto. "auto" or empty -// selects the largest non-removable, non-optical disk. +// resolveDisk maps the spx_disk value from grub.cfg to a block device path. +// +// Supported values: +// +// auto — use the only non-removable disk; fail if multiple found +// largest — largest non-removable disk (explicit opt-in) +// smallest — smallest non-removable disk (typical OS-on-SSD pattern) +// nvme — the only NVMe disk; fail if multiple found +// /dev/sda (etc.) — exact device path; fail if not present func resolveDisk(target string) (string, error) { - if target != "" && target != "auto" { + switch strings.ToLower(target) { + case "", "auto": + return singleDisk() + case "largest": + return diskBySize(true) + case "smallest": + return diskBySize(false) + case "nvme": + return nvmeDisk() + default: if _, err := os.Stat(target); err != nil { return "", fmt.Errorf("%q not found", target) } return target, nil } - return largestNonRemovableDisk() } -func largestNonRemovableDisk() (string, error) { +type diskCandidate struct { + dev string + bytes int64 +} + +// nonRemovableDisks returns all non-removable, non-virtual block devices. +func nonRemovableDisks() ([]diskCandidate, error) { entries, err := os.ReadDir("/sys/block") if err != nil { - return "", err + return nil, err } - - type candidate struct { - dev string - bytes int64 - } - var candidates []candidate - + var out []diskCandidate for _, e := range entries { dev := e.Name() - // Skip virtual/optical devices. switch { case strings.HasPrefix(dev, "loop"), strings.HasPrefix(dev, "ram"), @@ -366,23 +195,95 @@ func largestNonRemovableDisk() (string, error) { continue } sizeRaw, _ := os.ReadFile("/sys/block/" + dev + "/size") - sectors, err := strconv.ParseInt(strings.TrimSpace(string(sizeRaw)), 10, 64) - if err != nil || sectors == 0 { + sectors, parseErr := strconv.ParseInt(strings.TrimSpace(string(sizeRaw)), 10, 64) + if parseErr != nil || sectors == 0 { continue } - candidates = append(candidates, candidate{dev: dev, bytes: sectors * 512}) + out = append(out, diskCandidate{dev: dev, bytes: sectors * 512}) } + return out, nil +} - if len(candidates) == 0 { - return "", fmt.Errorf("no suitable disk found (all disks are removable or virtual)") +// diskList returns a human-readable list of candidates for error messages. +func diskList(disks []diskCandidate) string { + lines := make([]string, len(disks)) + for i, d := range disks { + lines[i] = fmt.Sprintf(" /dev/%s (%dG)", d.dev, d.bytes>>30) } + return strings.Join(lines, "\n") +} - sort.Slice(candidates, func(i, j int) bool { - return candidates[i].bytes > candidates[j].bytes +// singleDisk selects the only non-removable disk, or fails listing all found. +func singleDisk() (string, error) { + disks, err := nonRemovableDisks() + if err != nil { + return "", err + } + switch len(disks) { + case 0: + return "", fmt.Errorf("no non-removable disks found") + case 1: + slog.Info("autoinstall: single disk selected", "disk", disks[0].dev, "size_gb", disks[0].bytes>>30) + return "/dev/" + disks[0].dev, nil + default: + return "", fmt.Errorf( + "multiple disks found — set SPINIFEX_DISK to one of:\n%s\n"+ + " largest (largest disk)\n"+ + " smallest (smallest disk)\n"+ + " nvme (NVMe only)\n"+ + " /dev/sdX (exact path)", + diskList(disks), + ) + } +} + +// diskBySize selects the largest (largest=true) or smallest non-removable disk. +func diskBySize(largest bool) (string, error) { + disks, err := nonRemovableDisks() + if err != nil { + return "", err + } + if len(disks) == 0 { + return "", fmt.Errorf("no non-removable disks found") + } + sort.Slice(disks, func(i, j int) bool { + if largest { + return disks[i].bytes > disks[j].bytes + } + return disks[i].bytes < disks[j].bytes }) - slog.Info("autoinstall: selected target disk", "disk", candidates[0].dev, "size_gb", - candidates[0].bytes>>30) - return "/dev/" + candidates[0].dev, nil + label := "smallest" + if largest { + label = "largest" + } + slog.Info("autoinstall: disk selected by size", "mode", label, "disk", disks[0].dev, "size_gb", disks[0].bytes>>30) + return "/dev/" + disks[0].dev, nil +} + +// nvmeDisk selects the only NVMe disk, or fails listing all NVMe disks found. +func nvmeDisk() (string, error) { + disks, err := nonRemovableDisks() + if err != nil { + return "", err + } + var nvmes []diskCandidate + for _, d := range disks { + if strings.HasPrefix(d.dev, "nvme") { + nvmes = append(nvmes, d) + } + } + switch len(nvmes) { + case 0: + return "", fmt.Errorf("no NVMe disks found") + case 1: + slog.Info("autoinstall: NVMe disk selected", "disk", nvmes[0].dev, "size_gb", nvmes[0].bytes>>30) + return "/dev/" + nvmes[0].dev, nil + default: + return "", fmt.Errorf( + "multiple NVMe disks found — set SPINIFEX_DISK to one of:\n%s", + diskList(nvmes), + ) + } } // resolveNIC returns the interface name to use. "auto" or empty picks the diff --git a/cmd/spinifex/cmd/admin.go b/cmd/spinifex/cmd/admin.go index 19774b06f..898767670 100644 --- a/cmd/spinifex/cmd/admin.go +++ b/cmd/spinifex/cmd/admin.go @@ -2148,6 +2148,7 @@ func runAdminBanner(cmd *cobra.Command, _ []string) { | Spinifex — Mulga Defense Corporation | +----------------------------------------------------+ | Node: %-40s| + | Login: %-40s| | Dashboard: %-40s| | API: %-40s| | SSH: %-40s| @@ -2157,14 +2158,21 @@ func runAdminBanner(cmd *cobra.Command, _ []string) { `, hostname, + "spinifex", "https://"+currentIP+":3000", "https://"+currentIP+":9999", - "root@"+currentIP, + "spinifex@"+currentIP, ) - // Append banner to /etc/motd, preserving any existing content (e.g. the - // Debian "ABSOLUTELY NO WARRANTY" disclaimer). A sentinel marks the start - // of our section so re-runs replace it cleanly rather than accumulating. + // Write to /etc/issue — displayed on the console before the login prompt. + // Overwrite entirely; this is a purpose-built appliance so we own this file. + if err := os.WriteFile("/etc/issue", []byte(banner), 0o644); err != nil { + slog.Warn("Failed to write /etc/issue", "err", err) + } + + // Append to /etc/motd — displayed after SSH login, preserving any existing + // content (e.g. the Debian disclaimer). A sentinel marks our section so + // re-runs replace it cleanly rather than accumulating. if err := appendBannerToMotd(banner); err != nil { slog.Warn("Failed to write /etc/motd", "err", err) }