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
147 lines
4.1 KiB
Go
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)
|
|
}
|