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:
tomnewton-mulga
2026-04-10 10:02:16 +10:00
parent e8e05d6898
commit c5b6beb006
4 changed files with 904 additions and 218 deletions
+17 -11
View File
@@ -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)
+21 -5
View File
@@ -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
+80 -32
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff