mirror of
https://github.com/mulgadc/spinifex.git
synced 2026-10-23 04:36:40 +00:00
c5b6beb006
- Replace single network screen with separate WAN and LAN screens - WAN/LAN IPs now live on Linux bridges (br-wan, br-lan) over the physical NICs, not on the NICs directly — makes OVS/OVN veth attachment SSH-safe - Add DHCP/static toggle on both screens (DHCP is the default) - Add DNS nameserver input for static mode (comma-separated, default hint 1.1.1.1 / 8.8.8.8 shown as placeholder) - Detect WiFi NICs via /sys/class/net/<name>/wireless; show SSID and password fields when a WiFi adapter is selected - LAN screen shows WAN NIC greyed/unavailable; cursor skips over it - LAN screen only shown when 2+ NICs are detected - Geneve encap IP uses br-lan address when LAN NIC present, else br-wan - Remove --macvlan/--wan-iface from firstboot; setup-ovn.sh auto-detects br-wan as a Linux bridge and uses veth mode - Pin both WAN and LAN NIC names to MAC via a single udev rules file - node.conf writes MANAGEMENT_IFACE=br-wan (bridge, not physical NIC)
200 lines
6.7 KiB
Go
200 lines
6.7 KiB
Go
/*
|
|
Copyright © 2026 Mulga Defense Corporation
|
|
|
|
This program is free software: you can redistribute it and/or modify
|
|
it under the terms of the GNU Affero General Public License as published by
|
|
the Free Software Foundation, either version 3 of the License, or
|
|
(at your option) any later version.
|
|
|
|
This program is distributed in the hope that it will be useful,
|
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
GNU Affero General Public License for more details.
|
|
|
|
You should have received a copy of the GNU Affero General Public License
|
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
*/
|
|
|
|
// Package firstboot writes the oneshot systemd service and configuration that
|
|
// completes Spinifex provisioning on the first real boot after installation.
|
|
package firstboot
|
|
|
|
import (
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
)
|
|
|
|
// Config holds the values the firstboot service needs to configure the node.
|
|
type Config struct {
|
|
Hostname string
|
|
// EncapIP is the Geneve tunnel IP for OVN. Set to the LAN bridge IP when a
|
|
// dedicated LAN NIC is present, otherwise the WAN bridge IP. Empty when DHCP
|
|
// is used — setup-ovn.sh auto-detects the IP from the default route in that case.
|
|
EncapIP string
|
|
// ClusterRole is "init" or "join".
|
|
ClusterRole string
|
|
// JoinAddr is host:port of the primary node, only used when ClusterRole is "join".
|
|
JoinAddr string
|
|
}
|
|
|
|
// Write drops the firstboot script and systemd unit into root, which should be
|
|
// the path of the installed system's root filesystem (e.g. /mnt/spinifex-install).
|
|
func Write(root string, cfg Config) error {
|
|
if err := writeScript(root, cfg); err != nil {
|
|
return fmt.Errorf("firstboot script: %w", err)
|
|
}
|
|
if err := writeUnit(root); err != nil {
|
|
return fmt.Errorf("firstboot unit: %w", err)
|
|
}
|
|
if err := enableUnit(root); err != nil {
|
|
return err
|
|
}
|
|
if err := writeBannerUnit(root); err != nil {
|
|
return fmt.Errorf("banner unit: %w", err)
|
|
}
|
|
if err := enableBannerUnit(root); err != nil {
|
|
return err
|
|
}
|
|
return writeGettyDropIn(root)
|
|
}
|
|
|
|
func writeScript(root string, cfg Config) error {
|
|
clusterCmd := buildClusterCmd(cfg)
|
|
|
|
// --encap-ip is optional: when DHCP is used the IP is unknown at install time
|
|
// and setup-ovn.sh auto-detects it from the default route at boot.
|
|
setupOVN := "/usr/local/bin/spinifex-setup-ovn.sh \\\n --management"
|
|
if cfg.EncapIP != "" {
|
|
setupOVN += fmt.Sprintf(" \\\n --encap-ip=%s", cfg.EncapIP)
|
|
}
|
|
|
|
script := fmt.Sprintf(`#!/bin/bash
|
|
# Spinifex firstboot — runs once after ISO installation, then disables itself.
|
|
set -euo pipefail
|
|
|
|
# Set hostname
|
|
hostnamectl set-hostname %s
|
|
|
|
# Configure OVN networking.
|
|
# br-wan (and br-lan if present) are Linux bridges created by the installer.
|
|
# setup-ovn.sh auto-detects br-wan as the default route device (Linux bridge)
|
|
# and wires it to OVS via a veth pair — non-destructive, SSH-safe.
|
|
%s
|
|
|
|
# Cluster formation — capture credentials to file for display on console.
|
|
%s 2>&1 | tee /root/spinifex-credentials.txt
|
|
chmod 600 /root/spinifex-credentials.txt
|
|
|
|
# Fix ownership: spx admin init runs as root (no SUDO_USER in systemd context)
|
|
# so config and data files are created as root:root. Hand them to the service
|
|
# user so systemd units running as spinifex can read them.
|
|
chown -R spinifex:spinifex /etc/spinifex /var/lib/spinifex
|
|
|
|
# Start services
|
|
systemctl start spinifex.target
|
|
|
|
# Disable this service so it never runs again
|
|
systemctl disable spinifex-firstboot.service
|
|
`, cfg.Hostname, setupOVN, clusterCmd)
|
|
|
|
path := filepath.Join(root, "usr/local/bin/spinifex-firstboot.sh")
|
|
return os.WriteFile(path, []byte(script), 0o755)
|
|
}
|
|
|
|
func writeUnit(root string) error {
|
|
unit := `[Unit]
|
|
Description=Spinifex first-boot provisioning
|
|
After=network-online.target
|
|
Wants=network-online.target
|
|
ConditionPathExists=/usr/local/bin/spinifex-firstboot.sh
|
|
|
|
[Service]
|
|
Type=oneshot
|
|
Environment=HOME=/root
|
|
ExecStart=/usr/local/bin/spinifex-firstboot.sh
|
|
RemainAfterExit=yes
|
|
StandardOutput=journal
|
|
StandardError=journal
|
|
|
|
[Install]
|
|
WantedBy=multi-user.target
|
|
`
|
|
path := filepath.Join(root, "etc/systemd/system/spinifex-firstboot.service")
|
|
return os.WriteFile(path, []byte(unit), 0o644)
|
|
}
|
|
|
|
func enableUnit(root string) error {
|
|
wantsDir := filepath.Join(root, "etc/systemd/system/multi-user.target.wants")
|
|
if err := os.MkdirAll(wantsDir, 0o755); err != nil {
|
|
return err
|
|
}
|
|
link := filepath.Join(wantsDir, "spinifex-firstboot.service")
|
|
target := "/etc/systemd/system/spinifex-firstboot.service"
|
|
// Remove stale symlink if present.
|
|
_ = os.Remove(link)
|
|
return os.Symlink(target, link)
|
|
}
|
|
|
|
func buildClusterCmd(cfg Config) string {
|
|
switch cfg.ClusterRole {
|
|
case "join":
|
|
return fmt.Sprintf("spx admin join --node %s --host %s", cfg.Hostname, cfg.JoinAddr)
|
|
default:
|
|
return fmt.Sprintf("spx admin init --node %s --nodes 1", cfg.Hostname)
|
|
}
|
|
}
|
|
|
|
// writeBannerUnit writes the spinifex-banner.service unit that runs
|
|
// `spx admin banner --boot-check` on every boot after spinifex.target is up.
|
|
// Running After=spinifex.target ensures the banner reflects a settled system
|
|
// state and that the IP check/restart happens once services are already running.
|
|
func writeBannerUnit(root string) error {
|
|
unit := `[Unit]
|
|
Description=Spinifex console banner and boot health check
|
|
After=spinifex.target
|
|
Wants=spinifex.target
|
|
|
|
[Service]
|
|
Type=oneshot
|
|
ExecStart=/usr/local/bin/spx admin banner --boot-check
|
|
RemainAfterExit=yes
|
|
StandardOutput=journal
|
|
StandardError=journal
|
|
|
|
[Install]
|
|
WantedBy=multi-user.target
|
|
`
|
|
path := filepath.Join(root, "etc/systemd/system/spinifex-banner.service")
|
|
return os.WriteFile(path, []byte(unit), 0o644)
|
|
}
|
|
|
|
func enableBannerUnit(root string) error {
|
|
wantsDir := filepath.Join(root, "etc/systemd/system/multi-user.target.wants")
|
|
if err := os.MkdirAll(wantsDir, 0o755); err != nil {
|
|
return err
|
|
}
|
|
link := filepath.Join(wantsDir, "spinifex-banner.service")
|
|
target := "/etc/systemd/system/spinifex-banner.service"
|
|
_ = os.Remove(link)
|
|
return os.Symlink(target, link)
|
|
}
|
|
|
|
// writeGettyDropIn makes all getty instances (tty1, ttyS0, etc.) wait for
|
|
// spinifex-banner.service before showing the login prompt. This ensures:
|
|
// - All spinifex services have settled (banner runs After=spinifex.target)
|
|
// - The /etc/motd banner is written before the user sees the login prompt
|
|
// - Service startup messages finish scrolling before the prompt appears
|
|
func writeGettyDropIn(root string) error {
|
|
dropIn := `[Unit]
|
|
After=spinifex-banner.service
|
|
Wants=spinifex-banner.service
|
|
`
|
|
// Drop-in on getty@.service applies to all instances: tty1, ttyS0, etc.
|
|
dropInDir := filepath.Join(root, "etc/systemd/system/getty@.service.d")
|
|
if err := os.MkdirAll(dropInDir, 0o755); err != nil {
|
|
return err
|
|
}
|
|
return os.WriteFile(filepath.Join(dropInDir, "spinifex-wait.conf"), []byte(dropIn), 0o644)
|
|
}
|