Files
rollcall/cmd/rollcall/discover.go
T
Ilker ManapandClaude Opus 5.5 7a007ef048
CI / agent (push) Successful in 3s
CI / site (push) Successful in 3s
CI / go (push) Successful in 36s
Proxy DHCP for networks whose DHCP server stays in charge
A VLAN in proxy mode leaves addresses to the site's DHCP server; the rack
only answers PXE (dnsmasq proxy range + pxe-service), machines keep DHCP
unless a static range is set aside. CLI -proxy-dhcp, web UI mode select,
bootstrap --proxy-dhcp, doctor/discover aware.

dnsmasq 2.91 (and 2.93) loops forever in its proxy path when the proxy
range carries a set: tag: option_filter appends a request's tags to the
context's persistent list, so the next request's stack interface tag
points at itself (found with gdb on the lab rack). Proxy ranges are
untagged and PXE services limited by interface; dhcp-boot/dhcp-match only
for served subnets; no dhcp-host lines for proxied ones.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 09:04:10 +01:00

325 lines
8.9 KiB
Go

package main
// rollcall net discover: what this host sees on each interface, for the day
// when the provisioning network is not decided yet. Addresses (and the subnet
// they imply), the switch port and VLAN from LLDP (lldpd), DHCP servers that
// answer, neighbours; then a suggestion of what to do with each interface.
import (
"encoding/json"
"flag"
"fmt"
"net"
"os"
"os/exec"
"path/filepath"
"sort"
"strconv"
"strings"
"time"
)
type discoIface struct {
Name string `json:"name"`
Kind string `json:"kind"` // physical | vlan | bond | bridge | virtual
MAC string `json:"mac"`
Up bool `json:"up"`
SpeedMbps int `json:"speed_mbps,omitempty"`
Addrs []string `json:"addrs"`
Subnets []string `json:"subnets"`
Default bool `json:"default_route"`
Switch string `json:"switch,omitempty"`
Port string `json:"port,omitempty"`
VLAN string `json:"vlan,omitempty"`
DHCP []string `json:"dhcp_servers"`
DHCPProbe string `json:"dhcp_probe,omitempty"` // why it was not probed
Neighbours int `json:"neighbours"`
Suggestion string `json:"suggestion"`
}
func discoverCmd(args []string) error {
fs := flag.NewFlagSet("net discover", flag.ExitOnError)
asJSON := fs.Bool("json", false, "JSON output")
noProbe := fs.Bool("no-dhcp-probe", false, "do not send DHCPDISCOVERs")
_ = fs.Parse(args)
ifs := discoverInterfaces(!*noProbe)
if *asJSON {
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
return enc.Encode(ifs)
}
fmt.Printf("%-12s %-8s %-5s %6s %-22s %-28s %-14s %s\n", "INTERFACE", "KIND", "LINK", "SPEED", "ADDRESSES", "SWITCH / PORT / VLAN", "DHCP SERVERS", "NEIGH")
for _, i := range ifs {
link := "down"
if i.Up {
link = "up"
}
speed := "-"
if i.SpeedMbps > 0 {
speed = fmt.Sprintf("%dG", i.SpeedMbps/1000)
if i.SpeedMbps < 1000 {
speed = fmt.Sprintf("%dM", i.SpeedMbps)
}
}
sw := "-"
if i.Switch != "" || i.Port != "" {
sw = strings.Trim(i.Switch+" / "+i.Port+" / "+orDash(i.VLAN), " /")
}
dh := strings.Join(i.DHCP, ",")
if dh == "" {
dh = orDash(i.DHCPProbe)
}
name := i.Name
if i.Default {
name += "*"
}
fmt.Printf("%-12s %-8s %-5s %6s %-22s %-28s %-14s %d\n", name, i.Kind, link, speed, trunc(strings.Join(i.Addrs, " "), 22), trunc(sw, 28), trunc(dh, 14), i.Neighbours)
}
fmt.Println("(* carries the default route)")
fmt.Println()
for _, i := range ifs {
if i.Suggestion != "" {
fmt.Printf("%-12s %s\n", i.Name, i.Suggestion)
}
}
return nil
}
func discoverInterfaces(probe bool) []discoIface {
def := defaultRouteIface()
lldp := lldpNeighbours()
nets, _ := net.Interfaces()
var out []discoIface
type probeRes struct {
servers []string
err error
}
probes := map[string]chan probeRes{}
if probe { // in parallel: a DHCP server may take seconds to offer (it pings the address first)
for _, n := range nets {
if n.Flags&net.FlagLoopback != 0 || n.Flags&net.FlagUp == 0 || skipIface(n.Name) || ifaceKind(n.Name) == "bridge" || ifaceKind(n.Name) == "virtual" {
continue
}
ch := make(chan probeRes, 1)
probes[n.Name] = ch
go func(name string) { s, err := dhcpProbe(name, 6*time.Second); ch <- probeRes{s, err} }(n.Name)
}
}
for _, n := range nets {
if n.Flags&net.FlagLoopback != 0 || skipIface(n.Name) {
continue
}
d := discoIface{Name: n.Name, MAC: n.HardwareAddr.String(), Kind: ifaceKind(n.Name), Default: n.Name == def, Addrs: []string{}, Subnets: []string{}, DHCP: []string{}}
if b, err := os.ReadFile("/sys/class/net/" + n.Name + "/operstate"); err == nil {
d.Up = strings.TrimSpace(string(b)) == "up"
}
if b, err := os.ReadFile("/sys/class/net/" + n.Name + "/speed"); err == nil {
if v, err := strconv.Atoi(strings.TrimSpace(string(b))); err == nil && v > 0 {
d.SpeedMbps = v
}
}
addrs, _ := n.Addrs()
for _, a := range addrs {
ipn, ok := a.(*net.IPNet)
if !ok || ipn.IP.IsLinkLocalUnicast() {
continue
}
ones, _ := ipn.Mask.Size()
d.Addrs = append(d.Addrs, fmt.Sprintf("%s/%d", ipn.IP, ones))
d.Subnets = append(d.Subnets, (&net.IPNet{IP: ipn.IP.Mask(ipn.Mask), Mask: ipn.Mask}).String())
}
if l, ok := lldp[n.Name]; ok {
d.Switch, d.Port, d.VLAN = l[0], l[1], l[2]
}
d.Neighbours = neighbourCount(n.Name)
switch {
case !probe:
d.DHCPProbe = "not probed"
case !d.Up:
d.DHCPProbe = "link down"
case d.Kind == "bridge" || d.Kind == "virtual":
d.DHCPProbe = "skipped"
case probes[n.Name] == nil:
d.DHCPProbe = "skipped"
default:
r := <-probes[n.Name]
if s, err := r.servers, r.err; err != nil {
d.DHCPProbe = "needs root"
} else {
for _, x := range s {
if ifaceWithAddr(x) == "" {
d.DHCP = append(d.DHCP, x)
} else {
d.DHCP = append(d.DHCP, x+"(this host)")
}
}
if len(s) == 0 {
d.DHCPProbe = "none"
}
}
}
d.Suggestion = discoSuggest(d)
out = append(out, d)
}
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
return out
}
func discoSuggest(d discoIface) string {
foreign := 0
for _, s := range d.DHCP {
if !strings.HasSuffix(s, "(this host)") {
foreign++
}
}
v4 := ""
for _, s := range d.Subnets {
if !strings.Contains(s, ":") {
v4 = s
break
}
}
vlan := ""
if d.VLAN != "" {
vlan = " (switch says VLAN " + d.VLAN + ")"
}
switch {
case d.Kind == "bridge" || d.Kind == "virtual":
return ""
case !d.Up:
return "no link: cable it to the provisioning network" + vlan
case d.Default && foreign > 0:
return "uplink: management network with its own DHCP (" + strings.Join(d.DHCP, ",") + "); keep it for the region's access" + vlan
case d.Default:
return "uplink: the region is reached here" + vlan
case v4 == "":
return "link up, no IPv4 address" + vlan + ": give it its address on the provisioning network, then bootstrap.sh --iface " + d.Name
case foreign > 0:
return v4 + " already has DHCP (" + strings.Join(d.DHCP, ",") + "): keep it and answer only PXE from here - bootstrap.sh --iface " + d.Name + " --proxy-dhcp - or the network team turns it off for this VLAN"
default:
return "candidate provisioning network " + v4 + vlan + ": bootstrap.sh --iface " + d.Name + " (or Network -> New subnet in the web UI)"
}
}
func skipIface(name string) bool {
for _, p := range []string{"docker", "veth", "virbr", "br-", "cni", "flannel", "tap", "vnet", "wg", "tun"} {
if strings.HasPrefix(name, p) {
return true
}
}
return false
}
func ifaceKind(name string) string {
base := "/sys/class/net/" + name
switch {
case exists(base + "/bridge"):
return "bridge"
case exists(base + "/bonding"):
return "bond"
case exists("/proc/net/vlan/" + name):
return "vlan"
case exists(base + "/device"):
return "physical"
}
return "virtual"
}
func exists(p string) bool { _, err := os.Stat(p); return err == nil }
func defaultRouteIface() string {
b, err := os.ReadFile("/proc/net/route")
if err != nil {
return ""
}
for _, l := range strings.Split(string(b), "\n")[1:] {
f := strings.Fields(l)
if len(f) > 2 && f[1] == "00000000" && f[7] == "00000000" {
return f[0]
}
}
return ""
}
func neighbourCount(iface string) int {
out, err := exec.Command("ip", "-j", "neigh", "show", "dev", iface).Output()
if err != nil {
return 0
}
var ns []map[string]any
_ = json.Unmarshal(out, &ns)
n := 0
for _, x := range ns {
if st, _ := x["state"].([]any); len(st) > 0 && st[0] != "FAILED" && st[0] != "INCOMPLETE" {
n++
}
}
return n
}
// lldpNeighbours: interface -> [switch name, port, vlan] from lldpd.
func lldpNeighbours() map[string][3]string {
out := map[string][3]string{}
bin, err := exec.LookPath("lldpcli")
if err != nil {
return out
}
b, err := exec.Command(bin, "-f", "json0", "show", "neighbors", "details").Output()
if err != nil {
return out
}
var doc struct {
LLDP []struct {
Interface []struct {
Name string `json:"name"`
Chassis []struct {
Name []struct {
Value string `json:"value"`
} `json:"name"`
} `json:"chassis"`
Port []struct {
ID []struct {
Value string `json:"value"`
} `json:"id"`
Descr []struct {
Value string `json:"value"`
} `json:"descr"`
} `json:"port"`
VLAN []struct {
VlanID string `json:"vlan-id"`
PVID bool `json:"pvid"`
} `json:"vlan"`
} `json:"interface"`
} `json:"lldp"`
}
if json.Unmarshal(b, &doc) != nil {
return out
}
for _, l := range doc.LLDP {
for _, i := range l.Interface {
var sw, port, vlan string
if len(i.Chassis) > 0 && len(i.Chassis[0].Name) > 0 {
sw = i.Chassis[0].Name[0].Value
}
if len(i.Port) > 0 {
if len(i.Port[0].Descr) > 0 {
port = i.Port[0].Descr[0].Value
} else if len(i.Port[0].ID) > 0 {
port = i.Port[0].ID[0].Value
}
}
var vs []string
for _, v := range i.VLAN {
s := v.VlanID
if v.PVID {
s += "(untagged)"
}
vs = append(vs, s)
}
vlan = strings.Join(vs, ",")
out[filepath.Base(i.Name)] = [3]string{sw, port, vlan}
}
}
return out
}