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>
325 lines
8.9 KiB
Go
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
|
|
}
|