Files
nmon/internal/hostaddr/mac.go
Vibe Coder c4d14a67d1 Add the optional IEEE vendor registry for MAC addresses and the l2/oui settings
The owner asked to see the vendor of each MAC where the IEEE registry can be
fetched, and never to depend on it. internal/oui downloads MA-L, MA-M and MA-S
weekly through the outbound allow-list (timeouts, a size cap and a row-count
sanity check, so a truncated or wrong download keeps the previous file), stores
one checksummed, atomically written file in <data-dir>/oui that nmon backup
includes, and looks MACs up by the longest prefix. A missing registry only
leaves the vendor empty. `nmon oui import DIR|FILE` loads the CSVs offline, and
the Ansible role can ship them next to the MIB sidecar.

internal/hostaddr parses the MAC, prefix, IP and CIDR forms the lookup accepts
and classifies a MAC as global, local or multicast. config gains the l2 and oui
sections (validated here; the L2 job that reads l2 follows).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T4PdJUPrakM8fHvN8jYv2x
2026-10-24 01:30:11 +00:00

147 lines
4.1 KiB
Go

// Package hostaddr parses and normalises the link-layer and network addresses that the MAC/ARP/FDB lookup
// handles: MAC addresses in every common notation, partial MAC prefixes, IP addresses and CIDRs. It sits below
// the L2 job, the store queries and the API so each gets the same canonical text.
package hostaddr
import (
"errors"
"fmt"
"strings"
)
// MAC kinds, computed from the first octet. They are never stored.
const (
KindGlobal = "global" // universally administered unicast: the vendor prefix is meaningful
KindLocal = "local" // locally administered (randomised phone and VM MACs): no vendor
KindMulticast = "multicast" // group address
)
// ErrBadMAC is returned for text that is not a MAC address or a usable prefix of one.
var ErrBadMAC = errors.New("not a MAC address")
// MinPrefixDigits is the shortest MAC prefix a search accepts: the 24-bit vendor prefix.
const MinPrefixDigits = 6
// hexOnly strips the separators of the common MAC notations and returns the lower-case hex digits. It
// accepts aa:bb:cc:dd:ee:ff, aa-bb-cc-dd-ee-ff, aabb.ccdd.eeff and aabbccddeeff, and partial forms of each.
func hexOnly(s string) (string, error) {
s = strings.ToLower(strings.TrimSpace(s))
if s == "" {
return "", ErrBadMAC
}
var b strings.Builder
for _, c := range s {
switch {
case c >= '0' && c <= '9', c >= 'a' && c <= 'f':
b.WriteRune(c)
case c == ':' || c == '-' || c == '.':
default:
return "", fmt.Errorf("%w: %q", ErrBadMAC, s)
}
}
return b.String(), nil
}
// colonize inserts a colon after every two digits.
func colonize(h string) string {
var b strings.Builder
for i := 0; i < len(h); i += 2 {
if i > 0 {
b.WriteByte(':')
}
b.WriteString(h[i:min(i+2, len(h))])
}
return b.String()
}
// ParseMAC returns the canonical lower-case colon form of a complete MAC address.
func ParseMAC(s string) (string, error) {
h, err := hexOnly(s)
if err != nil {
return "", err
}
if len(h) != 12 {
return "", fmt.Errorf("%w: %q has %d hex digits, want 12", ErrBadMAC, s, len(h))
}
return colonize(h), nil
}
// ParsePrefix returns the canonical colon form of a partial MAC address of at least MinPrefixDigits hex
// digits (an odd digit count is allowed: "aa:bb:cc:d"). A complete address is also a valid prefix.
func ParsePrefix(s string) (string, error) {
h, err := hexOnly(s)
if err != nil {
return "", err
}
if len(h) < MinPrefixDigits || len(h) > 12 {
return "", fmt.Errorf("%w: %q needs 6 to 12 hex digits", ErrBadMAC, s)
}
return colonize(h), nil
}
// FromBytes renders a 6-byte hardware address; ok is false for any other length and for the all-zero address
// (agents use it for "unknown" in ARP and FDB rows).
func FromBytes(b []byte) (string, bool) {
if len(b) != 6 {
return "", false
}
const digits = "0123456789abcdef"
zero := true
var sb strings.Builder
for i, c := range b {
if c != 0 {
zero = false
}
if i > 0 {
sb.WriteByte(':')
}
sb.WriteByte(digits[c>>4])
sb.WriteByte(digits[c&0xf])
}
return sb.String(), !zero
}
// Kind classifies a canonical MAC: multicast when the group bit of the first octet is set, local when the
// locally-administered bit is, else global. Text that is not a MAC is global, so a lookup stays harmless.
func Kind(mac string) string {
if len(mac) < 2 {
return KindGlobal
}
var o byte
for _, c := range mac[:2] {
o <<= 4
switch {
case c >= '0' && c <= '9':
o |= byte(c - '0')
case c >= 'a' && c <= 'f':
o |= byte(c-'a') + 10
}
}
switch {
case o&1 != 0:
return KindMulticast
case o&2 != 0:
return KindLocal
}
return KindGlobal
}
// PrefixRange returns the half-open range [lo, hi) of canonical MACs that start with the canonical prefix
// p: every such string sorts inside it, so a prefix search is a range scan on an index. A prefix ending in
// a colon cannot come from ParsePrefix; it is handled for completeness.
func PrefixRange(p string) (lo, hi string) {
if p == "" {
return "", ""
}
last := p[len(p)-1]
switch last {
case ':':
return p, p[:len(p)-1] + ";" // ';' follows ':' in ASCII
case '9':
return p, p[:len(p)-1] + "a"
case 'f':
return p, p[:len(p)-1] + "g"
}
return p, p[:len(p)-1] + string(last+1)
}