mirror of
https://github.com/mulgadc/spinifex.git
synced 2026-10-20 03:11:46 +00:00
427 lines
12 KiB
Go
427 lines
12 KiB
Go
// Package netprobe identifies physical network interfaces by hardware rather
|
|
// than kernel name, so an operator choosing between eno1, ens1f0np0 and
|
|
// ens1f1np1 can tell the 1gbe Broadcom from the 25gbe Mellanox.
|
|
package netprobe
|
|
|
|
import (
|
|
"cmp"
|
|
"fmt"
|
|
"log/slog"
|
|
"net"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"slices"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// sysClassNet is the kernel's per-interface attribute tree. A variable, not a
|
|
// constant, so tests can point the probe at a fixture instead of the running
|
|
// machine's hardware.
|
|
var sysClassNet = "/sys/class/net"
|
|
|
|
// The external helpers the probe leans on, indirected so tests can exercise it
|
|
// without the host's real tooling.
|
|
var (
|
|
listInterfaces = net.Interfaces
|
|
commandOutput = func(name string, args ...string) ([]byte, error) {
|
|
return exec.Command(name, args...).Output()
|
|
}
|
|
)
|
|
|
|
// NIC describes one physical interface as presented in the installer's
|
|
// selection table.
|
|
type NIC struct {
|
|
Name string
|
|
|
|
// Vendor and Model come from the udev hardware database, e.g. "Mellanox
|
|
// Technologies" and "MT27710 Family [ConnectX-4 Lx]". Empty when the
|
|
// database carries no entry for the card.
|
|
Vendor string
|
|
Model string
|
|
|
|
// Driver is the kernel module that claimed the port, e.g. "mlx5_core". It
|
|
// comes straight from sysfs, so it still identifies a card the hardware
|
|
// database misses.
|
|
Driver string
|
|
|
|
// DeviceID is the PCI or USB "vendor:device" pair, the last-resort identity
|
|
// for hardware neither the database nor a driver name pins down.
|
|
DeviceID string
|
|
|
|
// AltName is the first alternative name systemd assigns, which is what
|
|
// operators often see in switch and cabling documentation.
|
|
AltName string
|
|
|
|
// Speed is the negotiated link speed rendered for display ("25 Gbps") and
|
|
// SpeedMbps the raw figure behind it. Both are zero on a port with no
|
|
// carrier, which is why Probe brings links up before reading.
|
|
Speed string
|
|
SpeedMbps int
|
|
|
|
// Carrier reports a cable with link. It orders the picker so the port the
|
|
// operator has just plugged in is the one offered first.
|
|
Carrier bool
|
|
|
|
// State is the link summary shown in the table: "online", "no cable" or
|
|
// "down".
|
|
State string
|
|
|
|
MTU int
|
|
IsWiFi bool
|
|
}
|
|
|
|
// Label renders the NIC's identity line: kernel name, negotiated speed and link
|
|
// state. The hardware detail is deliberately absent — it goes on its own line
|
|
// via Hardware, where there is room to read it.
|
|
func (n NIC) Label() string {
|
|
speed := n.Speed
|
|
if speed == "" {
|
|
speed = "—"
|
|
}
|
|
state := n.State
|
|
if state == "" {
|
|
state = "unknown"
|
|
}
|
|
return fmt.Sprintf("%s %s %s", n.Name, speed, state)
|
|
}
|
|
|
|
// Hardware renders the card's identity for the detail line, degrading from the
|
|
// database name to the driver and device id rather than to "unknown". An
|
|
// operator needs something to match against the card in their hand.
|
|
func (n NIC) Hardware() string {
|
|
if hw := strings.TrimSpace(n.Vendor + " " + n.Model); hw != "" {
|
|
return hw
|
|
}
|
|
switch {
|
|
case n.Driver != "" && n.DeviceID != "":
|
|
return n.Driver + " [" + n.DeviceID + "]"
|
|
case n.Driver != "":
|
|
return n.Driver
|
|
case n.DeviceID != "":
|
|
return n.DeviceID
|
|
}
|
|
return "no hardware detail"
|
|
}
|
|
|
|
// linkUpTimeout bounds how long Probe waits after bringing links up for speed
|
|
// and carrier to settle. A 25gbe port negotiating against a cold switch can
|
|
// take a second or two to report a speed.
|
|
var linkUpTimeout = 2 * time.Second
|
|
|
|
// Probe enumerates non-loopback physical interfaces and enriches each from
|
|
// sysfs and the udev hardware database, ordered so the ports worth assigning
|
|
// come first. Links are brought up first: a down port reports no speed and no
|
|
// carrier, which would leave every fast port unidentified at exactly the moment
|
|
// the operator needs to pick one.
|
|
//
|
|
// Nothing the probe depends on needs a running systemd. The live installer
|
|
// boots straight into spinifex-init with only udevd started, so networkctl —
|
|
// which has to reach systemd-networkd — enriches gaps and is never the source.
|
|
func Probe() ([]NIC, error) {
|
|
ifaces, err := listInterfaces()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var names, physical []string
|
|
for _, iface := range ifaces {
|
|
if iface.Flags&net.FlagLoopback != 0 {
|
|
continue
|
|
}
|
|
if len(iface.HardwareAddr) == 0 {
|
|
continue
|
|
}
|
|
names = append(names, iface.Name)
|
|
if hasDevice(iface.Name) {
|
|
physical = append(physical, iface.Name)
|
|
}
|
|
}
|
|
|
|
// Bridges, bonds and veth pairs carry a MAC like any port but have no
|
|
// device behind them, and offering them would let a plane be bound to
|
|
// something that does not exist until after the install. Keep them only if
|
|
// the filter found nothing, so an unusual platform still gets a list.
|
|
if len(physical) > 0 {
|
|
names = physical
|
|
}
|
|
|
|
brought := false
|
|
for _, name := range names {
|
|
if _, err := commandOutput("ip", "link", "set", "dev", name, "up"); err != nil {
|
|
slog.Debug("netprobe: could not bring link up", "iface", name, "err", err)
|
|
continue
|
|
}
|
|
brought = true
|
|
}
|
|
if brought {
|
|
time.Sleep(linkUpTimeout)
|
|
}
|
|
|
|
nics := make([]NIC, 0, len(names))
|
|
for _, name := range names {
|
|
nics = append(nics, describe(name))
|
|
}
|
|
slices.SortFunc(nics, byUsefulness)
|
|
return nics, nil
|
|
}
|
|
|
|
// describe reads everything about one interface that needs no daemon, then lets
|
|
// networkctl fill the gaps if it happens to be answering.
|
|
func describe(name string) NIC {
|
|
nic := NIC{
|
|
Name: name,
|
|
Driver: sysLinkBase(name, "device/driver"),
|
|
DeviceID: deviceID(name),
|
|
MTU: sysInt(name, "mtu"),
|
|
IsWiFi: isWiFi(name),
|
|
}
|
|
|
|
// The kernel reports -1 for a port that cannot state its speed; treat that
|
|
// as unknown rather than letting it sort below every real figure.
|
|
nic.SpeedMbps = max(sysInt(name, "speed"), 0)
|
|
nic.Speed = formatSpeed(nic.SpeedMbps)
|
|
nic.Carrier = sysAttr(name, "carrier") == "1"
|
|
nic.State = linkState(name, nic.Carrier)
|
|
|
|
props := udevProperties(name)
|
|
nic.Vendor = props["ID_VENDOR_FROM_DATABASE"]
|
|
nic.Model = props["ID_MODEL_FROM_DATABASE"]
|
|
|
|
// udev's predictable names are the ones that appear in cabling notes; the
|
|
// path-based name is the one networkctl lists first.
|
|
for _, key := range []string{"ID_NET_NAME_PATH", "ID_NET_NAME_ONBOARD", "ID_NET_NAME_SLOT"} {
|
|
if alt := props[key]; alt != "" && alt != name {
|
|
nic.AltName = alt
|
|
break
|
|
}
|
|
}
|
|
|
|
if out, err := commandOutput("networkctl", "status", "--no-pager", name); err == nil {
|
|
fill(&nic, ParseStatus(string(out)))
|
|
} else {
|
|
slog.Debug("netprobe: networkctl unavailable, using sysfs only", "iface", name, "err", err)
|
|
}
|
|
return nic
|
|
}
|
|
|
|
// fill copies parsed hardware detail onto a NIC without overwriting anything
|
|
// sysfs already established. State is never taken: the carrier bit is a truer
|
|
// answer to "is this cable live" than networkd's configuration status.
|
|
func fill(dst *NIC, src NIC) {
|
|
if dst.Vendor == "" {
|
|
dst.Vendor = src.Vendor
|
|
}
|
|
if dst.Model == "" {
|
|
dst.Model = src.Model
|
|
}
|
|
if dst.AltName == "" {
|
|
dst.AltName = src.AltName
|
|
}
|
|
if dst.Speed == "" {
|
|
dst.Speed = src.Speed
|
|
}
|
|
if dst.MTU == 0 {
|
|
dst.MTU = src.MTU
|
|
}
|
|
}
|
|
|
|
// byUsefulness orders the picker so the interfaces an operator would actually
|
|
// assign come first: a live cable, then wired over wireless, then the fastest
|
|
// port. The rest still appear, just below the plausible choices.
|
|
func byUsefulness(a, b NIC) int {
|
|
if a.Carrier != b.Carrier {
|
|
return boolOrder(a.Carrier)
|
|
}
|
|
if a.IsWiFi != b.IsWiFi {
|
|
return boolOrder(b.IsWiFi)
|
|
}
|
|
if a.SpeedMbps != b.SpeedMbps {
|
|
return cmp.Compare(b.SpeedMbps, a.SpeedMbps)
|
|
}
|
|
return cmp.Compare(a.Name, b.Name)
|
|
}
|
|
|
|
// boolOrder sorts true ahead of false.
|
|
func boolOrder(first bool) int {
|
|
if first {
|
|
return -1
|
|
}
|
|
return 1
|
|
}
|
|
|
|
// formatSpeed renders the kernel's Mbit/s figure the way a datasheet does.
|
|
func formatSpeed(mbps int) string {
|
|
switch {
|
|
case mbps <= 0:
|
|
return ""
|
|
case mbps%1000 == 0:
|
|
return fmt.Sprintf("%d Gbps", mbps/1000)
|
|
default:
|
|
return fmt.Sprintf("%d Mbps", mbps)
|
|
}
|
|
}
|
|
|
|
// linkState collapses the kernel's flags into the three outcomes an operator
|
|
// acts on: the port is live, the port is enabled but unplugged, or the port
|
|
// never came up at all. operstate reports "down" for the latter two alike.
|
|
func linkState(name string, carrier bool) string {
|
|
switch {
|
|
case carrier:
|
|
return "online"
|
|
case adminUp(name):
|
|
return "no cable"
|
|
default:
|
|
return "down"
|
|
}
|
|
}
|
|
|
|
// adminUp reads the IFF_UP bit out of the interface flags.
|
|
func adminUp(name string) bool {
|
|
flags, err := strconv.ParseUint(strings.TrimPrefix(sysAttr(name, "flags"), "0x"), 16, 64)
|
|
return err == nil && flags&0x1 != 0
|
|
}
|
|
|
|
// deviceID reads the PCI or USB "vendor:device" pair. A NIC's device link
|
|
// points at the PCI function directly; on USB it points at the interface, whose
|
|
// parent carries the ids.
|
|
func deviceID(name string) string {
|
|
dev := filepath.Join(sysClassNet, name, "device")
|
|
if id := idPair(dev, "vendor", "device"); id != "" {
|
|
return id
|
|
}
|
|
return idPair(filepath.Join(dev, ".."), "idVendor", "idProduct")
|
|
}
|
|
|
|
func idPair(dir, vendorAttr, productAttr string) string {
|
|
vendor := readTrimmed(filepath.Join(dir, vendorAttr))
|
|
product := readTrimmed(filepath.Join(dir, productAttr))
|
|
if vendor == "" || product == "" {
|
|
return ""
|
|
}
|
|
return strings.TrimPrefix(vendor, "0x") + ":" + strings.TrimPrefix(product, "0x")
|
|
}
|
|
|
|
// udevProperties reads the interface's udev database entry, which is where the
|
|
// hardware-database vendor and model names live. udevd populates it during the
|
|
// installer's device enumeration, so this works with no systemd running.
|
|
func udevProperties(name string) map[string]string {
|
|
out, err := commandOutput("udevadm", "info", "--query=property", "--path="+filepath.Join(sysClassNet, name))
|
|
if err != nil {
|
|
slog.Debug("netprobe: udevadm info failed", "iface", name, "err", err)
|
|
return nil
|
|
}
|
|
|
|
props := make(map[string]string)
|
|
for line := range strings.SplitSeq(string(out), "\n") {
|
|
if key, value, ok := strings.Cut(strings.TrimSpace(line), "="); ok {
|
|
props[key] = value
|
|
}
|
|
}
|
|
return props
|
|
}
|
|
|
|
// ParseStatus extracts the hardware fields from `networkctl status <iface>`
|
|
// output. The format is a set of right-aligned "Key: value" lines, with
|
|
// Alternative Names continuing across subsequent indented lines.
|
|
func ParseStatus(out string) NIC {
|
|
var nic NIC
|
|
var inAltNames bool
|
|
|
|
for line := range strings.SplitSeq(out, "\n") {
|
|
trimmed := strings.TrimSpace(line)
|
|
if trimmed == "" {
|
|
inAltNames = false
|
|
continue
|
|
}
|
|
|
|
key, value, found := strings.Cut(trimmed, ": ")
|
|
if !found {
|
|
// A continuation line under "Alternative Names:" — take the first
|
|
// only, which is the one operators see in cabling notes.
|
|
if inAltNames && nic.AltName == "" {
|
|
nic.AltName = trimmed
|
|
}
|
|
continue
|
|
}
|
|
inAltNames = false
|
|
|
|
key = strings.TrimSpace(key)
|
|
value = strings.TrimSpace(value)
|
|
switch key {
|
|
case "Vendor":
|
|
nic.Vendor = value
|
|
case "Model":
|
|
nic.Model = value
|
|
case "Speed":
|
|
nic.Speed = value
|
|
case "Online state":
|
|
// Rendered as "online" or "online (configured)".
|
|
if f := strings.Fields(value); len(f) > 0 {
|
|
nic.State = f[0]
|
|
}
|
|
case "MTU":
|
|
// Rendered as "9000 (min: 68, max: 9978)".
|
|
if f := strings.Fields(value); len(f) > 0 {
|
|
if mtu, err := strconv.Atoi(f[0]); err == nil {
|
|
nic.MTU = mtu
|
|
}
|
|
}
|
|
case "Alternative Names":
|
|
inAltNames = true
|
|
if f := strings.Fields(value); len(f) > 0 && nic.AltName == "" {
|
|
nic.AltName = f[0]
|
|
}
|
|
}
|
|
}
|
|
return nic
|
|
}
|
|
|
|
// hasDevice reports whether the interface is backed by real hardware, which
|
|
// sysfs shows as a device link to the PCI, USB or platform node behind it.
|
|
func hasDevice(name string) bool {
|
|
_, err := os.Stat(filepath.Join(sysClassNet, name, "device"))
|
|
return err == nil
|
|
}
|
|
|
|
// isWiFi reports whether the interface has a wireless subdirectory in sysfs.
|
|
func isWiFi(name string) bool {
|
|
_, err := os.Stat(filepath.Join(sysClassNet, name, "wireless"))
|
|
return err == nil
|
|
}
|
|
|
|
// sysAttr reads one sysfs attribute. A missing or unreadable attribute is not
|
|
// an error worth surfacing: speed and carrier both fail on a port with no link,
|
|
// which the state column already conveys.
|
|
func sysAttr(name, attr string) string {
|
|
return readTrimmed(filepath.Join(sysClassNet, name, attr))
|
|
}
|
|
|
|
func sysInt(name, attr string) int {
|
|
n, err := strconv.Atoi(sysAttr(name, attr))
|
|
if err != nil {
|
|
return 0
|
|
}
|
|
return n
|
|
}
|
|
|
|
// sysLinkBase resolves a sysfs symlink and returns its final element, which is
|
|
// how the driver name is exposed.
|
|
func sysLinkBase(name, attr string) string {
|
|
target, err := os.Readlink(filepath.Join(sysClassNet, name, attr))
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
return filepath.Base(target)
|
|
}
|
|
|
|
func readTrimmed(path string) string {
|
|
b, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
return strings.TrimSpace(string(b))
|
|
}
|