Files
spinifex/cmd/installer/ui/ui.go
T
2026-07-09 09:43:31 +10:00

1653 lines
44 KiB
Go

// Package ui presents the interactive installer TUI using bubbletea and lipgloss.
package ui
import (
"errors"
"fmt"
"log/slog"
"net"
"os"
"strings"
"github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/mulgadc/spinifex/cmd/installer/branding"
"github.com/mulgadc/spinifex/cmd/installer/install"
"github.com/mulgadc/spinifex/spinifex/admin"
)
// screen represents which step of the wizard is active.
type screen int
const (
screenWelcome screen = iota
screenDisk
screenDiskConfirm
screenNetworkWAN
screenNetworkLAN
screenIdentity
screenPassword
screenJoinConfig
screenConfirm
screenDone // signals completion; program exits
)
// nicInfo holds display info for a network interface.
type nicInfo struct {
Name string
IsWiFi bool
}
// wanField tracks which element is focused on the WAN network screen.
type wanField int
const (
wanFieldNIC wanField = iota // NIC picker
wanFieldMethod // DHCP / Static toggle
wanFieldIP // static only
wanFieldMask // static only
wanFieldGateway // static only
wanFieldDNS // static only
wanFieldSSID // WiFi NIC only
wanFieldWiFiPass // WiFi NIC only
)
// lanField tracks which element is focused on the LAN network screen.
type lanField int
const (
lanFieldNIC lanField = iota // NIC picker (WAN NIC shown greyed)
lanFieldMethod // DHCP / Static toggle
lanFieldIP // static only
lanFieldMask // static only
lanFieldDNS // static only
lanFieldSSID // WiFi NIC only
lanFieldWiFiPass // WiFi NIC only
)
// model is the top-level bubbletea model for the installer wizard.
type model struct {
screen screen
width int
height int
// Disk selection
disks []diskInfo
diskCursor int
eraseInput textinput.Model
// NIC list (shared between WAN and LAN screens)
nics []nicInfo
// WAN network screen
wanNicCursor int
wanNicManualInput textinput.Model // used when no NICs are auto-detected
wanDHCP bool
wanFocus wanField
wanIP textinput.Model
wanMask textinput.Model
wanGateway textinput.Model
wanDNS textinput.Model
wanSSID textinput.Model
wanWiFiPass textinput.Model
// LAN network screen (only shown when len(nics) > 1)
lanNicCursor int
lanDHCP bool
lanFocus lanField
lanIP textinput.Model
lanMask textinput.Model
lanDNS textinput.Model
lanSSID textinput.Model
lanWiFiPass textinput.Model
// Identity
hostnameInput textinput.Model
clusterRole int // 0 = init, 1 = join
// Join config
joinIPInput textinput.Model
joinPortInput textinput.Model
// Credentials (email + password)
emailInput textinput.Model
passwordInput textinput.Model
passwordConfirmInput textinput.Model
credsFocus int // 0 = email, 1 = password, 2 = confirm
// Accumulated validation error shown on current screen
validationErr string
// Final result — set when screenDone is reached
result *install.Config
err error
}
// Run launches the bubbletea program connected to ttyPath and returns the
// completed Config when the user finishes the wizard.
func Run(ttyPath string) (*install.Config, error) {
disks, err := availableDisks()
if err != nil {
return nil, fmt.Errorf("listing disks: %w", err)
}
if len(disks) == 0 {
return nil, errors.New("no block devices found")
}
nics, err := availableNICs()
if err != nil {
return nil, fmt.Errorf("listing network interfaces: %w", err)
}
m := newModel(disks, nics)
var opts []tea.ProgramOption
opts = append(opts, tea.WithAltScreen())
if ttyPath != "" {
tty, err := os.OpenFile(ttyPath, os.O_RDWR, 0)
if err != nil {
// Requested TTY unavailable (e.g. serial console selected but no
// serial port present). Fall back to tty1 rather than aborting so
// the installer remains usable on the display.
slog.Warn("ui: could not open requested TTY, falling back to tty1", "tty", ttyPath, "err", err)
if tty, err = os.OpenFile("/dev/tty1", os.O_RDWR, 0); err != nil {
return nil, fmt.Errorf("open fallback console /dev/tty1: %w", err)
}
}
opts = append(opts, tea.WithInput(tty), tea.WithOutput(tty))
}
p := tea.NewProgram(m, opts...)
final, err := p.Run()
if err != nil {
return nil, err
}
fm, ok := final.(model)
if !ok {
return nil, errors.New("unexpected model type")
}
if fm.err != nil {
return nil, fm.err
}
return fm.result, nil
}
func newModel(disks []diskInfo, nics []nicInfo) model {
eraseIn := textinput.New()
eraseIn.Placeholder = "yes"
eraseIn.CharLimit = 3
wanNicManualIn := textinput.New()
wanNicManualIn.Placeholder = "e.g. eth0, enp0s1"
wanNicManualIn.CharLimit = 32
wanIPIn := textinput.New()
wanIPIn.Placeholder = "192.168.1.10"
wanMaskIn := textinput.New()
wanMaskIn.Placeholder = "255.255.255.0 or 24"
wanGWIn := textinput.New()
wanGWIn.Placeholder = "192.168.1.1"
wanDNSIn := textinput.New()
wanDNSIn.Placeholder = "1.1.1.1, 8.8.8.8"
wanSSIDIn := textinput.New()
wanSSIDIn.Placeholder = "Network SSID"
wanSSIDIn.CharLimit = 64
wanWiFiPassIn := textinput.New()
wanWiFiPassIn.Placeholder = "WiFi password"
wanWiFiPassIn.EchoMode = textinput.EchoPassword
wanWiFiPassIn.CharLimit = 128
lanIPIn := textinput.New()
lanIPIn.Placeholder = "10.10.8.2"
lanMaskIn := textinput.New()
lanMaskIn.Placeholder = "255.255.255.0 or 24"
lanDNSIn := textinput.New()
lanDNSIn.Placeholder = "1.1.1.1, 8.8.8.8"
lanSSIDIn := textinput.New()
lanSSIDIn.Placeholder = "Network SSID"
lanSSIDIn.CharLimit = 64
lanWiFiPassIn := textinput.New()
lanWiFiPassIn.Placeholder = "WiFi password"
lanWiFiPassIn.EchoMode = textinput.EchoPassword
lanWiFiPassIn.CharLimit = 128
hostnameIn := textinput.New()
hostnameIn.Placeholder = "node1"
hostnameIn.CharLimit = 64
joinIPIn := textinput.New()
joinIPIn.Placeholder = "192.168.1.10"
joinPortIn := textinput.New()
joinPortIn.Placeholder = "4432"
joinPortIn.CharLimit = 5
emailIn := textinput.New()
emailIn.Placeholder = "admin@mydomain.com"
emailIn.CharLimit = 254 // RFC 5321 upper bound
emailIn.Width = 40
passIn := textinput.New()
passIn.Placeholder = "Admin password"
passIn.EchoMode = textinput.EchoPassword
passIn.CharLimit = 128
passConfirmIn := textinput.New()
passConfirmIn.Placeholder = "Confirm password"
passConfirmIn.EchoMode = textinput.EchoPassword
passConfirmIn.CharLimit = 128
// Initial LAN cursor: first NIC that is not the WAN NIC (cursor 0).
lanCursor := 0
if len(nics) > 1 {
lanCursor = 1 // wanNicCursor starts at 0
}
return model{
screen: screenWelcome,
disks: disks,
nics: nics,
eraseInput: eraseIn,
wanNicManualInput: wanNicManualIn,
wanDHCP: true, // DHCP is the default
wanIP: wanIPIn,
wanMask: wanMaskIn,
wanGateway: wanGWIn,
wanDNS: wanDNSIn,
wanSSID: wanSSIDIn,
wanWiFiPass: wanWiFiPassIn,
lanNicCursor: lanCursor,
lanDHCP: true, // DHCP is the default
lanIP: lanIPIn,
lanMask: lanMaskIn,
lanDNS: lanDNSIn,
lanSSID: lanSSIDIn,
lanWiFiPass: lanWiFiPassIn,
hostnameInput: hostnameIn,
emailInput: emailIn,
passwordInput: passIn,
passwordConfirmInput: passConfirmIn,
joinIPInput: joinIPIn,
joinPortInput: joinPortIn,
}
}
// ── Styles ────────────────────────────────────────────────────────────────────
var (
styleLogo = lipgloss.NewStyle().
Foreground(branding.ColorPrimary).
Bold(true)
styleTitle = lipgloss.NewStyle().
Foreground(branding.ColorPrimary).
Bold(true).
MarginBottom(1)
styleSubtitle = lipgloss.NewStyle().
Foreground(branding.ColorMuted)
styleBox = lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(branding.ColorBorder).
Padding(1, 2)
styleSelected = lipgloss.NewStyle().
Foreground(branding.ColorBackground).
Background(branding.ColorPrimary).
Bold(true)
styleWarning = lipgloss.NewStyle().
Foreground(branding.ColorWarning).
Bold(true)
styleError = lipgloss.NewStyle().
Foreground(branding.ColorError)
styleMuted = lipgloss.NewStyle().
Foreground(branding.ColorMuted)
styleSuccess = lipgloss.NewStyle().
Foreground(branding.ColorSuccess)
styleLabel = lipgloss.NewStyle().
Foreground(branding.ColorAccent).
Bold(true)
styleHelp = lipgloss.NewStyle().
Foreground(branding.ColorMuted).
MarginTop(1)
)
// ── Init / Update / View ──────────────────────────────────────────────────────
func (m model) Init() tea.Cmd {
return textinput.Blink
}
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.width = msg.Width
m.height = msg.Height
return m, nil
case tea.KeyMsg:
m.validationErr = ""
switch msg.String() {
case "ctrl+c":
m.err = errors.New("installation cancelled")
return m, tea.Quit
}
return m.handleKey(msg)
}
// Forward to active input
return m.updateActiveInput(msg)
}
func (m model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
key := msg.String()
switch m.screen {
case screenWelcome:
if key == "enter" || key == " " {
m.screen = screenDisk
}
case screenDisk:
switch key {
case "up", "k":
if m.diskCursor > 0 {
m.diskCursor--
}
case "down", "j":
if m.diskCursor < len(m.disks)-1 {
m.diskCursor++
}
case "enter":
m.screen = screenDiskConfirm
m.eraseInput.Focus()
m.eraseInput.SetValue("")
}
case screenDiskConfirm:
switch key {
case "enter":
if strings.ToLower(strings.TrimSpace(m.eraseInput.Value())) != "yes" {
m.validationErr = "Type 'yes' to confirm disk erasure"
return m, nil
}
m.screen = screenNetworkWAN
m.wanFocus = wanFieldNIC
m = m.withFocusedWANField()
case "esc":
m.screen = screenDisk
return m, nil
default:
var cmd tea.Cmd
m.eraseInput, cmd = m.eraseInput.Update(msg)
return m, cmd
}
case screenNetworkWAN:
return m.handleWANKey(key, msg)
case screenNetworkLAN:
return m.handleLANKey(key, msg)
case screenIdentity:
switch key {
case "esc":
m.hostnameInput.Blur()
if len(m.nics) > 1 {
m.screen = screenNetworkLAN
m = m.withFocusedLANField()
} else {
m.screen = screenNetworkWAN
m = m.withFocusedWANField()
}
case "tab", "down":
if m.hostnameInput.Focused() {
m.hostnameInput.Blur()
} else {
m.hostnameInput.Focus()
}
case "left", "right":
if m.hostnameInput.Focused() {
var cmd tea.Cmd
m.hostnameInput, cmd = m.hostnameInput.Update(msg)
return m, cmd
}
if key == "left" {
m.clusterRole = 0
} else {
m.clusterRole = 1
}
case "enter":
if m.hostnameInput.Focused() {
if strings.TrimSpace(m.hostnameInput.Value()) == "" {
m.validationErr = "Hostname is required"
return m, nil
}
m.hostnameInput.Blur()
return m, nil
}
if strings.TrimSpace(m.hostnameInput.Value()) == "" {
m.validationErr = "Hostname is required"
m.hostnameInput.Focus()
return m, nil
}
m.screen = screenPassword
m.emailInput.Focus()
m.credsFocus = 0
default:
if m.hostnameInput.Focused() {
var cmd tea.Cmd
m.hostnameInput, cmd = m.hostnameInput.Update(msg)
return m, cmd
}
}
case screenPassword:
switch key {
case "tab", "down":
m = m.setCredsFocus((m.credsFocus + 1) % 3)
case "shift+tab", "up":
m = m.setCredsFocus((m.credsFocus + 2) % 3)
case "enter":
// On email field: validate, then advance to password.
if m.credsFocus == 0 {
if err := admin.ValidateEmail(m.emailInput.Value()); err != nil {
m.validationErr = err.Error()
return m, nil
}
m.validationErr = ""
m = m.setCredsFocus(1)
return m, nil
}
// On password field: just advance (defer validation to confirm).
if m.credsFocus == 1 {
m = m.setCredsFocus(2)
return m, nil
}
// On confirm: validate email (again, in case user tabbed past),
// then validate password + match.
if err := admin.ValidateEmail(m.emailInput.Value()); err != nil {
m.validationErr = err.Error()
m = m.setCredsFocus(0)
return m, nil
}
pw := m.passwordInput.Value()
confirm := m.passwordConfirmInput.Value()
if pw == "" {
m.validationErr = "Password is required"
m = m.setCredsFocus(1)
return m, nil
}
if pw != confirm {
m.validationErr = "Passwords do not match"
return m, nil
}
m.validationErr = ""
if m.clusterRole == 1 {
m.screen = screenJoinConfig
m.joinIPInput.Focus()
} else {
m.screen = screenConfirm
}
case "esc":
m.emailInput.Blur()
m.passwordInput.Blur()
m.passwordConfirmInput.Blur()
m.screen = screenIdentity
m.hostnameInput.Focus()
default:
var cmd tea.Cmd
switch m.credsFocus {
case 0:
m.emailInput, cmd = m.emailInput.Update(msg)
case 1:
m.passwordInput, cmd = m.passwordInput.Update(msg)
default:
m.passwordConfirmInput, cmd = m.passwordConfirmInput.Update(msg)
}
return m, cmd
}
case screenJoinConfig:
switch key {
case "tab", "down":
if m.joinIPInput.Focused() {
m.joinIPInput.Blur()
m.joinPortInput.Focus()
} else {
m.joinPortInput.Blur()
m.joinIPInput.Focus()
}
case "enter":
if m.joinIPInput.Focused() {
m.joinIPInput.Blur()
m.joinPortInput.Focus()
return m, nil
}
joinIP := strings.TrimSpace(m.joinIPInput.Value())
if net.ParseIP(joinIP) == nil {
m.validationErr = "Invalid primary node IP"
return m, nil
}
m.screen = screenConfirm
case "esc":
m.joinIPInput.Blur()
m.joinPortInput.Blur()
m.screen = screenPassword
m = m.setCredsFocus(2)
default:
var cmd tea.Cmd
if m.joinIPInput.Focused() {
m.joinIPInput, cmd = m.joinIPInput.Update(msg)
} else {
m.joinPortInput, cmd = m.joinPortInput.Update(msg)
}
return m, cmd
}
case screenConfirm:
switch key {
case "enter", "y", "Y":
m.result = m.buildConfig()
m.screen = screenDone
return m, tea.Quit
case "n", "N":
m.err = errors.New("installation cancelled")
return m, tea.Quit
case "esc":
if m.clusterRole == 1 {
m.screen = screenJoinConfig
m.joinIPInput.Focus()
m.joinPortInput.Blur()
} else {
m.screen = screenPassword
m = m.setCredsFocus(0)
}
}
}
return m, nil
}
// setCredsFocus moves focus among the three credential inputs (email,
// password, confirm) and ensures exactly one is focused. Returns the
// updated model — callers must reassign (m = m.setCredsFocus(...)).
// Value receiver keeps model's method set consistent with the other
// bubbletea View/buildConfig methods (avoids golangci-lint recvcheck).
func (m model) setCredsFocus(i int) model {
m.emailInput.Blur()
m.passwordInput.Blur()
m.passwordConfirmInput.Blur()
switch i {
case 0:
m.emailInput.Focus()
case 1:
m.passwordInput.Focus()
default:
m.passwordConfirmInput.Focus()
i = 2
}
m.credsFocus = i
return m
}
// ── WAN screen key handling ───────────────────────────────────────────────────
func (m model) handleWANKey(key string, msg tea.KeyMsg) (tea.Model, tea.Cmd) {
isWiFi := len(m.nics) > 0 && m.nics[m.wanNicCursor].IsWiFi
switch key {
case "tab", "down":
m.wanFocus = m.wanNextFocus(m.wanFocus, true)
m = m.withFocusedWANField()
case "shift+tab", "up":
m.wanFocus = m.wanNextFocus(m.wanFocus, false)
m = m.withFocusedWANField()
case "left", "h":
switch m.wanFocus {
case wanFieldNIC:
if len(m.nics) > 0 && m.wanNicCursor > 0 {
m.wanNicCursor--
}
case wanFieldMethod:
m.wanDHCP = true
// If current focus would be invalid in DHCP mode, snap back.
m.wanFocus = m.wanClampFocus(m.wanFocus)
m = m.withFocusedWANField()
}
case "right", "l":
switch m.wanFocus {
case wanFieldNIC:
if len(m.nics) > 0 && m.wanNicCursor < len(m.nics)-1 {
m.wanNicCursor++
}
case wanFieldMethod:
m.wanDHCP = false
m = m.withFocusedWANField()
}
case "esc":
m.screen = screenDiskConfirm
m = m.withFocusedWANField()
return m, nil
case "enter":
// Always check last-field first: if we're on it, validate and advance screen.
if m.wanFocus == m.wanLastFocus() {
if errMsg := m.validateWAN(); errMsg != "" {
m.validationErr = errMsg
return m, nil
}
if len(m.nics) > 1 {
m.screen = screenNetworkLAN
m.lanFocus = lanFieldNIC
m.lanNicCursor = m.initialLANCursor()
m = m.withFocusedLANField()
} else {
m.screen = screenIdentity
m.hostnameInput.Focus()
}
return m, nil
}
// Otherwise advance to the next field.
m.wanFocus = m.wanNextFocus(m.wanFocus, true)
m = m.withFocusedWANField()
default:
// Forward keystrokes to the active text input.
switch m.wanFocus {
case wanFieldIP:
var cmd tea.Cmd
m.wanIP, cmd = m.wanIP.Update(msg)
return m, cmd
case wanFieldMask:
var cmd tea.Cmd
m.wanMask, cmd = m.wanMask.Update(msg)
return m, cmd
case wanFieldGateway:
var cmd tea.Cmd
m.wanGateway, cmd = m.wanGateway.Update(msg)
return m, cmd
case wanFieldDNS:
var cmd tea.Cmd
m.wanDNS, cmd = m.wanDNS.Update(msg)
return m, cmd
case wanFieldSSID:
if isWiFi {
var cmd tea.Cmd
m.wanSSID, cmd = m.wanSSID.Update(msg)
return m, cmd
}
case wanFieldWiFiPass:
if isWiFi {
var cmd tea.Cmd
m.wanWiFiPass, cmd = m.wanWiFiPass.Update(msg)
return m, cmd
}
case wanFieldNIC:
if len(m.nics) == 0 {
var cmd tea.Cmd
m.wanNicManualInput, cmd = m.wanNicManualInput.Update(msg)
return m, cmd
}
}
}
return m, nil
}
// wanNextFocus returns the next valid focus index, skipping fields that are
// hidden in the current DHCP/WiFi configuration.
func (m model) wanNextFocus(current wanField, forward bool) wanField {
return nextFocusInList[wanField](current, m.wanVisibleFields(), forward)
}
func (m model) wanLastFocus() wanField {
fields := m.wanVisibleFields()
return fields[len(fields)-1]
}
func (m model) wanVisibleFields() []wanField {
isWiFi := len(m.nics) > 0 && m.nics[m.wanNicCursor].IsWiFi
fields := []wanField{wanFieldNIC, wanFieldMethod}
if !m.wanDHCP {
fields = append(fields, wanFieldIP, wanFieldMask, wanFieldGateway, wanFieldDNS)
}
if isWiFi {
fields = append(fields, wanFieldSSID, wanFieldWiFiPass)
}
return fields
}
// wanClampFocus snaps the current focus to the nearest visible field (used
// when toggling between DHCP and static removes visible fields).
func (m model) wanClampFocus(current wanField) wanField {
for _, f := range m.wanVisibleFields() {
if f >= current {
return f
}
}
fields := m.wanVisibleFields()
return fields[len(fields)-1]
}
func (m model) withFocusedWANField() model {
m.wanIP.Blur()
m.wanMask.Blur()
m.wanGateway.Blur()
m.wanDNS.Blur()
m.wanSSID.Blur()
m.wanWiFiPass.Blur()
m.wanNicManualInput.Blur()
switch m.wanFocus {
case wanFieldIP:
m.wanIP.Focus()
case wanFieldMask:
m.wanMask.Focus()
case wanFieldGateway:
m.wanGateway.Focus()
case wanFieldDNS:
m.wanDNS.Focus()
case wanFieldSSID:
m.wanSSID.Focus()
case wanFieldWiFiPass:
m.wanWiFiPass.Focus()
case wanFieldNIC:
if len(m.nics) == 0 {
m.wanNicManualInput.Focus()
}
}
return m
}
func (m model) validateWAN() string {
if len(m.nics) == 0 && strings.TrimSpace(m.wanNicManualInput.Value()) == "" {
return "Enter interface name (e.g. eth0, enp0s1)"
}
if !m.wanDHCP {
ip := strings.TrimSpace(m.wanIP.Value())
mask := strings.TrimSpace(m.wanMask.Value())
gw := strings.TrimSpace(m.wanGateway.Value())
dns := strings.TrimSpace(m.wanDNS.Value())
if net.ParseIP(ip) == nil {
return "Invalid WAN IP address"
}
if !validSubnetMask(mask) {
return "Invalid subnet mask (e.g. 255.255.255.0 or 24)"
}
if net.ParseIP(gw) == nil {
return "Invalid gateway address"
}
if dns == "" {
return "Enter at least one DNS nameserver"
}
}
isWiFi := len(m.nics) > 0 && m.nics[m.wanNicCursor].IsWiFi
if isWiFi && strings.TrimSpace(m.wanSSID.Value()) == "" {
return "WiFi SSID is required"
}
return ""
}
// ── LAN screen key handling ───────────────────────────────────────────────────
func (m model) handleLANKey(key string, msg tea.KeyMsg) (tea.Model, tea.Cmd) {
isWiFi := len(m.nics) > m.lanNicCursor && m.nics[m.lanNicCursor].IsWiFi
switch key {
case "tab", "down":
m.lanFocus = m.lanNextFocus(m.lanFocus, true)
m = m.withFocusedLANField()
case "shift+tab", "up":
m.lanFocus = m.lanNextFocus(m.lanFocus, false)
m = m.withFocusedLANField()
case "left", "h":
switch m.lanFocus {
case lanFieldNIC:
m = m.moveLANCursorLeft()
case lanFieldMethod:
m.lanDHCP = true
m.lanFocus = m.lanClampFocus(m.lanFocus)
m = m.withFocusedLANField()
}
case "right", "l":
switch m.lanFocus {
case lanFieldNIC:
m = m.moveLANCursorRight()
case lanFieldMethod:
m.lanDHCP = false
m = m.withFocusedLANField()
}
case "esc":
m.screen = screenNetworkWAN
m = m.withFocusedWANField()
return m, nil
case "enter":
if m.lanFocus == m.lanLastFocus() {
if errMsg := m.validateLAN(); errMsg != "" {
m.validationErr = errMsg
return m, nil
}
m.screen = screenIdentity
m.hostnameInput.Focus()
return m, nil
}
m.lanFocus = m.lanNextFocus(m.lanFocus, true)
m = m.withFocusedLANField()
default:
switch m.lanFocus {
case lanFieldIP:
var cmd tea.Cmd
m.lanIP, cmd = m.lanIP.Update(msg)
return m, cmd
case lanFieldMask:
var cmd tea.Cmd
m.lanMask, cmd = m.lanMask.Update(msg)
return m, cmd
case lanFieldDNS:
var cmd tea.Cmd
m.lanDNS, cmd = m.lanDNS.Update(msg)
return m, cmd
case lanFieldSSID:
if isWiFi {
var cmd tea.Cmd
m.lanSSID, cmd = m.lanSSID.Update(msg)
return m, cmd
}
case lanFieldWiFiPass:
if isWiFi {
var cmd tea.Cmd
m.lanWiFiPass, cmd = m.lanWiFiPass.Update(msg)
return m, cmd
}
}
}
return m, nil
}
func (m model) lanNextFocus(current lanField, forward bool) lanField {
return nextFocusInList[lanField](current, m.lanVisibleFields(), forward)
}
func (m model) lanLastFocus() lanField {
fields := m.lanVisibleFields()
return fields[len(fields)-1]
}
func (m model) lanVisibleFields() []lanField {
isWiFi := len(m.nics) > m.lanNicCursor && m.nics[m.lanNicCursor].IsWiFi
fields := []lanField{lanFieldNIC, lanFieldMethod}
if !m.lanDHCP {
fields = append(fields, lanFieldIP, lanFieldMask, lanFieldDNS)
}
if isWiFi {
fields = append(fields, lanFieldSSID, lanFieldWiFiPass)
}
return fields
}
func (m model) lanClampFocus(current lanField) lanField {
for _, f := range m.lanVisibleFields() {
if f >= current {
return f
}
}
fields := m.lanVisibleFields()
return fields[len(fields)-1]
}
func (m model) withFocusedLANField() model {
m.lanIP.Blur()
m.lanMask.Blur()
m.lanDNS.Blur()
m.lanSSID.Blur()
m.lanWiFiPass.Blur()
switch m.lanFocus {
case lanFieldIP:
m.lanIP.Focus()
case lanFieldMask:
m.lanMask.Focus()
case lanFieldDNS:
m.lanDNS.Focus()
case lanFieldSSID:
m.lanSSID.Focus()
case lanFieldWiFiPass:
m.lanWiFiPass.Focus()
}
return m
}
// initialLANCursor returns the first NIC index that is not the WAN NIC.
func (m model) initialLANCursor() int {
for i := range m.nics {
if i != m.wanNicCursor {
return i
}
}
return 0
}
// moveLANCursorLeft/Right skip the WAN NIC so it can never be selected.
// If no valid position exists in that direction, the cursor stays put.
func (m model) moveLANCursorLeft() model {
for i := m.lanNicCursor - 1; i >= 0; i-- {
if i != m.wanNicCursor {
m.lanNicCursor = i
return m
}
}
return m
}
func (m model) moveLANCursorRight() model {
for i := m.lanNicCursor + 1; i < len(m.nics); i++ {
if i != m.wanNicCursor {
m.lanNicCursor = i
return m
}
}
return m
}
func (m model) validateLAN() string {
if !m.lanDHCP {
ip := strings.TrimSpace(m.lanIP.Value())
mask := strings.TrimSpace(m.lanMask.Value())
dns := strings.TrimSpace(m.lanDNS.Value())
if net.ParseIP(ip) == nil {
return "Invalid LAN IP address"
}
if !validSubnetMask(mask) {
return "Invalid subnet mask (e.g. 255.255.255.0 or 24)"
}
if dns == "" {
return "Enter at least one DNS nameserver"
}
}
isWiFi := len(m.nics) > m.lanNicCursor && m.nics[m.lanNicCursor].IsWiFi
if isWiFi && strings.TrimSpace(m.lanSSID.Value()) == "" {
return "WiFi SSID is required"
}
return ""
}
// nextFocusInList finds current in the list and returns the next/prev entry,
// wrapping around. Used by both WAN and LAN focus helpers.
func nextFocusInList[T ~int](current T, list []T, forward bool) T {
for i, v := range list {
if v == current {
if forward {
return list[(i+1)%len(list)]
}
return list[(i-1+len(list))%len(list)]
}
}
// current not in list (e.g. after a mode change) — return first/last
if forward {
return list[0]
}
return list[len(list)-1]
}
func (m model) updateActiveInput(msg tea.Msg) (tea.Model, tea.Cmd) {
switch m.screen {
case screenDiskConfirm:
var cmd tea.Cmd
m.eraseInput, cmd = m.eraseInput.Update(msg)
return m, cmd
case screenNetworkWAN:
switch m.wanFocus {
case wanFieldIP:
var cmd tea.Cmd
m.wanIP, cmd = m.wanIP.Update(msg)
return m, cmd
case wanFieldMask:
var cmd tea.Cmd
m.wanMask, cmd = m.wanMask.Update(msg)
return m, cmd
case wanFieldGateway:
var cmd tea.Cmd
m.wanGateway, cmd = m.wanGateway.Update(msg)
return m, cmd
case wanFieldDNS:
var cmd tea.Cmd
m.wanDNS, cmd = m.wanDNS.Update(msg)
return m, cmd
case wanFieldSSID:
var cmd tea.Cmd
m.wanSSID, cmd = m.wanSSID.Update(msg)
return m, cmd
case wanFieldWiFiPass:
var cmd tea.Cmd
m.wanWiFiPass, cmd = m.wanWiFiPass.Update(msg)
return m, cmd
case wanFieldNIC:
if len(m.nics) == 0 {
var cmd tea.Cmd
m.wanNicManualInput, cmd = m.wanNicManualInput.Update(msg)
return m, cmd
}
}
case screenNetworkLAN:
switch m.lanFocus {
case lanFieldIP:
var cmd tea.Cmd
m.lanIP, cmd = m.lanIP.Update(msg)
return m, cmd
case lanFieldMask:
var cmd tea.Cmd
m.lanMask, cmd = m.lanMask.Update(msg)
return m, cmd
case lanFieldDNS:
var cmd tea.Cmd
m.lanDNS, cmd = m.lanDNS.Update(msg)
return m, cmd
case lanFieldSSID:
var cmd tea.Cmd
m.lanSSID, cmd = m.lanSSID.Update(msg)
return m, cmd
case lanFieldWiFiPass:
var cmd tea.Cmd
m.lanWiFiPass, cmd = m.lanWiFiPass.Update(msg)
return m, cmd
}
case screenIdentity:
if m.hostnameInput.Focused() {
var cmd tea.Cmd
m.hostnameInput, cmd = m.hostnameInput.Update(msg)
return m, cmd
}
case screenPassword:
var cmd tea.Cmd
switch m.credsFocus {
case 0:
m.emailInput, cmd = m.emailInput.Update(msg)
case 1:
m.passwordInput, cmd = m.passwordInput.Update(msg)
default:
m.passwordConfirmInput, cmd = m.passwordConfirmInput.Update(msg)
}
return m, cmd
}
return m, nil
}
// ── View ──────────────────────────────────────────────────────────────────────
func (m model) View() string {
w := m.width
if w == 0 {
w = 80
}
var content string
switch m.screen {
case screenWelcome:
content = m.viewWelcome(w)
case screenDisk:
content = m.viewDisk(w)
case screenDiskConfirm:
content = m.viewDiskConfirm(w)
case screenNetworkWAN:
content = m.viewNetworkWAN(w)
case screenNetworkLAN:
content = m.viewNetworkLAN(w)
case screenIdentity:
content = m.viewIdentity(w)
case screenPassword:
content = m.viewPassword(w)
case screenJoinConfig:
content = m.viewJoinConfig(w)
case screenConfirm:
content = m.viewConfirm(w)
case screenDone:
content = m.viewDone(w)
}
return content
}
func (m model) viewWelcome(w int) string {
logo := styleLogo.Render(branding.Logo)
subtitle := styleSubtitle.Render(branding.Subtitle)
publisher := styleMuted.Render(branding.Publisher)
warning := styleWarning.Render("WARNING: Installation will erase the selected disk entirely.")
help := styleHelp.Render("Press Enter to begin")
body := lipgloss.JoinVertical(lipgloss.Center,
logo,
subtitle,
publisher,
"",
warning,
"",
help,
)
return lipgloss.Place(w, m.height, lipgloss.Center, lipgloss.Center,
styleBox.Width(min(w-4, 72)).Render(body),
)
}
func (m model) viewDisk(w int) string {
title := styleTitle.Render("Select Installation Disk")
subtitle := styleMuted.Render("All data on the selected disk will be permanently erased.")
var rows []string
for i, d := range m.disks {
line := fmt.Sprintf(" %-20s %-8s %s", d.Path, d.Size, d.Model)
if i == m.diskCursor {
line = styleSelected.Render("> " + line[2:])
} else {
line = styleMuted.Render(line)
}
rows = append(rows, line)
}
help := styleHelp.Render("↑/↓ to select • Enter to confirm")
body := lipgloss.JoinVertical(lipgloss.Left, append([]string{title, subtitle, ""}, append(rows, "", help)...)...)
return lipgloss.Place(w, m.height, lipgloss.Center, lipgloss.Center,
styleBox.Width(min(w-4, 72)).Render(body),
)
}
func (m model) viewDiskConfirm(w int) string {
title := styleTitle.Render("Confirm Disk Erasure")
disk := styleLabel.Render(m.disks[m.diskCursor].Path)
msg := fmt.Sprintf("All data on %s will be permanently erased.\nType 'yes' to confirm:", disk)
var lines []string
lines = append(lines, title, msg, "", m.eraseInput.View())
if m.validationErr != "" {
lines = append(lines, "", styleError.Render(m.validationErr))
}
lines = append(lines, styleHelp.Render("Enter to confirm • Esc to go back"))
body := lipgloss.JoinVertical(lipgloss.Left, lines...)
return lipgloss.Place(w, m.height, lipgloss.Center, lipgloss.Center,
styleBox.Width(min(w-4, 64)).Render(body),
)
}
// renderNICPicker renders a horizontal NIC selector. disabledIdx is the index
// to show as greyed/unavailable (-1 to disable none).
func (m model) renderNICPicker(cursor, disabledIdx int, focused bool) string {
if len(m.nics) == 0 {
return ""
}
var parts []string
for i, nic := range m.nics {
tag := "[ETH]"
if nic.IsWiFi {
tag = "[WIFI]"
}
label := fmt.Sprintf("%s %s", tag, nic.Name)
switch {
case i == disabledIdx:
parts = append(parts, styleMuted.Render("("+label+")"))
case i == cursor && focused:
parts = append(parts, styleSelected.Render(" "+label+" "))
case i == cursor:
parts = append(parts, styleLabel.Render("["+label+"]"))
default:
parts = append(parts, styleMuted.Render(label))
}
}
return strings.Join(parts, " ")
}
func (m model) viewNetworkWAN(w int) string {
title := styleTitle.Render("WAN Network (Step 1 of " + m.networkStepCount() + ")")
subtitle := styleMuted.Render("The management IP is assigned to a bridge (br-wan) over this NIC.")
var lines []string
lines = append(lines, title, subtitle, "")
// NIC picker
nicLabel := styleLabel.Render("WAN network interface")
var nicLine string
if len(m.nics) == 0 {
nicLine = m.wanNicManualInput.View()
} else {
nicLine = m.renderNICPicker(m.wanNicCursor, -1, m.wanFocus == wanFieldNIC)
}
lines = append(lines, nicLabel, nicLine, "")
// IP method toggle
methodLabel := styleLabel.Render("IP method")
methods := []string{"DHCP (automatic)", "Static"}
dhcpIdx := 0
if !m.wanDHCP {
dhcpIdx = 1
}
var methodParts []string
for i, s := range methods {
if i == dhcpIdx && m.wanFocus == wanFieldMethod {
methodParts = append(methodParts, styleSelected.Render(" "+s+" "))
} else if i == dhcpIdx {
methodParts = append(methodParts, styleLabel.Render("["+s+"]"))
} else {
methodParts = append(methodParts, styleMuted.Render(s))
}
}
lines = append(lines, methodLabel, strings.Join(methodParts, " "), "")
// Static fields
if !m.wanDHCP {
lines = append(lines,
styleLabel.Render("IP address"), m.wanIP.View(), "",
styleLabel.Render("Subnet mask"), m.wanMask.View(), "",
styleLabel.Render("Default gateway"), m.wanGateway.View(), "",
styleLabel.Render("DNS nameservers"), m.wanDNS.View(), "",
)
}
// WiFi fields
if len(m.nics) > 0 && m.nics[m.wanNicCursor].IsWiFi {
lines = append(lines,
styleLabel.Render("WiFi SSID"), m.wanSSID.View(), "",
styleLabel.Render("WiFi password"), m.wanWiFiPass.View(), "",
)
}
if m.validationErr != "" {
lines = append(lines, styleError.Render(m.validationErr), "")
}
lines = append(lines, styleHelp.Render("Tab/↑↓ to move • ←/→ to select • Enter to proceed"))
body := lipgloss.JoinVertical(lipgloss.Left, lines...)
return lipgloss.Place(w, m.height, lipgloss.Center, lipgloss.Center,
styleBox.Width(min(w-4, 68)).Render(body),
)
}
func (m model) viewNetworkLAN(w int) string {
title := styleTitle.Render("LAN Network (Step 2 of " + m.networkStepCount() + ")")
subtitle := styleMuted.Render("Internal interface for EC2/VPC and Geneve tunnel traffic (br-lan).")
var lines []string
lines = append(lines, title, subtitle, "")
// NIC picker — WAN NIC shown greyed
nicLabel := styleLabel.Render("LAN network interface")
nicLine := m.renderNICPicker(m.lanNicCursor, m.wanNicCursor, m.lanFocus == lanFieldNIC)
lines = append(lines, nicLabel, nicLine, "")
// IP method toggle
methodLabel := styleLabel.Render("IP method")
methods := []string{"DHCP (automatic)", "Static"}
dhcpIdx := 0
if !m.lanDHCP {
dhcpIdx = 1
}
var methodParts []string
for i, s := range methods {
if i == dhcpIdx && m.lanFocus == lanFieldMethod {
methodParts = append(methodParts, styleSelected.Render(" "+s+" "))
} else if i == dhcpIdx {
methodParts = append(methodParts, styleLabel.Render("["+s+"]"))
} else {
methodParts = append(methodParts, styleMuted.Render(s))
}
}
lines = append(lines, methodLabel, strings.Join(methodParts, " "), "")
// Static fields (no gateway for LAN)
if !m.lanDHCP {
lines = append(lines,
styleLabel.Render("IP address"), m.lanIP.View(), "",
styleLabel.Render("Subnet mask"), m.lanMask.View(), "",
styleLabel.Render("DNS nameservers"), m.lanDNS.View(), "",
)
}
// WiFi fields
if len(m.nics) > m.lanNicCursor && m.nics[m.lanNicCursor].IsWiFi {
lines = append(lines,
styleLabel.Render("WiFi SSID"), m.lanSSID.View(), "",
styleLabel.Render("WiFi password"), m.lanWiFiPass.View(), "",
)
}
if m.validationErr != "" {
lines = append(lines, styleError.Render(m.validationErr), "")
}
lines = append(lines, styleHelp.Render("Tab/↑↓ to move • ←/→ to select • Enter to proceed"))
body := lipgloss.JoinVertical(lipgloss.Left, lines...)
return lipgloss.Place(w, m.height, lipgloss.Center, lipgloss.Center,
styleBox.Width(min(w-4, 68)).Render(body),
)
}
func (m model) networkStepCount() string {
if len(m.nics) > 1 {
return "2"
}
return "1"
}
func (m model) viewIdentity(w int) string {
title := styleTitle.Render("Node Identity")
hostnameLabel := styleLabel.Render("Hostname")
roleLabel := styleLabel.Render("Cluster role")
roles := []string{"Initialize new cluster", "Join existing cluster"}
var roleParts []string
for i, r := range roles {
if i == m.clusterRole && !m.hostnameInput.Focused() {
roleParts = append(roleParts, styleSelected.Render(" "+r+" "))
} else if i == m.clusterRole {
roleParts = append(roleParts, styleLabel.Render("["+r+"]"))
} else {
roleParts = append(roleParts, styleMuted.Render(r))
}
}
var lines []string
lines = append(lines, title, "", hostnameLabel, m.hostnameInput.View(), "", roleLabel, strings.Join(roleParts, " "))
if m.validationErr != "" {
lines = append(lines, "", styleError.Render(m.validationErr))
}
lines = append(lines, styleHelp.Render("Tab to toggle focus • ←/→ to select role • Enter to proceed"))
body := lipgloss.JoinVertical(lipgloss.Left, lines...)
return lipgloss.Place(w, m.height, lipgloss.Center, lipgloss.Center,
styleBox.Width(min(w-4, 64)).Render(body),
)
}
func (m model) viewPassword(w int) string {
title := styleTitle.Render("Administrator Credentials")
emailLabel := styleLabel.Render("Email")
emailHelp := styleHelp.Render("Used to notify of important system updates to Spinifex or security announcements")
passLabel := styleLabel.Render("Password")
confirmLabel := styleLabel.Render("Confirm password")
var lines []string
lines = append(lines,
title, "",
emailLabel, m.emailInput.View(), emailHelp, "",
passLabel, m.passwordInput.View(), "",
confirmLabel, m.passwordConfirmInput.View(),
)
if m.validationErr != "" {
lines = append(lines, "", styleError.Render(m.validationErr))
}
lines = append(lines, "", styleHelp.Render("Tab to move • Enter to proceed • Esc to go back"))
body := lipgloss.JoinVertical(lipgloss.Left, lines...)
return lipgloss.Place(w, m.height, lipgloss.Center, lipgloss.Center,
styleBox.Width(min(w-4, 64)).Render(body),
)
}
func (m model) viewJoinConfig(w int) string {
title := styleTitle.Render("Join Existing Cluster")
ipLabel := styleLabel.Render("Primary node IP")
portLabel := styleLabel.Render("Formation port")
var lines []string
lines = append(lines, title, "", ipLabel, m.joinIPInput.View(), "", portLabel, m.joinPortInput.View())
if m.validationErr != "" {
lines = append(lines, "", styleError.Render(m.validationErr))
}
lines = append(lines, styleHelp.Render("Tab to move • Enter to proceed • Esc to go back"))
body := lipgloss.JoinVertical(lipgloss.Left, lines...)
return lipgloss.Place(w, m.height, lipgloss.Center, lipgloss.Center,
styleBox.Width(min(w-4, 64)).Render(body),
)
}
func (m model) viewConfirm(w int) string {
title := styleTitle.Render("Confirm Installation")
cfg := m.buildConfig()
role := "Initialize new cluster"
if cfg.ClusterRole == "join" {
role = fmt.Sprintf("Join cluster at %s", cfg.JoinAddr)
}
wanNet := "DHCP"
if !cfg.WANDHCPMode {
wanNet = fmt.Sprintf("%s/%s via %s", cfg.WANAddress, cfg.WANMask, cfg.WANGateway)
}
summary := []struct{ k, v string }{
{"Disk", cfg.Disk},
{"WAN interface", fmt.Sprintf("%s → br-wan", cfg.WANInterface)},
{"WAN address", wanNet},
}
if cfg.LANInterface != "" {
lanNet := "DHCP"
if !cfg.LANDHCPMode {
lanNet = fmt.Sprintf("%s/%s", cfg.LANAddress, cfg.LANMask)
}
summary = append(summary,
struct{ k, v string }{"LAN interface", fmt.Sprintf("%s → br-lan", cfg.LANInterface)},
struct{ k, v string }{"LAN address", lanNet},
)
}
summary = append(summary,
struct{ k, v string }{"Hostname", cfg.Hostname},
struct{ k, v string }{"Cluster role", role},
)
if cfg.HasCACert {
summary = append(summary, struct{ k, v string }{"CA certificate", "provided"})
}
var rows []string
for _, s := range summary {
rows = append(rows, fmt.Sprintf(" %s%-20s%s %s",
styleLabel.Render(""), styleLabel.Render(s.k), "", s.v))
}
warning := styleWarning.Render("This will erase " + cfg.Disk + " and begin installation.")
body := lipgloss.JoinVertical(lipgloss.Left,
title, "",
strings.Join(rows, "\n"), "",
warning, "",
styleHelp.Render("Enter/Y to install • N to cancel • Esc to go back"),
)
return lipgloss.Place(w, m.height, lipgloss.Center, lipgloss.Center,
styleBox.Width(min(w-4, 72)).Render(body),
)
}
func (m model) viewDone(w int) string {
body := lipgloss.JoinVertical(lipgloss.Center,
styleSuccess.Render("Installation complete."),
"",
styleMuted.Render("The system will reboot shortly."),
)
return lipgloss.Place(w, m.height, lipgloss.Center, lipgloss.Center,
styleBox.Width(min(w-4, 48)).Render(body),
)
}
// ── Helpers ───────────────────────────────────────────────────────────────────
func (m model) buildConfig() *install.Config {
cfg := &install.Config{}
if len(m.disks) > m.diskCursor {
cfg.Disk = m.disks[m.diskCursor].Path
}
// WAN
if len(m.nics) > m.wanNicCursor {
cfg.WANInterface = m.nics[m.wanNicCursor].Name
if m.nics[m.wanNicCursor].IsWiFi {
cfg.WANWiFiSSID = strings.TrimSpace(m.wanSSID.Value())
cfg.WANWiFiPass = m.wanWiFiPass.Value()
}
} else {
cfg.WANInterface = strings.TrimSpace(m.wanNicManualInput.Value())
}
cfg.WANDHCPMode = m.wanDHCP
if !m.wanDHCP {
cfg.WANAddress = strings.TrimSpace(m.wanIP.Value())
cfg.WANMask = strings.TrimSpace(m.wanMask.Value())
cfg.WANGateway = strings.TrimSpace(m.wanGateway.Value())
cfg.WANDNS = parseDNS(m.wanDNS.Value())
}
// LAN (only when 2+ NICs)
if len(m.nics) > 1 && m.lanNicCursor < len(m.nics) {
cfg.LANInterface = m.nics[m.lanNicCursor].Name
cfg.LANDHCPMode = m.lanDHCP
if !m.lanDHCP {
cfg.LANAddress = strings.TrimSpace(m.lanIP.Value())
cfg.LANMask = strings.TrimSpace(m.lanMask.Value())
cfg.LANDNS = parseDNS(m.lanDNS.Value())
}
if m.nics[m.lanNicCursor].IsWiFi {
cfg.LANWiFiSSID = strings.TrimSpace(m.lanSSID.Value())
cfg.LANWiFiPass = m.lanWiFiPass.Value()
}
}
cfg.Hostname = strings.TrimSpace(m.hostnameInput.Value())
if m.clusterRole == 0 {
cfg.ClusterRole = "init"
} else {
cfg.ClusterRole = "join"
port := strings.TrimSpace(m.joinPortInput.Value())
if port == "" {
port = "4432"
}
cfg.JoinAddr = net.JoinHostPort(strings.TrimSpace(m.joinIPInput.Value()), port)
}
cfg.RootPassword = m.passwordInput.Value()
cfg.Email = strings.TrimSpace(m.emailInput.Value())
return cfg
}
// parseDNS splits a comma-separated DNS string into individual nameserver entries.
func parseDNS(raw string) []string {
var out []string
for s := range strings.SplitSeq(raw, ",") {
s = strings.TrimSpace(s)
if s != "" {
out = append(out, s)
}
}
return out
}
// diskInfo holds display info for a block device.
type diskInfo struct {
Path string
Size string
Model string
}
func availableDisks() ([]diskInfo, error) {
entries, err := os.ReadDir("/sys/block")
if err != nil {
return nil, err
}
var disks []diskInfo
for _, e := range entries {
name := e.Name()
if strings.HasPrefix(name, "loop") || strings.HasPrefix(name, "ram") {
continue
}
d := diskInfo{Path: "/dev/" + name}
d.Size = readSysBlockFile(name, "size")
if d.Size != "" {
d.Size = formatSectors(d.Size)
}
d.Model = strings.TrimSpace(readSysBlockFile(name, "device/model"))
disks = append(disks, d)
}
return disks, nil
}
func readSysBlockFile(dev, file string) string {
data, err := os.ReadFile("/sys/block/" + dev + "/" + file)
if err != nil {
return ""
}
return strings.TrimSpace(string(data))
}
func formatSectors(sectors string) string {
var n int64
if _, err := fmt.Sscan(sectors, &n); err != nil {
return ""
}
bytes := n * 512
switch {
case bytes >= 1<<40:
return fmt.Sprintf("%.1fT", float64(bytes)/(1<<40))
case bytes >= 1<<30:
return fmt.Sprintf("%.1fG", float64(bytes)/(1<<30))
case bytes >= 1<<20:
return fmt.Sprintf("%.1fM", float64(bytes)/(1<<20))
default:
return fmt.Sprintf("%dB", bytes)
}
}
// validSubnetMask accepts dotted-decimal (255.255.255.0) or CIDR prefix (/24 or 24).
func validSubnetMask(s string) bool {
s = strings.TrimSpace(s)
s = strings.TrimPrefix(s, "/")
var prefix int
if _, err := fmt.Sscan(s, &prefix); err == nil && len(s) <= 2 {
return prefix >= 0 && prefix <= 32
}
return net.ParseIP(s) != nil
}
func availableNICs() ([]nicInfo, error) {
ifaces, err := net.Interfaces()
if err != nil {
return nil, err
}
var nics []nicInfo
for _, iface := range ifaces {
if iface.Flags&net.FlagLoopback != 0 {
continue
}
nics = append(nics, nicInfo{
Name: iface.Name,
IsWiFi: isWiFiNIC(iface.Name),
})
}
return nics, nil
}
// isWiFiNIC returns true if the interface has a wireless subdirectory in sysfs.
func isWiFiNIC(name string) bool {
_, err := os.Stat("/sys/class/net/" + name + "/wireless")
return err == nil
}