mirror of
https://github.com/mulgadc/spinifex.git
synced 2026-10-20 11:21:49 +00:00
feat: multi-NIC WAN/LAN bridge network setup in ISO installer (mulga-897)
- 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)
This commit is contained in:
@@ -27,9 +27,11 @@ import (
|
||||
|
||||
// Config holds the values the firstboot service needs to configure the node.
|
||||
type Config struct {
|
||||
Hostname string
|
||||
OVNInterface string
|
||||
ManagementIP string
|
||||
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".
|
||||
@@ -60,6 +62,13 @@ func Write(root string, cfg Config) error {
|
||||
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
|
||||
@@ -68,13 +77,10 @@ set -euo pipefail
|
||||
hostnamectl set-hostname %s
|
||||
|
||||
# Configure OVN networking.
|
||||
# --macvlan creates a virtual sub-interface (spx-ext-<NIC>) off the management
|
||||
# NIC so OVN gets its own L2 path without stealing the host IP (SSH-safe).
|
||||
/usr/local/bin/spinifex-setup-ovn.sh \
|
||||
--management \
|
||||
--macvlan \
|
||||
--wan-iface=%s \
|
||||
--encap-ip=%s
|
||||
# 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
|
||||
@@ -90,7 +96,7 @@ systemctl start spinifex.target
|
||||
|
||||
# Disable this service so it never runs again
|
||||
systemctl disable spinifex-firstboot.service
|
||||
`, cfg.Hostname, cfg.OVNInterface, cfg.ManagementIP, clusterCmd)
|
||||
`, cfg.Hostname, setupOVN, clusterCmd)
|
||||
|
||||
path := filepath.Join(root, "usr/local/bin/spinifex-firstboot.sh")
|
||||
return os.WriteFile(path, []byte(script), 0o755)
|
||||
|
||||
@@ -24,11 +24,27 @@ type Config struct {
|
||||
// Disk is the block device path to install onto (e.g. /dev/sda).
|
||||
Disk string
|
||||
|
||||
// Network
|
||||
ManagementIP string
|
||||
SubnetMask string
|
||||
Gateway string
|
||||
OVNInterface string
|
||||
// WAN interface — always present. The management IP lives on br-wan (a Linux
|
||||
// bridge over the NIC), not on the physical NIC itself. This allows OVN/OVS
|
||||
// to safely attach to the bridge without disrupting host connectivity.
|
||||
WANInterface string
|
||||
WANDHCPMode bool
|
||||
WANAddress string // empty if WANDHCPMode
|
||||
WANMask string // empty if WANDHCPMode
|
||||
WANGateway string // empty if WANDHCPMode
|
||||
WANDNS []string // empty if WANDHCPMode; e.g. ["1.1.1.1", "8.8.8.8"]
|
||||
WANWiFiSSID string // non-empty if a WiFi NIC was selected
|
||||
WANWiFiPass string // non-empty if a WiFi NIC was selected
|
||||
|
||||
// LAN interface — only set when 2+ NICs are present. The Geneve encap IP
|
||||
// for OVN tunnels lives on br-lan. When empty, the WAN address is used.
|
||||
LANInterface string
|
||||
LANDHCPMode bool
|
||||
LANAddress string
|
||||
LANMask string
|
||||
LANDNS []string
|
||||
LANWiFiSSID string
|
||||
LANWiFiPass string
|
||||
|
||||
// Node identity
|
||||
Hostname string
|
||||
|
||||
@@ -183,10 +183,12 @@ func installSpinifex(cfg *Config) error {
|
||||
}
|
||||
}
|
||||
|
||||
// Write /etc/spinifex/node.conf — read at runtime by spinifex-banner.sh
|
||||
// Write /etc/spinifex/node.conf — read at runtime by spx admin banner
|
||||
// to look up the current IP dynamically (handles IP changes after install).
|
||||
nodeConf := fmt.Sprintf("MANAGEMENT_IP=%s\nMANAGEMENT_IFACE=%s\nNODE_HOSTNAME=%s\n",
|
||||
cfg.ManagementIP, cfg.OVNInterface, cfg.Hostname)
|
||||
// MANAGEMENT_IFACE is the bridge (br-wan), not the physical NIC.
|
||||
// MANAGEMENT_IP is empty for DHCP — banner's --boot-check fills it in at boot.
|
||||
nodeConf := fmt.Sprintf("MANAGEMENT_IP=%s\nMANAGEMENT_IFACE=br-wan\nNODE_HOSTNAME=%s\n",
|
||||
cfg.WANAddress, cfg.Hostname)
|
||||
confDir := filepath.Join(mountRoot, "etc/spinifex")
|
||||
if err := os.MkdirAll(confDir, 0o755); err != nil {
|
||||
return err
|
||||
@@ -195,39 +197,79 @@ func installSpinifex(cfg *Config) error {
|
||||
}
|
||||
|
||||
func writeNetworkConfig(cfg *Config) error {
|
||||
// Write a simple /etc/network/interfaces for the management interface.
|
||||
// The OVN interface is left unconfigured here — setup-ovn.sh handles it
|
||||
// on first boot.
|
||||
content := fmt.Sprintf(`# Generated by Spinifex installer
|
||||
auto lo
|
||||
iface lo inet loopback
|
||||
// IPs live on Linux bridges (br-wan, br-lan), not on the physical NICs.
|
||||
// This lets OVN/OVS wire into the bridges non-destructively via veth pairs
|
||||
// without ever moving the IP off a live interface (SSH-safe).
|
||||
var b strings.Builder
|
||||
b.WriteString("# Generated by Spinifex installer\nsource /etc/network/interfaces.d/*\n\nauto lo\niface lo inet loopback\n\n")
|
||||
|
||||
auto %s
|
||||
iface %s inet static
|
||||
address %s
|
||||
netmask %s
|
||||
gateway %s
|
||||
`, cfg.OVNInterface, cfg.OVNInterface, cfg.ManagementIP, cfg.SubnetMask, cfg.Gateway)
|
||||
writeBridge := func(nicIface, bridgeName string, dhcp bool, addr, mask, gw string, dns []string, ssid, wifiPass string, hasGateway bool) {
|
||||
// Physical NIC: brought up with no IP so the bridge can own it.
|
||||
comment := strings.ToUpper(bridgeName[3:]) // "br-wan" → "WAN", "br-lan" → "LAN"
|
||||
fmt.Fprintf(&b, "# %s NIC\nauto %s\niface %s inet manual\n", comment, nicIface, nicIface)
|
||||
if ssid != "" {
|
||||
fmt.Fprintf(&b, " wpa-ssid %s\n wpa-psk %s\n", ssid, wifiPass)
|
||||
}
|
||||
if !dhcp {
|
||||
for _, ns := range dns {
|
||||
ns = strings.TrimSpace(ns)
|
||||
if ns != "" {
|
||||
fmt.Fprintf(&b, " dns-nameservers %s\n", ns)
|
||||
}
|
||||
}
|
||||
}
|
||||
b.WriteString("\n")
|
||||
|
||||
if err := os.WriteFile(filepath.Join(mountRoot, "etc/network/interfaces"), []byte(content), 0o644); err != nil {
|
||||
// Bridge stanza: carries the IP.
|
||||
fmt.Fprintf(&b, "# %s bridge\nauto %s\n", comment, bridgeName)
|
||||
if dhcp {
|
||||
fmt.Fprintf(&b, "iface %s inet dhcp\n", bridgeName)
|
||||
} else {
|
||||
fmt.Fprintf(&b, "iface %s inet static\n address %s\n netmask %s\n", bridgeName, addr, mask)
|
||||
if hasGateway && gw != "" {
|
||||
fmt.Fprintf(&b, " gateway %s\n", gw)
|
||||
}
|
||||
}
|
||||
fmt.Fprintf(&b, " bridge_ports %s\n bridge_stp off\n bridge_fd 0\n bridge_maxwait 0\n\n", nicIface)
|
||||
}
|
||||
|
||||
writeBridge(cfg.WANInterface, "br-wan", cfg.WANDHCPMode,
|
||||
cfg.WANAddress, cfg.WANMask, cfg.WANGateway, cfg.WANDNS,
|
||||
cfg.WANWiFiSSID, cfg.WANWiFiPass, true)
|
||||
|
||||
if cfg.LANInterface != "" {
|
||||
writeBridge(cfg.LANInterface, "br-lan", cfg.LANDHCPMode,
|
||||
cfg.LANAddress, cfg.LANMask, "", cfg.LANDNS,
|
||||
cfg.LANWiFiSSID, cfg.LANWiFiPass, false)
|
||||
}
|
||||
|
||||
if err := os.WriteFile(filepath.Join(mountRoot, "etc/network/interfaces"), []byte(b.String()), 0o644); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Pin the NIC name to its MAC address via a udev rule so the installed
|
||||
// system always uses the same name regardless of the host's udev naming
|
||||
// policy (predictable names vs eth0-style). Robust across hardware.
|
||||
mac, err := os.ReadFile("/sys/class/net/" + cfg.OVNInterface + "/address")
|
||||
if err != nil {
|
||||
slog.Warn("writeNetworkConfig: could not read NIC MAC, skipping udev pin", "iface", cfg.OVNInterface, "err", err)
|
||||
return nil
|
||||
}
|
||||
udevRule := fmt.Sprintf("SUBSYSTEM==\"net\", ACTION==\"add\", ATTR{address}==\"%s\", NAME=\"%s\"\n",
|
||||
strings.TrimSpace(string(mac)), cfg.OVNInterface)
|
||||
// Pin each NIC name to its MAC address via udev so the installed system
|
||||
// always uses the same interface name regardless of probe order.
|
||||
udevDir := filepath.Join(mountRoot, "etc/udev/rules.d")
|
||||
if err := os.MkdirAll(udevDir, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(filepath.Join(udevDir, "70-spinifex-net.rules"), []byte(udevRule), 0o644)
|
||||
var udevRules strings.Builder
|
||||
for _, iface := range []string{cfg.WANInterface, cfg.LANInterface} {
|
||||
if iface == "" {
|
||||
continue
|
||||
}
|
||||
mac, err := os.ReadFile("/sys/class/net/" + iface + "/address")
|
||||
if err != nil {
|
||||
slog.Warn("writeNetworkConfig: could not read NIC MAC, skipping udev pin", "iface", iface, "err", err)
|
||||
continue
|
||||
}
|
||||
fmt.Fprintf(&udevRules, "SUBSYSTEM==\"net\", ACTION==\"add\", ATTR{address}==\"%s\", NAME=\"%s\"\n",
|
||||
strings.TrimSpace(string(mac)), iface)
|
||||
}
|
||||
if udevRules.Len() > 0 {
|
||||
return os.WriteFile(filepath.Join(udevDir, "70-spinifex-net.rules"), []byte(udevRules.String()), 0o644)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func installBootloader(disk string) error {
|
||||
@@ -319,12 +361,18 @@ func reboot() error {
|
||||
|
||||
// toFirstbootConfig maps installer Config to the firstboot package's Config.
|
||||
func (c *Config) toFirstbootConfig() firstboot.Config {
|
||||
// Geneve encap IP: use LAN bridge IP when a dedicated LAN NIC is present,
|
||||
// otherwise fall back to WAN bridge IP. Empty for DHCP — setup-ovn.sh
|
||||
// auto-detects the IP from the default route at boot in that case.
|
||||
encapIP := c.WANAddress
|
||||
if c.LANInterface != "" && c.LANAddress != "" {
|
||||
encapIP = c.LANAddress
|
||||
}
|
||||
return firstboot.Config{
|
||||
Hostname: c.Hostname,
|
||||
OVNInterface: c.OVNInterface,
|
||||
ManagementIP: c.ManagementIP,
|
||||
ClusterRole: c.ClusterRole,
|
||||
JoinAddr: c.JoinAddr,
|
||||
Hostname: c.Hostname,
|
||||
EncapIP: encapIP,
|
||||
ClusterRole: c.ClusterRole,
|
||||
JoinAddr: c.JoinAddr,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+786
-170
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user