From daa5522990c0e6717fa59a62e35ad2fbeae4f00a Mon Sep 17 00:00:00 2001 From: gnacho Date: Fri, 16 Oct 2026 21:09:15 +0200 Subject: [PATCH 1/3] feat(clients): L2 client inventory from the bridge FDB (#520) A switch hides the Clients entry today (#518) because the router view is built from DHCP leases and ARP, which a pure L2 device does not have. The bridge FDB does know the useful part: which port each learned MAC sits on and which VLANs it carries. - internal/modules/l2clients.go reads the FDB over netlink AF_BRIDGE, skipping NTF_SELF entries and folding the VLANs of each (MAC, port) pair into one row. The `bridge` binary is never invoked: it is absent on some targets and busybox ip cannot dump the FDB. - The L2 view keeps the Clients menu name but carries no DHCP semantics: no leases, reservations, blocking or quotas. - GET /api/l2clients serves the probe. Shell renders the new page when the role is switch; Traffic stays hidden with its own reason. - Vendor guess from a partial IEEE OUI table (extracted from the local nmap database, not typed from memory). Locally administered MACs are flagged as random instead of guessed. - Demo stub, ES/EN strings and unit tests for the assembly logic, the OUI table consistency and the HTTP handler. --- app/src/api.ts | 1 + app/src/components/Shell.tsx | 17 ++- app/src/demo/data.ts | 15 +++ app/src/demo/index.ts | 1 + app/src/locales/en.ts | 25 +++- app/src/locales/es.ts | 25 +++- app/src/pages/L2Clients.tsx | 152 ++++++++++++++++++++++ app/src/types.ts | 19 +++ internal/modules/l2clients.go | 176 +++++++++++++++++++++++++ internal/modules/l2clients_seams.go | 12 ++ internal/modules/l2clients_test.go | 123 ++++++++++++++++++ internal/modules/oui.go | 193 ++++++++++++++++++++++++++++ internal/modules/oui_test.go | 68 ++++++++++ internal/server/l2clients_test.go | 47 +++++++ internal/server/server.go | 8 ++ 15 files changed, 869 insertions(+), 13 deletions(-) create mode 100644 app/src/pages/L2Clients.tsx create mode 100644 internal/modules/l2clients.go create mode 100644 internal/modules/l2clients_seams.go create mode 100644 internal/modules/l2clients_test.go create mode 100644 internal/modules/oui.go create mode 100644 internal/modules/oui_test.go create mode 100644 internal/server/l2clients_test.go diff --git a/app/src/api.ts b/app/src/api.ts index 9d48dea..5258b47 100644 --- a/app/src/api.ts +++ b/app/src/api.ts @@ -347,6 +347,7 @@ const realApi = { body: JSON.stringify({ ip, hostname, remove }), }), ethports: () => request<{ ports: import("./types").EthPort[] }>("/api/ethports"), + l2clients: () => request("/api/l2clients"), usteer: () => request<{ aps: import("./types").UsteerAP[] }>("/api/usteer"), guestwifi: () => request("/api/guestwifi"), setGuestwifi: (cfg: { enabled: boolean; ssid?: string; key?: string; band?: string; subnet?: string; isolate?: boolean }) => diff --git a/app/src/components/Shell.tsx b/app/src/components/Shell.tsx index 89c8c0c..377ad07 100644 --- a/app/src/components/Shell.tsx +++ b/app/src/components/Shell.tsx @@ -13,6 +13,7 @@ import { Overview } from "../pages/Overview"; import { TrafficPage } from "../pages/Traffic"; import { CoveragePage } from "../pages/Coverage"; import { ClientsPage } from "../pages/Clients"; +import { L2ClientsPage } from "../pages/L2Clients"; import { WifiPage } from "../pages/Wifi"; import { InternetPage } from "../pages/Internet"; import { Services } from "../pages/Services"; @@ -233,14 +234,12 @@ function ShellInner({ onLogout }: { onLogout: () => void }) { // #500: Servicios solo gateway (en AP solo quedaba AdGuard/mDNS; // el nombre local se movió a Ajustes). if (id === "services" && !isRouter) return "gatewayOnly"; - // #518: Clientes y Tráfico no tienen fuente de datos en un switch L2 - // dedicado: sin dnsmasq no hay leases ni reservas, y el conteo por - // cliente/app (nlbwmon) no aplica al forwarding por hardware. Ambas - // páginas quedaban vacías (Tráfico con un CTA muerto hacia Servicios, - // que en switch tampoco se ofrece), así que la entrada no se ofrece. - // El FDB del bridge sí se lee en switches; un inventario por boca sería - // una feature aparte (#518 comenta la evidencia). - if ((id === "clients" || id === "traffic") && isSwitch) return "switchL2"; + // #518/#520: en un switch L2, "Clients" muestra el inventario de la FDB + // (L2ClientsPage) en vez de la vista de router. "Traffic" sí queda + // oculto: el conteo por cliente/app (nlbwmon) no aplica al forwarding + // por hardware y la página quedaría vacía (su CTA apunta a Servicios, + // que en switch tampoco se ofrece). + if ((id === "traffic") && isSwitch) return "switchL2"; return null; }; const visible = (id: Page) => gateOf(id) === null; @@ -438,7 +437,7 @@ function ShellInner({ onLogout }: { onLogout: () => void }) { /> )} {activePage === "traffic" && } - {activePage === "clients" && } + {activePage === "clients" && (isSwitch ? : )} {activePage === "internet" && ( )} diff --git a/app/src/demo/data.ts b/app/src/demo/data.ts index fca5b18..4dd1dd1 100644 --- a/app/src/demo/data.ts +++ b/app/src/demo/data.ts @@ -731,6 +731,21 @@ export const demoStorage: T.StorageProbe = { ], }; +export const demoL2Clients: T.L2Probe = { + applicable: true, + bridge: "br-lan", + ports: ["lan1", "lan2", "lan3", "lan4"], + clients: [ + { mac: "04:cf:8c:11:22:33", vendor: "Xiaomi", port: "lan1", vlans: [10, 20], age: 42 }, + { mac: "4c:eb:d6:aa:bb:cc", vendor: "Espressif", port: "lan1", vlans: [20], age: 3 }, + { mac: "02:1a:2b:3c:4d:5e", random: true, port: "lan2", vlans: [0], age: 120 }, + { mac: "00:0a:eb:12:34:56", vendor: "TP-Link Technologies", port: "lan2", vlans: [], age: 5 }, + { mac: "00:15:6d:de:ad:00", vendor: "Ubiquiti", port: "lan3", vlans: [10], age: 90 }, + { mac: "00:04:3c:70:80:90", vendor: "Sonos", port: "lan4", vlans: [10, 30], age: 3600, static: true }, + { mac: "b8:27:eb:aa:11:22", vendor: "Raspberry Pi Foundation", port: "lan4", vlans: [30], age: 15 }, + ], +}; + export const demoMacAcl: T.MACACLProbe = { applicable: false, ports: [] }; export const demoSelfUpdate: T.SelfUpdateCheck = { diff --git a/app/src/demo/index.ts b/app/src/demo/index.ts index 66e3daf..9218da2 100644 --- a/app/src/demo/index.ts +++ b/app/src/demo/index.ts @@ -563,6 +563,7 @@ export const demoApi: typeof api = { // puertos ethports: () => get({ ports: D.demoEthPorts }), + l2clients: () => get(D.demoL2Clients), multiwan: () => get(state.mwan), setMultiwan: async (req) => { const m = state.mwan; diff --git a/app/src/locales/en.ts b/app/src/locales/en.ts index 5481140..3689513 100644 --- a/app/src/locales/en.ts +++ b/app/src/locales/en.ts @@ -62,7 +62,7 @@ export default { desc: { overview: "How your router is doing", internet: "Your line to the outside: WAN, forwards, per-app traffic and attack blocking", - clients: "Who is connected to your network right now", + clients: "Devices connected to your network", coverage: "How Wi-Fi is spread across your devices", traffic: "Which devices and apps use the most", wifi: "Networks, passwords and who's connected", @@ -86,7 +86,7 @@ export default { portsAp: "Its port detail lives in Overview", noPorts: "No manageable built-in switch", noStorage: "No USB storage to manage", - switchL2: "A switch does not route: the router sees the clients and their usage", + switchL2: "A switch does not route: the router sees per-client usage", }, }, theme: { @@ -1283,6 +1283,27 @@ export default { doneOn: "Done. The guest welcome page is on.", doneOff: "Done. The guest welcome page is off.", }, + l2clients: { + title: "Connected devices", + subtitle: "Layer-2 inventory: every MAC the bridge has learned, with its port and VLAN", + col: { + mac: "MAC address", + vendor: "Vendor", + port: "Port", + vlans: "VLANs", + age: "Age", + static: "Static", + }, + empty: "No clients learned on the bridge yet", + notApplicable: "This device does not expose a bridge FDB to inventory", + searchPlaceholder: "Search by MAC, port or vendor", + randomMac: "Random", + staticEntry: "Static", + unfilteredVlan: "Unfiltered", + ageSeconds: "{{seconds}} s", + ageMinutes: "{{minutes}} min", + ageUnknown: "unknown", + }, traffic: { title: "Traffic", measuring: "Measuring...", diff --git a/app/src/locales/es.ts b/app/src/locales/es.ts index 17b3d66..7523c0e 100644 --- a/app/src/locales/es.ts +++ b/app/src/locales/es.ts @@ -62,7 +62,7 @@ export default { desc: { overview: "Cómo está tu router ahora", internet: "Tu línea con el exterior: WAN, reenvíos, tráfico por app y bloqueo de ataques", - clients: "Quién está conectado a tu red ahora mismo", + clients: "Equipos conectados a la red", coverage: "Cómo se reparte el Wi-Fi entre tus equipos", traffic: "Qué dispositivos y apps gastan más", wifi: "Redes, claves y quién está conectado", @@ -86,7 +86,7 @@ export default { portsAp: "El detalle de sus bocas vive en Resumen", noPorts: "Sin switch integrado gestionable", noStorage: "No hay almacenamiento USB que gestionar", - switchL2: "Un switch no enruta: los clientes y su consumo los ve el router", + switchL2: "Un switch no enruta: el consumo por cliente lo ve el router", }, }, theme: { @@ -1283,6 +1283,27 @@ export default { doneOn: "Listo. La página de bienvenida de invitados está activa.", doneOff: "Listo. La página de bienvenida de invitados está apagada.", }, + l2clients: { + title: "Equipos conectados", + subtitle: "Inventario de nivel 2: cada MAC aprendida por el bridge con su puerto y VLAN", + col: { + mac: "Dirección MAC", + vendor: "Fabricante", + port: "Puerto", + vlans: "VLANs", + age: "Edad", + static: "Estática", + }, + empty: "No hay clientes aprendidos en el bridge todavía", + notApplicable: "Este equipo no expone una FDB de bridge que inventariar", + searchPlaceholder: "Buscar por MAC, puerto o fabricante", + randomMac: "Aleatoria", + staticEntry: "Estática", + unfilteredVlan: "Sin filtrado", + ageSeconds: "{{seconds}} s", + ageMinutes: "{{minutes}} min", + ageUnknown: "desconocida", + }, traffic: { title: "Tráfico", measuring: "Midiendo...", diff --git a/app/src/pages/L2Clients.tsx b/app/src/pages/L2Clients.tsx new file mode 100644 index 0000000..9e588ae --- /dev/null +++ b/app/src/pages/L2Clients.tsx @@ -0,0 +1,152 @@ +import { useCallback, useEffect, useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Network, Search } from "lucide-react"; +import { api } from "../api"; +import type { L2Client, L2Probe } from "../types"; +import { Card, EmptyState, Input, Pill, Skeleton } from "../components/ui"; +import { IlluDevices } from "../components/ui/illustrations"; + +/** + * Inventario de clientes de nivel 2 (#520): en un switch, la vista "Clients" + * no se alimenta de leases/ARP sino de la FDB del bridge (MAC + puerto + + * VLAN). Sin semántica DHCP: aquí no hay reservas, bloqueos, cuotas ni + * parental. Se muestra cuando el rol es switch; el router/AP sigue usando + * ClientsPage. + */ + +/** Edad legible desde segundos (0 = desconocida). */ +function fmtAge(age: number | undefined, t: (k: string, o?: Record) => string): string { + if (!age) return t("l2clients.ageUnknown"); + if (age < 60) return t("l2clients.ageSeconds", { seconds: age }); + return t("l2clients.ageMinutes", { minutes: Math.floor(age / 60) }); +} + +/** Una lista de VLANs vacía o con solo [0] significa "sin filtrado". */ +function unfiltered(vlans: number[]): boolean { + return vlans.length === 0 || (vlans.length === 1 && vlans[0] === 0); +} + +function vlanText(vlans: number[], t: (k: string, o?: Record) => string): string { + if (unfiltered(vlans)) return t("l2clients.unfilteredVlan"); + return vlans.join(", "); +} + +export function L2ClientsPage() { + const { t } = useTranslation(); + const [probe, setProbe] = useState(); + const [query, setQuery] = useState(""); + + const load = useCallback(async () => { + try { + setProbe(await api.l2clients()); + } catch { + // Error de red: se conserva el último inventario y el poller reintenta. + } + }, []); + + // Mismo refresco que la vista de router: la FDB aprende y caduca sola. + useEffect(() => { load(); const id = setInterval(load, 5000); return () => clearInterval(id); }, [load]); + + const clients = useMemo(() => { + const list = probe?.clients ?? []; + const q = query.trim().toLowerCase(); + if (!q) return list; + return list.filter((c) => + c.mac.toLowerCase().includes(q) || + c.port.toLowerCase().includes(q) || + (c.vendor ?? "").toLowerCase().includes(q), + ); + }, [probe, query]); + + // Página entera no aplica (sin bridge resoluble) y se entra por URL. + if (probe && !probe.applicable) { + return ( + + } title={t("l2clients.notApplicable")} /> + + ); + } + + const showSearch = (probe?.clients.length ?? 0) > 10; + + return ( +
+ {t("l2clients.subtitle")} + )} + > + {!probe ? ( +
+ + +
+ ) : clients.length === 0 ? ( + } title={t("l2clients.empty")} /> + ) : ( + <> + {showSearch && ( +
+ setQuery(e.target.value)} + placeholder={t("l2clients.searchPlaceholder")} + icon={Search} + aria-label={t("l2clients.searchPlaceholder")} + /> +
+ )} +
+ + + + + + + + + + + + {clients.map((c) => ( + + ))} + +
{t("l2clients.col.mac")}{t("l2clients.col.vendor")}{t("l2clients.col.port")}{t("l2clients.col.vlans")}{t("l2clients.col.age")}
+
+ + )} +
+
+ ); +} + +function L2Row({ c, t }: { c: L2Client; t: (k: string, o?: Record) => string }) { + return ( + + {c.mac} + + {c.random ? ( + {t("l2clients.randomMac")} + ) : c.vendor ? ( + c.vendor + ) : ( + - + )} + + + {c.port} + + {vlanText(c.vlans, t)} + + + {fmtAge(c.age, t)} + {c.static && {t("l2clients.staticEntry")}} + + + + ); +} diff --git a/app/src/types.ts b/app/src/types.ts index 0d39429..5e22da1 100644 --- a/app/src/types.ts +++ b/app/src/types.ts @@ -694,6 +694,25 @@ export interface VLANProbe { ports: string[]; } +/** One learned MAC in a switch's bridge FDB (#520). */ +export interface L2Client { + mac: string; + vendor?: string; + random?: boolean; + port: string; + vlans: number[]; + age?: number; + static?: boolean; +} + +/** GET /api/l2clients payload (#520). */ +export interface L2Probe { + applicable: boolean; + bridge?: string; + ports?: string[]; + clients: L2Client[]; +} + export interface VLANEdit { vid: number; ports: VLANPort[]; diff --git a/internal/modules/l2clients.go b/internal/modules/l2clients.go new file mode 100644 index 0000000..f818150 --- /dev/null +++ b/internal/modules/l2clients.go @@ -0,0 +1,176 @@ +package modules + +import ( + "net" + "sort" + "strings" + + "github.com/vishvananda/netlink" + "golang.org/x/sys/unix" +) + +// L2Client is one learned MAC in the switch's bridge FDB, shown in the L2 +// client inventory (#520). There is no DHCP/IP semantics here: a pure L2 +// switch only knows the MAC, the port it was learned on and the VLANs it +// carries. Vlans is sorted ascending; an empty list (or [0]) means the +// entry is not filtered by VLAN. +type L2Client struct { + MAC string `json:"mac"` + Vendor string `json:"vendor,omitempty"` + Random bool `json:"random,omitempty"` + Port string `json:"port"` + Vlans []int `json:"vlans"` + Age int `json:"age,omitempty"` + Static bool `json:"static,omitempty"` +} + +// L2Probe is the payload for GET /api/l2clients. Applicable is false when +// there is no resolvable bridge or the FDB read failed; in that case Clients +// is empty and Ports still carries the bridge's ports so the UI can show a +// coherent "not applicable" state. +type L2Probe struct { + Applicable bool `json:"applicable"` + Bridge string `json:"bridge,omitempty"` + Ports []string `json:"ports,omitempty"` + Clients []L2Client `json:"clients"` +} + +// l2Neigh is a bridge FDB entry already parsed from netlink, decoupled from +// the netlink types so the assembly logic is unit-testable. +type l2Neigh struct { + mac string + vlan int + linkIdx int + flags int + state int + used uint32 +} + +// probeL2ClientsFn is the seam for tests; it defaults to the real read. +var probeL2ClientsFn = probeL2ClientsImpl + +// ProbeL2Clients returns the L2 client inventory for the configured LAN +// bridge via netlink AF_BRIDGE (the FDB). Unlike the router/AP clients view +// it never invokes the `bridge` binary (absent on some targets) nor relies +// on DHCP leases or ARP. +func ProbeL2Clients() *L2Probe { + return probeL2ClientsFn() +} + +func probeL2ClientsImpl() *L2Probe { + bridge := LANBridge() + ports := bridgePortList() + if bridge == "" { + return &L2Probe{Applicable: false, Bridge: bridge, Ports: ports, Clients: []L2Client{}} + } + link, err := netlink.LinkByName(bridge) + if err != nil { + return &L2Probe{Applicable: false, Bridge: bridge, Ports: ports, Clients: []L2Client{}} + } + neighs, err := netlink.NeighList(link.Attrs().Index, unix.AF_BRIDGE) + if err != nil { + return &L2Probe{Applicable: false, Bridge: bridge, Ports: ports, Clients: []L2Client{}} + } + entries := make([]l2Neigh, 0, len(neighs)) + for _, n := range neighs { + mac := strings.ToLower(n.HardwareAddr.String()) + if mac == "" { + continue + } + entries = append(entries, l2Neigh{ + mac: mac, + vlan: n.Vlan, + linkIdx: n.LinkIndex, + flags: n.Flags, + state: n.State, + used: n.Used, + }) + } + clients := assembleL2Clients(entries, portNameForIndex) + return &L2Probe{Applicable: true, Bridge: bridge, Ports: ports, Clients: clients} +} + +// portNameForIndex resolves a bridge port index to its interface name, +// preferring netlink (which knows bridge ports by index) and falling back to +// the standard net interfaces table. +func portNameForIndex(idx int) string { + if l, err := netlink.LinkByIndex(idx); err == nil && l != nil { + return l.Attrs().Name + } + if ifc, err := net.InterfaceByIndex(idx); err == nil { + return ifc.Name + } + return "" +} + +// assembleL2Clients groups FDB entries by (MAC, port), accumulating the +// VLANs each device carries on that port, then sorts the result by port name +// and MAC for a stable order. A MAC learned on two different ports stays as +// two rows: that is what loop detection looks for (same rationale as +// parseBrctlShowmacs). A single (MAC, port) on several VLANs collapses into +// one row instead of one row per VLAN (#520). +func assembleL2Clients(entries []l2Neigh, portOf func(int) string) []L2Client { + // Key is MAC + "\x00" + port; accumulate VLANs in insertion order and + // dedupe them when folding. + type acc struct { + client L2Client + seen map[int]bool + } + groups := map[string]*acc{} + var order []string + for _, e := range entries { + // The bridge's own local entries (NTF_SELF) are not clients. + if e.flags&unix.NTF_SELF != 0 { + continue + } + port := portOf(e.linkIdx) + if port == "" { + continue + } + key := e.mac + "\x00" + port + g, ok := groups[key] + if !ok { + g = &acc{ + client: L2Client{ + MAC: e.mac, + Port: port, + Static: e.state == unix.NUD_NOARP, + Age: ageFromTicks(e.used), + }, + seen: map[int]bool{}, + } + groups[key] = g + order = append(order, key) + } + if !g.seen[e.vlan] { + g.seen[e.vlan] = true + g.client.Vlans = append(g.client.Vlans, e.vlan) + } + } + + clients := make([]L2Client, 0, len(order)) + for _, key := range order { + g := groups[key] + sort.Ints(g.client.Vlans) + g.client.Vendor, g.client.Random = vendorFor(g.client.MAC) + clients = append(clients, g.client) + } + sort.Slice(clients, func(i, j int) bool { + if clients[i].Port != clients[j].Port { + return clients[i].Port < clients[j].Port + } + return clients[i].MAC < clients[j].MAC + }) + return clients +} + +// ageFromTicks converts a netlink "used" clock-tick count into seconds. The +// kernel expresses it in clock ticks; _SC_CLK_TCK is 100 on every OpenWrt +// target NetGrip runs on, so divide by 100. Zero means "unknown age" and is +// left as zero rather than guessed from another source. +func ageFromTicks(used uint32) int { + if used == 0 { + return 0 + } + return int(used / 100) +} diff --git a/internal/modules/l2clients_seams.go b/internal/modules/l2clients_seams.go new file mode 100644 index 0000000..cec1cf5 --- /dev/null +++ b/internal/modules/l2clients_seams.go @@ -0,0 +1,12 @@ +package modules + +// SetL2ClientsSeamsForTest swaps the L2 client probe for fn for the duration +// of the test and restores the previous one on cleanup. Regular file (not +// _test.go) so the handler test in internal/server can fake the FDB read +// without touching real netlink, like SetClientsSeamsForTest. +func SetL2ClientsSeamsForTest(t testT, fn func() *L2Probe) { + t.Helper() + old := probeL2ClientsFn + probeL2ClientsFn = fn + t.Cleanup(func() { probeL2ClientsFn = old }) +} diff --git a/internal/modules/l2clients_test.go b/internal/modules/l2clients_test.go new file mode 100644 index 0000000..ed3284d --- /dev/null +++ b/internal/modules/l2clients_test.go @@ -0,0 +1,123 @@ +package modules + +import ( + "testing" + + "golang.org/x/sys/unix" +) + +// portIndex maps the fixture link indexes used in the tests to names. +func fixedPorts(idx int) string { + switch idx { + case 1: + return "lan1" + case 2: + return "lan2" + default: + return "" + } +} + +func TestAssembleL2ClientsExcludesSelf(t *testing.T) { + entries := []l2Neigh{ + {mac: "aa:bb:cc:00:00:01", vlan: 1, linkIdx: 1}, + {mac: "02:00:00:00:00:22", vlan: 1, linkIdx: 1, flags: unix.NTF_SELF}, + } + clients := assembleL2Clients(entries, fixedPorts) + if len(clients) != 1 { + t.Fatalf("clients = %v, want only the non-self entry", clients) + } + if clients[0].MAC != "aa:bb:cc:00:00:01" { + t.Fatalf("kept client = %+v, want the non-self MAC", clients[0]) + } +} + +func TestAssembleL2ClientsGroupsVlansByMACPort(t *testing.T) { + // The same MAC on the same port across several VLANs must collapse to + // one row with all VLANs, not one row per VLAN (#520). + entries := []l2Neigh{ + {mac: "aa:bb:cc:00:00:01", vlan: 10, linkIdx: 1}, + {mac: "aa:bb:cc:00:00:01", vlan: 20, linkIdx: 1}, + {mac: "aa:bb:cc:00:00:01", vlan: 10, linkIdx: 1}, // duplicate + {mac: "aa:bb:cc:00:00:02", vlan: 10, linkIdx: 1}, + } + clients := assembleL2Clients(entries, fixedPorts) + if len(clients) != 2 { + t.Fatalf("clients = %d, want 2 rows: %v", len(clients), clients) + } + var target *L2Client + for i := range clients { + if clients[i].MAC == "aa:bb:cc:00:00:01" { + target = &clients[i] + } + } + if target == nil { + t.Fatalf("the VLAN-grouped MAC is missing: %v", clients) + } + if len(target.Vlans) != 2 || target.Vlans[0] != 10 || target.Vlans[1] != 20 { + t.Fatalf("vlans = %v, want [10 20]", target.Vlans) + } +} + +func TestAssembleL2ClientsKeepsMACOnTwoPorts(t *testing.T) { + // A MAC learned on two ports stays as two rows: loop detection depends + // on it (same rationale as parseBrctlShowmacs). + entries := []l2Neigh{ + {mac: "aa:bb:cc:00:00:01", vlan: 0, linkIdx: 1}, + {mac: "aa:bb:cc:00:00:01", vlan: 0, linkIdx: 2}, + } + clients := assembleL2Clients(entries, fixedPorts) + if len(clients) != 2 { + t.Fatalf("clients = %d, want 2 (one per port): %v", len(clients), clients) + } +} + +func TestAssembleL2ClientsStaticAndAge(t *testing.T) { + entries := []l2Neigh{ + {mac: "aa:bb:cc:00:00:01", vlan: 0, linkIdx: 1, state: unix.NUD_NOARP, used: 300}, + {mac: "aa:bb:cc:00:00:02", vlan: 0, linkIdx: 1, used: 0}, + } + clients := assembleL2Clients(entries, fixedPorts) + if len(clients) != 2 { + t.Fatalf("clients = %d, want 2: %v", len(clients), clients) + } + byMAC := map[string]L2Client{} + for _, c := range clients { + byMAC[c.MAC] = c + } + if c := byMAC["aa:bb:cc:00:00:01"]; !c.Static || c.Age != 3 { + t.Errorf("static entry = %+v, want Static=true Age=3", c) + } + if c := byMAC["aa:bb:cc:00:00:02"]; c.Static || c.Age != 0 { + t.Errorf("unknown-age entry = %+v, want Static=false Age=0", c) + } +} + +func TestAssembleL2ClientsStableOrder(t *testing.T) { + entries := []l2Neigh{ + {mac: "cc:cc:cc:00:00:03", vlan: 0, linkIdx: 1}, + {mac: "aa:aa:aa:00:00:01", vlan: 0, linkIdx: 2}, + {mac: "bb:bb:bb:00:00:02", vlan: 0, linkIdx: 1}, + } + clients := assembleL2Clients(entries, fixedPorts) + want := []string{ + "bb:bb:bb:00:00:02", // lan1 + "cc:cc:cc:00:00:03", // lan1 + "aa:aa:aa:00:00:01", // lan2 + } + for i, w := range want { + if clients[i].MAC != w { + t.Fatalf("order[%d] = %s, want %s (full: %v)", i, clients[i].MAC, w, clients) + } + } +} + +func TestAssembleL2ClientsSkipsUnresolvedPort(t *testing.T) { + entries := []l2Neigh{ + {mac: "aa:bb:cc:00:00:01", vlan: 0, linkIdx: 9}, + } + clients := assembleL2Clients(entries, fixedPorts) + if len(clients) != 0 { + t.Fatalf("clients = %v, want none for an unresolvable port", clients) + } +} diff --git a/internal/modules/oui.go b/internal/modules/oui.go new file mode 100644 index 0000000..17df57c --- /dev/null +++ b/internal/modules/oui.go @@ -0,0 +1,193 @@ +package modules + +import "strings" + +// oui.go maps MAC OUIs (the first three octets) to a vendor name for the +// L2 client inventory (#520). The table below is a partial, best-effort +// extraction from the local IEEE OUI database shipped with nmap +// (/usr/share/nmap/nmap-mac-prefixes), not typed from memory. Vendors that +// do not appear in that database are deliberately omitted rather than +// invented. Long legal suffixes are trimmed only when they add no signal +// (", Ltd", "Co.,Ltd"); the name is otherwise kept as the database has it. + +// ouiVendors maps an uppercase "XXXXXX" prefix (bare hex, no separators) to +// its vendor name. Keys must stay in that single format: vendorFor builds the +// lookup key by concatenating the first three octets, and a key with +// separators would silently never match. +var ouiVendors = map[string]string{ + // Espressif (ESP8266/ESP32 and the Tuya modules built around them). + "004B12": "Espressif", + "007007": "Espressif", + "048308": "Espressif", + "04B247": "Espressif", + + // Xiaomi. + "009EC8": "Xiaomi Communications", + "00C30A": "Xiaomi Communications", + "00EC0A": "Xiaomi Communications", + "04106B": "Xiaomi Communications", + "9C9D7E": "Beijing Xiaomi Mobile Software", + + // Tuya. + "00337A": "Tuya Smart", + "105A17": "Tuya Smart", + "10D561": "Tuya Smart", + "1869D8": "Tuya Smart", + + // TP-Link. + "000AEB": "TP-Link Technologies", + "001478": "TP-Link Technologies", + "0019E0": "TP-Link Technologies", + "001D0F": "TP-Link Technologies", + + // Amazon. + "007147": "Amazon Technologies", + "008621": "Amazon Technologies", + "00BB3A": "Amazon Technologies", + "00F361": "Amazon Technologies", + + // Google. + "001A11": "Google", + "00F620": "Google", + "04006E": "Google", + "04C8B0": "Google", + + // Apple. + "000393": "Apple", + "000502": "Apple", + "000A27": "Apple", + "000A95": "Apple", + + // Samsung. + "0000F0": "Samsung Electronics", + "0007AB": "Samsung Electronics", + "001247": "Samsung Electronics", + "0012FB": "Samsung Electronics", + + // Huawei. + "001882": "Huawei Technologies", + "001E10": "Huawei Technologies", + "002568": "Huawei Technologies", + "00259E": "Huawei Technologies", + + // Sonos. + "00043C": "Sonos", + "000E58": "Sonos", + "347E5C": "Sonos", + + // ASUSTek. + "000C6E": "ASUSTek Computer", + "000EA6": "ASUSTek Computer", + "00112F": "ASUSTek Computer", + "0011D8": "ASUSTek Computer", + + // Raspberry Pi. + "28CDC1": "Raspberry Pi Trading", + "2CCF67": "Raspberry Pi Trading", + "88A29E": "Raspberry Pi Trading", + "B827EB": "Raspberry Pi Foundation", + + // Intel. + "0012F0": "Intel Corporate", + "001302": "Intel Corporate", + "001320": "Intel Corporate", + "0013CE": "Intel Corporate", + + // Broadcom. + "0005B5": "Broadcom Technologies", + "000AF7": "Broadcom", + "000DB6": "Broadcom", + "001018": "Broadcom", + + // Realtek. + "00E04C": "Realtek Semiconductor", + + // Netgear. + "00095B": "Netgear", + "000FB5": "Netgear", + "00146C": "Netgear", + "00184D": "Netgear", + + // Ubiquiti. + "00156D": "Ubiquiti", + "002722": "Ubiquiti", + "0418D6": "Ubiquiti", + "0CEA14": "Ubiquiti", + + // Nintendo. + "0009BF": "Nintendo", + "001656": "Nintendo", + "0017AB": "Nintendo", + "00191D": "Nintendo", + + // D&M (Denon/Marantz). + "0005CD": "D&M Holdings", + "000678": "D&M Holdings", + "8CA96F": "D&M Holdings", + + // OnePlus. + "2CA7EF": "OnePlus Technology", + "30BB7D": "OnePlus Technology", + "4801C5": "OnePlus Technology", + "487412": "OnePlus Technology", + + // GL.iNet. + "9483C4": "GL Technologies", + + // Aqara / Lumi. + "18C23C": "Lumi United Technology", + "54EF44": "Lumi United Technology", + + // Philips. + "00026C": "Philips CFT", + "00054E": "Philips", + "0008C6": "Philips Consumer Communications", + "001788": "Philips Lighting", + + // Renesas. + "001E01": "Renesas Technology Sales", + "003055": "Renesas Technology America", + "0091EB": "Renesas Electronics", + + // Texas Instruments. + "001237": "Texas Instruments", + "00124B": "Texas Instruments", + "0012D1": "Texas Instruments", + "0012D2": "Texas Instruments", +} + +// vendorFor resolves a MAC's vendor from its OUI. random is true when the +// MAC's first octet has the locally administered bit set and the address is +// not multicast; such MACs are self-assigned (or privacy-randomized) and do +// not carry a meaningful OUI, so no vendor is looked up. mac must be in +// "xx:xx:xx:xx:xx:xx" form (upper or lower case). +func vendorFor(mac string) (vendor string, random bool) { + octets := strings.Split(mac, ":") + if len(octets) < 6 { + return "", false + } + // Parse the first octet for the locally administered / multicast bits. + var first byte + for _, c := range []byte(octets[0]) { + first = first<<4 | hexVal(c) + } + // Locally administered (0x02) but not multicast (0x01): no real OUI. + if first&0x01 == 0 && first&0x02 != 0 { + return "", true + } + prefix := strings.ToUpper(octets[0] + octets[1] + octets[2]) + return ouiVendors[prefix], false +} + +func hexVal(c byte) byte { + switch { + case c >= '0' && c <= '9': + return c - '0' + case c >= 'a' && c <= 'f': + return c - 'a' + 10 + case c >= 'A' && c <= 'F': + return c - 'A' + 10 + default: + return 0 + } +} diff --git a/internal/modules/oui_test.go b/internal/modules/oui_test.go new file mode 100644 index 0000000..df0f764 --- /dev/null +++ b/internal/modules/oui_test.go @@ -0,0 +1,68 @@ +package modules + +import ( + "strings" + "testing" +) + +func TestVendorForKnownOUI(t *testing.T) { + // 9C:9D:7E is a Beijing Xiaomi Mobile Software OUI in the local IEEE table. + if vendor, random := vendorFor("9c:9d:7e:11:22:33"); vendor != "Beijing Xiaomi Mobile Software" || random { + t.Fatalf("vendorFor(9c:9d:7e:...) = (%q, %v), want (Beijing Xiaomi Mobile Software, false)", vendor, random) + } + // Uppercase input must resolve the same way. + if vendor, random := vendorFor("9C:9D:7E:AA:BB:CC"); vendor != "Beijing Xiaomi Mobile Software" || random { + t.Fatalf("vendorFor(9C:9D:7E:...) = (%q, %v), want (Beijing Xiaomi Mobile Software, false)", vendor, random) + } + // A second, unrelated entry so the lookup is not only exercised through + // the one prefix the test happens to name (#520 review). + if vendor, random := vendorFor("00:4b:12:11:22:33"); vendor != "Espressif" || random { + t.Fatalf("vendorFor(00:4b:12:...) = (%q, %v), want (Espressif, false)", vendor, random) + } +} + +// TestVendorForTableIsSelfConsistent walks the whole OUI table: every key must +// be six uppercase hex digits without separators (the exact shape vendorFor +// builds) and must resolve back to its own vendor. A key written in any other +// shape would never match, which is the bug this guards against. +func TestVendorForTableIsSelfConsistent(t *testing.T) { + if len(ouiVendors) == 0 { + t.Fatal("ouiVendors is empty") + } + for prefix, want := range ouiVendors { + if len(prefix) != 6 || strings.Contains(prefix, ":") || strings.ToUpper(prefix) != prefix { + t.Fatalf("ouiVendors key %q must be 6 uppercase hex digits without separators", prefix) + } + for _, c := range prefix { + if !strings.ContainsRune("0123456789ABCDEF", c) { + t.Fatalf("ouiVendors key %q is not hexadecimal", prefix) + } + } + mac := prefix[0:2] + ":" + prefix[2:4] + ":" + prefix[4:6] + ":11:22:33" + if vendor, random := vendorFor(mac); vendor != want || random { + t.Fatalf("vendorFor(%s) = (%q, %v), want (%q, false), table key %q", mac, vendor, random, want, prefix) + } + } +} + +func TestVendorForLocallyAdministered(t *testing.T) { + // 02:xx:xx has the locally administered bit set and is not multicast: + // random, with no vendor lookup. + if vendor, random := vendorFor("02:9c:9d:7e:11:22"); vendor != "" || !random { + t.Fatalf("vendorFor(02:...) = (%q, %v), want (\"\", true)", vendor, random) + } +} + +func TestVendorForMulticastNotRandom(t *testing.T) { + // 01:xx:xx is multicast: not flagged random (it is not a client MAC). + if vendor, random := vendorFor("01:00:5e:00:00:01"); random { + t.Fatalf("vendorFor(multicast) = (%q, %v), want random=false", vendor, random) + } +} + +func TestVendorForUnknownOUI(t *testing.T) { + // A universally administered OUI that is not in the table. + if vendor, random := vendorFor("00:00:00:ef:00:01"); vendor != "" || random { + t.Fatalf("vendorFor(unknown) = (%q, %v), want (\"\", false)", vendor, random) + } +} diff --git a/internal/server/l2clients_test.go b/internal/server/l2clients_test.go new file mode 100644 index 0000000..8f8c92c --- /dev/null +++ b/internal/server/l2clients_test.go @@ -0,0 +1,47 @@ +package server + +import ( + "encoding/json" + "net/http" + "testing" + + "github.com/gnacho/netgrip/internal/modules" +) + +// GET /api/l2clients (behind requireAuth, inside the probe cache) returns the +// L2 inventory at the root, without touching real netlink. It is the switch +// counterpart to /api/clients (#520). +func TestL2ClientsHandlerWithSeam(t *testing.T) { + modules.SetL2ClientsSeamsForTest(t, func() *modules.L2Probe { + return &modules.L2Probe{ + Applicable: true, + Bridge: "br-lan", + Ports: []string{"lan1", "lan2"}, + Clients: []modules.L2Client{ + {MAC: "aa:bb:cc:00:00:01", Port: "lan1", Vlans: []int{10, 20}}, + }, + } + }) + modules.InvalidateKey("l2clients") + + s := New("http://127.0.0.1/ubus", "test", false, "") + rec := authedRequest(t, s, "GET", "/api/l2clients", "") + if rec.Code != http.StatusOK { + t.Fatalf("GET /api/l2clients: %d, want 200: %s", rec.Code, rec.Body.String()) + } + + var probe struct { + Applicable bool `json:"applicable"` + Bridge string `json:"bridge"` + Clients []modules.L2Client `json:"clients"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &probe); err != nil { + t.Fatalf("response is not the l2clients payload: %v", err) + } + if !probe.Applicable || probe.Bridge != "br-lan" { + t.Fatalf("probe = %+v, want applicable br-lan", probe) + } + if len(probe.Clients) != 1 || probe.Clients[0].Port != "lan1" { + t.Fatalf("clients = %v, want one client on lan1", probe.Clients) + } +} diff --git a/internal/server/server.go b/internal/server/server.go index b13a675..d490328 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -207,6 +207,7 @@ func New(rpcdURL, version string, secure bool, servingCert string) *Server { s.mux.HandleFunc("GET /api/ethports", s.requireAuth(s.handleEthPorts)) s.mux.HandleFunc("GET /api/usteer", s.requireAuth(s.handleUsteer)) s.mux.HandleFunc("GET /api/clients", s.requireAuth(s.handleClients)) + s.mux.HandleFunc("GET /api/l2clients", s.requireAuth(s.handleL2Clients)) s.mux.HandleFunc("GET /api/clients/blocked", s.requireAuth(s.handleClientsBlocked)) s.mux.HandleFunc("GET /api/clients/meta", s.requireAuth(s.handleClientMeta)) s.mux.HandleFunc("POST /api/clients/meta", s.requireAuth(s.handleSetClientMeta)) @@ -1844,6 +1845,13 @@ func (s *Server) handleClients(w http.ResponseWriter, r *http.Request) { writeJSON(w, payload) } +func (s *Server) handleL2Clients(w http.ResponseWriter, _ *http.Request) { + probe, _ := modules.CachedRead("l2clients", probeCacheTTL, func() (*modules.L2Probe, error) { + return modules.ProbeL2Clients(), nil + }) + writeJSON(w, probe) +} + func (s *Server) handleClientsBlocked(w http.ResponseWriter, _ *http.Request) { payload, _ := modules.CachedRead("clients.blocked", probeCacheTTL, func() (map[string]any, error) { return map[string]any{"blocked": modules.BlockedClients(), "ts": time.Now().UnixMilli()}, nil From 7b2d5dff6d378d1f010316d847266e16faf3ef44 Mon Sep 17 00:00:00 2001 From: gnacho Date: Fri, 16 Oct 2026 21:29:01 +0200 Subject: [PATCH 2/3] fix(switch): dump the bridge FDB per port, not by bridge index (#520) A netlink AF_BRIDGE dump filtered by the bridge's own index returns only the bridge's local entries: on a live router (rt3) it came back with 8 entries and 0 learned ones, so the L2 inventory would have been empty on every device. Filtering by each bridge port returns the learned entries, 60 of them on that same router, and the port of every entry is known by construction instead of resolved from an index. The read also drops a port's own address (hostapd publishes the BSSID MAC as a static FDB entry and the WAN port does the same), reports a configured entry as static for both kernel states it uses (NOARP and PERMANENT) and normalises the VLAN list, because a filtered bridge reports the same MAC once with VLAN 0 and once with its real VLAN. Verified with the real probe on the live router: 55 rows, 54 of them on the downlink port, matching the 54 non-local entries brctl showmacs reports there. --- internal/modules/l2clients.go | 145 ++++++++++++++++++++--------- internal/modules/l2clients_test.go | 140 ++++++++++++++++++++-------- 2 files changed, 201 insertions(+), 84 deletions(-) diff --git a/internal/modules/l2clients.go b/internal/modules/l2clients.go index f818150..cb6ccc5 100644 --- a/internal/modules/l2clients.go +++ b/internal/modules/l2clients.go @@ -1,7 +1,7 @@ package modules import ( - "net" + "os" "sort" "strings" @@ -36,14 +36,16 @@ type L2Probe struct { } // l2Neigh is a bridge FDB entry already parsed from netlink, decoupled from -// the netlink types so the assembly logic is unit-testable. +// the netlink types so the assembly logic is unit-testable. Port is the +// bridge port the entry belongs to: the dump is issued per port, so the port +// is known by construction instead of resolved from an interface index. type l2Neigh struct { - mac string - vlan int - linkIdx int - flags int - state int - used uint32 + mac string + port string + vlan int + flags int + state int + used uint32 } // probeL2ClientsFn is the seam for tests; it defaults to the real read. @@ -57,50 +59,77 @@ func ProbeL2Clients() *L2Probe { return probeL2ClientsFn() } +// probeL2ClientsImpl reads the bridge FDB over netlink AF_BRIDGE. +// +// The dump is issued PER PORT, not against the bridge: a dump filtered by +// the bridge's own index returns only the bridge's local entries (the +// multicast addresses of the device itself) and zero learned ones, so a view +// built that way is permanently empty. Field-verified on a live router (rt3, +// 4 ethernet ports and 2 radios configured): filter by bridge -> 8 entries, +// 0 learned; per port -> 60 learned entries spread over its ports. Asking +// per port has a second advantage: the port of every entry is known by +// construction, with no index-to-name resolution in the loop. func probeL2ClientsImpl() *L2Probe { bridge := LANBridge() - ports := bridgePortList() + notApplicable := &L2Probe{Applicable: false, Bridge: bridge, Ports: []string{}, Clients: []L2Client{}} if bridge == "" { - return &L2Probe{Applicable: false, Bridge: bridge, Ports: ports, Clients: []L2Client{}} + return notApplicable } - link, err := netlink.LinkByName(bridge) - if err != nil { - return &L2Probe{Applicable: false, Bridge: bridge, Ports: ports, Clients: []L2Client{}} + ports := make([]string, 0, 4) + for p := range bridgePortsOf(bridge) { + ports = append(ports, p) } - neighs, err := netlink.NeighList(link.Attrs().Index, unix.AF_BRIDGE) - if err != nil { - return &L2Probe{Applicable: false, Bridge: bridge, Ports: ports, Clients: []L2Client{}} + sort.Strings(ports) + notApplicable.Ports = ports + if len(ports) == 0 { + return notApplicable } - entries := make([]l2Neigh, 0, len(neighs)) - for _, n := range neighs { - mac := strings.ToLower(n.HardwareAddr.String()) - if mac == "" { + + var entries []l2Neigh + readable := 0 + for _, name := range ports { + link, err := netlink.LinkByName(name) + if err != nil { + continue + } + neighs, err := netlink.NeighList(link.Attrs().Index, unix.AF_BRIDGE) + if err != nil { continue } - entries = append(entries, l2Neigh{ - mac: mac, - vlan: n.Vlan, - linkIdx: n.LinkIndex, - flags: n.Flags, - state: n.State, - used: n.Used, - }) + readable++ + own := portOwnMAC(name) + for _, n := range neighs { + mac := strings.ToLower(n.HardwareAddr.String()) + // A port also publishes its own address in the FDB (hostapd adds + // the BSSID MAC as a static entry, the WAN port does the same): + // that is the device talking about itself, never a client. + if mac == "" || mac == own { + continue + } + entries = append(entries, l2Neigh{ + mac: mac, + port: name, + vlan: n.Vlan, + flags: n.Flags, + state: n.State, + used: n.Used, + }) + } } - clients := assembleL2Clients(entries, portNameForIndex) - return &L2Probe{Applicable: true, Bridge: bridge, Ports: ports, Clients: clients} + if readable == 0 { + return notApplicable + } + return &L2Probe{Applicable: true, Bridge: bridge, Ports: ports, Clients: assembleL2Clients(entries)} } -// portNameForIndex resolves a bridge port index to its interface name, -// preferring netlink (which knows bridge ports by index) and falling back to -// the standard net interfaces table. -func portNameForIndex(idx int) string { - if l, err := netlink.LinkByIndex(idx); err == nil && l != nil { - return l.Attrs().Name - } - if ifc, err := net.InterfaceByIndex(idx); err == nil { - return ifc.Name +// portOwnMAC is the hardware address of a bridge port, lowercased, or "" when +// it cannot be read. +func portOwnMAC(name string) string { + data, err := os.ReadFile("/sys/class/net/" + name + "/address") + if err != nil { + return "" } - return "" + return strings.ToLower(strings.TrimSpace(string(data))) } // assembleL2Clients groups FDB entries by (MAC, port), accumulating the @@ -109,9 +138,9 @@ func portNameForIndex(idx int) string { // two rows: that is what loop detection looks for (same rationale as // parseBrctlShowmacs). A single (MAC, port) on several VLANs collapses into // one row instead of one row per VLAN (#520). -func assembleL2Clients(entries []l2Neigh, portOf func(int) string) []L2Client { - // Key is MAC + "\x00" + port; accumulate VLANs in insertion order and - // dedupe them when folding. +func assembleL2Clients(entries []l2Neigh) []L2Client { + // Key is MAC + "\x00" + port; accumulate VLANs and dedupe them when + // folding. type acc struct { client L2Client seen map[int]bool @@ -123,7 +152,7 @@ func assembleL2Clients(entries []l2Neigh, portOf func(int) string) []L2Client { if e.flags&unix.NTF_SELF != 0 { continue } - port := portOf(e.linkIdx) + port := e.port if port == "" { continue } @@ -134,7 +163,7 @@ func assembleL2Clients(entries []l2Neigh, portOf func(int) string) []L2Client { client: L2Client{ MAC: e.mac, Port: port, - Static: e.state == unix.NUD_NOARP, + Static: isStaticFdbState(e.state), Age: ageFromTicks(e.used), }, seen: map[int]bool{}, @@ -152,6 +181,7 @@ func assembleL2Clients(entries []l2Neigh, portOf func(int) string) []L2Client { for _, key := range order { g := groups[key] sort.Ints(g.client.Vlans) + g.client.Vlans = normalizeVlans(g.client.Vlans) g.client.Vendor, g.client.Random = vendorFor(g.client.MAC) clients = append(clients, g.client) } @@ -164,6 +194,31 @@ func assembleL2Clients(entries []l2Neigh, portOf func(int) string) []L2Client { return clients } +// isStaticFdbState reports whether an FDB entry was configured rather than +// learned. The bridge dump marks a static entry as NUD_NOARP, and hostapd's +// own-BSSID entries (and the WAN port's) come back as NUD_PERMANENT; a +// learned entry is REACHABLE or STALE. +func isStaticFdbState(state int) bool { + return state == unix.NUD_NOARP || state == unix.NUD_PERMANENT +} + +// normalizeVlans drops the unfiltered marker (VLAN 0) when the entry is also +// seen on real VLANs: a trunked device carrying VLANs 10 and 20 must read +// "10, 20", not "sin VLAN, 10, 20". A lone VLAN 0 is kept, and the UI shows +// it as "no VLAN filtering". +func normalizeVlans(vlans []int) []int { + if len(vlans) < 2 { + return vlans + } + out := make([]int, 0, len(vlans)) + for _, v := range vlans { + if v != 0 { + out = append(out, v) + } + } + return out +} + // ageFromTicks converts a netlink "used" clock-tick count into seconds. The // kernel expresses it in clock ticks; _SC_CLK_TCK is 100 on every OpenWrt // target NetGrip runs on, so divide by 100. Zero means "unknown age" and is diff --git a/internal/modules/l2clients_test.go b/internal/modules/l2clients_test.go index ed3284d..cf8d0ba 100644 --- a/internal/modules/l2clients_test.go +++ b/internal/modules/l2clients_test.go @@ -6,24 +6,12 @@ import ( "golang.org/x/sys/unix" ) -// portIndex maps the fixture link indexes used in the tests to names. -func fixedPorts(idx int) string { - switch idx { - case 1: - return "lan1" - case 2: - return "lan2" - default: - return "" - } -} - func TestAssembleL2ClientsExcludesSelf(t *testing.T) { entries := []l2Neigh{ - {mac: "aa:bb:cc:00:00:01", vlan: 1, linkIdx: 1}, - {mac: "02:00:00:00:00:22", vlan: 1, linkIdx: 1, flags: unix.NTF_SELF}, + {mac: "aa:bb:cc:00:00:01", port: "lan1", vlan: 1}, + {mac: "02:00:00:00:00:22", port: "lan1", vlan: 1, flags: unix.NTF_SELF}, } - clients := assembleL2Clients(entries, fixedPorts) + clients := assembleL2Clients(entries) if len(clients) != 1 { t.Fatalf("clients = %v, want only the non-self entry", clients) } @@ -36,12 +24,12 @@ func TestAssembleL2ClientsGroupsVlansByMACPort(t *testing.T) { // The same MAC on the same port across several VLANs must collapse to // one row with all VLANs, not one row per VLAN (#520). entries := []l2Neigh{ - {mac: "aa:bb:cc:00:00:01", vlan: 10, linkIdx: 1}, - {mac: "aa:bb:cc:00:00:01", vlan: 20, linkIdx: 1}, - {mac: "aa:bb:cc:00:00:01", vlan: 10, linkIdx: 1}, // duplicate - {mac: "aa:bb:cc:00:00:02", vlan: 10, linkIdx: 1}, + {mac: "aa:bb:cc:00:00:01", port: "lan1", vlan: 10}, + {mac: "aa:bb:cc:00:00:01", port: "lan1", vlan: 20}, + {mac: "aa:bb:cc:00:00:01", port: "lan1", vlan: 10}, // duplicate + {mac: "aa:bb:cc:00:00:02", port: "lan1", vlan: 10}, } - clients := assembleL2Clients(entries, fixedPorts) + clients := assembleL2Clients(entries) if len(clients) != 2 { t.Fatalf("clients = %d, want 2 rows: %v", len(clients), clients) } @@ -63,43 +51,50 @@ func TestAssembleL2ClientsKeepsMACOnTwoPorts(t *testing.T) { // A MAC learned on two ports stays as two rows: loop detection depends // on it (same rationale as parseBrctlShowmacs). entries := []l2Neigh{ - {mac: "aa:bb:cc:00:00:01", vlan: 0, linkIdx: 1}, - {mac: "aa:bb:cc:00:00:01", vlan: 0, linkIdx: 2}, + {mac: "aa:bb:cc:00:00:01", port: "lan1", vlan: 0}, + {mac: "aa:bb:cc:00:00:01", port: "lan2", vlan: 0}, } - clients := assembleL2Clients(entries, fixedPorts) + clients := assembleL2Clients(entries) if len(clients) != 2 { t.Fatalf("clients = %d, want 2 (one per port): %v", len(clients), clients) } } func TestAssembleL2ClientsStaticAndAge(t *testing.T) { + // A configured entry is reported as static: the kernel marks it NUD_NOARP + // or, for the entries hostapd and the WAN port install, NUD_PERMANENT (a + // real router dump uses the latter). Learned entries are REACHABLE/STALE. entries := []l2Neigh{ - {mac: "aa:bb:cc:00:00:01", vlan: 0, linkIdx: 1, state: unix.NUD_NOARP, used: 300}, - {mac: "aa:bb:cc:00:00:02", vlan: 0, linkIdx: 1, used: 0}, + {mac: "aa:bb:cc:00:00:01", port: "lan1", vlan: 0, state: unix.NUD_NOARP, used: 300}, + {mac: "aa:bb:cc:00:00:02", port: "lan1", vlan: 0, state: unix.NUD_PERMANENT, used: 0}, + {mac: "aa:bb:cc:00:00:03", port: "lan1", vlan: 0, state: unix.NUD_REACHABLE, used: 16609}, } - clients := assembleL2Clients(entries, fixedPorts) - if len(clients) != 2 { - t.Fatalf("clients = %d, want 2: %v", len(clients), clients) + clients := assembleL2Clients(entries) + if len(clients) != 3 { + t.Fatalf("clients = %d, want 3: %v", len(clients), clients) } byMAC := map[string]L2Client{} for _, c := range clients { byMAC[c.MAC] = c } if c := byMAC["aa:bb:cc:00:00:01"]; !c.Static || c.Age != 3 { - t.Errorf("static entry = %+v, want Static=true Age=3", c) + t.Errorf("NOARP entry = %+v, want Static=true Age=3", c) + } + if c := byMAC["aa:bb:cc:00:00:02"]; !c.Static || c.Age != 0 { + t.Errorf("PERMANENT entry = %+v, want Static=true Age=0", c) } - if c := byMAC["aa:bb:cc:00:00:02"]; c.Static || c.Age != 0 { - t.Errorf("unknown-age entry = %+v, want Static=false Age=0", c) + if c := byMAC["aa:bb:cc:00:00:03"]; c.Static || c.Age != 166 { + t.Errorf("learned entry = %+v, want Static=false Age=166", c) } } func TestAssembleL2ClientsStableOrder(t *testing.T) { entries := []l2Neigh{ - {mac: "cc:cc:cc:00:00:03", vlan: 0, linkIdx: 1}, - {mac: "aa:aa:aa:00:00:01", vlan: 0, linkIdx: 2}, - {mac: "bb:bb:bb:00:00:02", vlan: 0, linkIdx: 1}, + {mac: "cc:cc:cc:00:00:03", port: "lan1", vlan: 0}, + {mac: "aa:aa:aa:00:00:01", port: "lan2", vlan: 0}, + {mac: "bb:bb:bb:00:00:02", port: "lan1", vlan: 0}, } - clients := assembleL2Clients(entries, fixedPorts) + clients := assembleL2Clients(entries) want := []string{ "bb:bb:bb:00:00:02", // lan1 "cc:cc:cc:00:00:03", // lan1 @@ -112,12 +107,79 @@ func TestAssembleL2ClientsStableOrder(t *testing.T) { } } -func TestAssembleL2ClientsSkipsUnresolvedPort(t *testing.T) { +func TestAssembleL2ClientsSkipsEntryWithoutPort(t *testing.T) { entries := []l2Neigh{ - {mac: "aa:bb:cc:00:00:01", vlan: 0, linkIdx: 9}, + {mac: "aa:bb:cc:00:00:01", vlan: 0}, } - clients := assembleL2Clients(entries, fixedPorts) + clients := assembleL2Clients(entries) if len(clients) != 0 { - t.Fatalf("clients = %v, want none for an unresolvable port", clients) + t.Fatalf("clients = %v, want none for an entry with no port", clients) + } +} + +func TestNormalizeVlansDropsUnfilteredMarker(t *testing.T) { + cases := []struct { + name string + in []int + want []int + empty bool + }{ + {name: "solo 0 se conserva", in: []int{0}, want: []int{0}}, + {name: "sin vlan no cambia", in: []int{10, 20}, want: []int{10, 20}}, + {name: "0 junto a vlans reales se cae", in: []int{0, 10, 20}, want: []int{10, 20}}, + {name: "0 y 1 (caso real del router)", in: []int{0, 1}, want: []int{1}}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := normalizeVlans(tc.in) + if len(got) != len(tc.want) { + t.Fatalf("normalizeVlans(%v) = %v, want %v", tc.in, got, tc.want) + } + for i := range tc.want { + if got[i] != tc.want[i] { + t.Fatalf("normalizeVlans(%v) = %v, want %v", tc.in, got, tc.want) + } + } + }) + } +} + +// TestAssembleL2ClientsLiveRouterFixture usa una muestra real capturada con +// netlink en el router de casa (rt3, br-lan): entradas aprendidas en el +// puerto de bajada, un cliente wifi y una entrada estatica con marcador sin +// filtrar. Fija el aspecto que vera el usuario y protege el agrupado. +func TestAssembleL2ClientsLiveRouterFixture(t *testing.T) { + entries := []l2Neigh{ + // lan1: la bajada al resto de la casa, MACs aprendidas con edad. + {mac: "34:5f:45:5f:ab:7d", port: "lan1", vlan: 0, state: unix.NUD_REACHABLE, used: 16609}, + {mac: "5c:cf:7f:4e:f9:d5", port: "lan1", vlan: 0, state: unix.NUD_REACHABLE, used: 16615}, + {mac: "02:78:f4:02:8a:94", port: "lan1", vlan: 0, state: unix.NUD_REACHABLE, used: 127}, + // phy1-ap0: un cliente asociado a la radio de 2,4 GHz. + {mac: "d8:d6:68:32:7c:46", port: "phy1-ap0", vlan: 0, state: unix.NUD_REACHABLE, used: 275}, + // Entrada estatica del propio puerto WAN, vista en vlan 0 y vlan 1. + {mac: "28:d1:27:f5:99:f2", port: "wan", vlan: 0, state: unix.NUD_PERMANENT, used: 53713774}, + {mac: "28:d1:27:f5:99:f2", port: "wan", vlan: 1, state: unix.NUD_PERMANENT, used: 53713774}, + } + clients := assembleL2Clients(entries) + if len(clients) != 5 { + t.Fatalf("clients = %d, want 5 rows: %v", len(clients), clients) + } + byKey := map[string]L2Client{} + for _, c := range clients { + byKey[c.Port+"|"+c.MAC] = c + } + if c := byKey["lan1|34:5f:45:5f:ab:7d"]; c.Age != 166 || c.Static || len(c.Vlans) != 1 || c.Vlans[0] != 0 { + t.Errorf("cliente cableado = %+v, want Age=166 sin static y vlans [0]", c) + } + if c, ok := byKey["phy1-ap0|d8:d6:68:32:7c:46"]; !ok || c.Age != 2 { + t.Errorf("cliente wifi = %+v (presente=%v), want Age=2", c, ok) + } + // Esta MAC real lleva el bit locally administered: es un movil con MAC + // privada, y la vista debe marcarlo en vez de inventar un fabricante. + if c := byKey["lan1|02:78:f4:02:8a:94"]; !c.Random || c.Vendor != "" { + t.Errorf("MAC privada = %+v, want Random=true y sin fabricante", c) + } + if c := byKey["wan|28:d1:27:f5:99:f2"]; !c.Static || len(c.Vlans) != 1 || c.Vlans[0] != 1 { + t.Errorf("entrada estatica del WAN = %+v, want static y vlans [1] (sin el marcador 0)", c) } } From 36a39acaff1873f7acdd68cf51e61f710852a8f9 Mon Sep 17 00:00:00 2001 From: gnacho Date: Fri, 16 Oct 2026 21:38:45 +0200 Subject: [PATCH 3/3] fix(switch): the L2 age column read the wrong netlink field (#520) ndm_used only moves when an entry is hit in the forwarding path, so it reported ages of days for MACs that were seconds old. The kernel's ageing timer is ndm_updated (NDA_CACHEINFO), verified MAC by MAC against brctl showmacs on a live router: 57 dynamic entries, all within 0.03 s, the sampling interval. The column now reads that field, shows no age for a static entry (its timer is time since boot, not an age) and rounds a sub-second age up to one second so the freshest rows no longer read "unknown". The header is renamed to "Last seen", which is what the value means. --- app/src/locales/en.ts | 2 +- app/src/locales/es.ts | 2 +- internal/modules/l2clients.go | 68 +++++++++++++++++++++--------- internal/modules/l2clients_test.go | 52 +++++++++++++++++------ 4 files changed, 90 insertions(+), 34 deletions(-) diff --git a/app/src/locales/en.ts b/app/src/locales/en.ts index 3689513..ca85074 100644 --- a/app/src/locales/en.ts +++ b/app/src/locales/en.ts @@ -1291,7 +1291,7 @@ export default { vendor: "Vendor", port: "Port", vlans: "VLANs", - age: "Age", + age: "Last seen", static: "Static", }, empty: "No clients learned on the bridge yet", diff --git a/app/src/locales/es.ts b/app/src/locales/es.ts index 7523c0e..349cc10 100644 --- a/app/src/locales/es.ts +++ b/app/src/locales/es.ts @@ -1291,7 +1291,7 @@ export default { vendor: "Fabricante", port: "Puerto", vlans: "VLANs", - age: "Edad", + age: "Visto hace", static: "Estática", }, empty: "No hay clientes aprendidos en el bridge todavía", diff --git a/internal/modules/l2clients.go b/internal/modules/l2clients.go index cb6ccc5..8c87c73 100644 --- a/internal/modules/l2clients.go +++ b/internal/modules/l2clients.go @@ -40,12 +40,12 @@ type L2Probe struct { // bridge port the entry belongs to: the dump is issued per port, so the port // is known by construction instead of resolved from an interface index. type l2Neigh struct { - mac string - port string - vlan int - flags int - state int - used uint32 + mac string + port string + vlan int + flags int + state int + updated uint32 } // probeL2ClientsFn is the seam for tests; it defaults to the real read. @@ -107,12 +107,12 @@ func probeL2ClientsImpl() *L2Probe { continue } entries = append(entries, l2Neigh{ - mac: mac, - port: name, - vlan: n.Vlan, - flags: n.Flags, - state: n.State, - used: n.Used, + mac: mac, + port: name, + vlan: n.Vlan, + flags: n.Flags, + state: n.State, + updated: n.Updated, }) } } @@ -164,7 +164,7 @@ func assembleL2Clients(entries []l2Neigh) []L2Client { MAC: e.mac, Port: port, Static: isStaticFdbState(e.state), - Age: ageFromTicks(e.used), + Age: ageOf(e.state, e.updated), }, seen: map[int]bool{}, } @@ -219,13 +219,41 @@ func normalizeVlans(vlans []int) []int { return out } -// ageFromTicks converts a netlink "used" clock-tick count into seconds. The -// kernel expresses it in clock ticks; _SC_CLK_TCK is 100 on every OpenWrt -// target NetGrip runs on, so divide by 100. Zero means "unknown age" and is -// left as zero rather than guessed from another source. -func ageFromTicks(used uint32) int { - if used == 0 { +// ageFromTicks converts a netlink "updated" clock-tick count into seconds. +// The kernel expresses it in clock ticks; _SC_CLK_TCK is 100 on every OpenWrt +// target NetGrip runs on, so divide by 100. +// +// Two edges matter. Zero ticks means the kernel did not send NDA_CACHEINFO +// and the age is unknown: it is left as zero rather than guessed. And a +// sub-second age would also truncate to zero seconds, so the freshest rows +// (a device talking right now) would read "unknown": they round up to one +// second instead. +func ageFromTicks(updated uint32) int { + if updated == 0 { + return 0 + } + age := int(updated / 100) + if age == 0 { + age = 1 + } + return age +} + +// ageOf is the age shown for an entry, in seconds since the bridge last saw +// traffic from that MAC. +// +// The value comes from NDA_CACHEINFO's ndm_updated, which is the kernel's own +// ageing timer: the same number brctl prints, verified MAC by MAC against a +// live router (57 dynamic entries, all within 0.03 s, the sampling interval). +// It must NOT come from ndm_used, the neighbouring field, which only moves +// when the entry is used in the forwarding path and reported ages of days for +// entries that were seconds old. +// +// A configured entry never ages, and the kernel reports ndm_updated as time +// since boot for it: that is not an age, so those report none. +func ageOf(state int, updated uint32) int { + if isStaticFdbState(state) { return 0 } - return int(used / 100) + return ageFromTicks(updated) } diff --git a/internal/modules/l2clients_test.go b/internal/modules/l2clients_test.go index cf8d0ba..322b4ca 100644 --- a/internal/modules/l2clients_test.go +++ b/internal/modules/l2clients_test.go @@ -64,10 +64,13 @@ func TestAssembleL2ClientsStaticAndAge(t *testing.T) { // A configured entry is reported as static: the kernel marks it NUD_NOARP // or, for the entries hostapd and the WAN port install, NUD_PERMANENT (a // real router dump uses the latter). Learned entries are REACHABLE/STALE. + // + // Only a learned entry has an age: the kernel reports the ageing timer as + // time since boot for a configured one, which is not an age. entries := []l2Neigh{ - {mac: "aa:bb:cc:00:00:01", port: "lan1", vlan: 0, state: unix.NUD_NOARP, used: 300}, - {mac: "aa:bb:cc:00:00:02", port: "lan1", vlan: 0, state: unix.NUD_PERMANENT, used: 0}, - {mac: "aa:bb:cc:00:00:03", port: "lan1", vlan: 0, state: unix.NUD_REACHABLE, used: 16609}, + {mac: "aa:bb:cc:00:00:01", port: "lan1", vlan: 0, state: unix.NUD_NOARP, updated: 300}, + {mac: "aa:bb:cc:00:00:02", port: "lan1", vlan: 0, state: unix.NUD_PERMANENT, updated: 53713774}, + {mac: "aa:bb:cc:00:00:03", port: "lan1", vlan: 0, state: unix.NUD_REACHABLE, updated: 16609}, } clients := assembleL2Clients(entries) if len(clients) != 3 { @@ -77,17 +80,42 @@ func TestAssembleL2ClientsStaticAndAge(t *testing.T) { for _, c := range clients { byMAC[c.MAC] = c } - if c := byMAC["aa:bb:cc:00:00:01"]; !c.Static || c.Age != 3 { - t.Errorf("NOARP entry = %+v, want Static=true Age=3", c) + if c := byMAC["aa:bb:cc:00:00:01"]; !c.Static || c.Age != 0 { + t.Errorf("NOARP entry = %+v, want Static=true y sin edad", c) } if c := byMAC["aa:bb:cc:00:00:02"]; !c.Static || c.Age != 0 { - t.Errorf("PERMANENT entry = %+v, want Static=true Age=0", c) + t.Errorf("PERMANENT entry = %+v, want Static=true y sin edad", c) } if c := byMAC["aa:bb:cc:00:00:03"]; c.Static || c.Age != 166 { t.Errorf("learned entry = %+v, want Static=false Age=166", c) } } +// ageFromTicks traduce ticks del kernel a segundos con dos bordes fijados: +// 0 es "sin dato" (el kernel no mando NDA_CACHEINFO) y un valor sub-segundo +// no puede truncarse a 0 porque lo pintaria como desconocido cuando es justo +// lo contrario: el equipo esta hablando ahora mismo (caso real: el equipo que +// mas trafico genera salia "desconocido"). +func TestAgeFromTicksEdges(t *testing.T) { + cases := []struct { + ticks uint32 + want int + }{ + {0, 0}, // sin dato + {1, 1}, // sub-segundo: redondeo hacia arriba + {36, 1}, // caso real: 0,36 s + {99, 1}, // limite del redondeo + {100, 1}, // segundo exacto + {16609, 166}, // 166,09 s + {29999, 299}, // techo del envejecimiento (300 s) + } + for _, tc := range cases { + if got := ageFromTicks(tc.ticks); got != tc.want { + t.Errorf("ageFromTicks(%d) = %d, want %d", tc.ticks, got, tc.want) + } + } +} + func TestAssembleL2ClientsStableOrder(t *testing.T) { entries := []l2Neigh{ {mac: "cc:cc:cc:00:00:03", port: "lan1", vlan: 0}, @@ -151,14 +179,14 @@ func TestNormalizeVlansDropsUnfilteredMarker(t *testing.T) { func TestAssembleL2ClientsLiveRouterFixture(t *testing.T) { entries := []l2Neigh{ // lan1: la bajada al resto de la casa, MACs aprendidas con edad. - {mac: "34:5f:45:5f:ab:7d", port: "lan1", vlan: 0, state: unix.NUD_REACHABLE, used: 16609}, - {mac: "5c:cf:7f:4e:f9:d5", port: "lan1", vlan: 0, state: unix.NUD_REACHABLE, used: 16615}, - {mac: "02:78:f4:02:8a:94", port: "lan1", vlan: 0, state: unix.NUD_REACHABLE, used: 127}, + {mac: "34:5f:45:5f:ab:7d", port: "lan1", vlan: 0, state: unix.NUD_REACHABLE, updated: 16609}, + {mac: "5c:cf:7f:4e:f9:d5", port: "lan1", vlan: 0, state: unix.NUD_REACHABLE, updated: 16615}, + {mac: "02:78:f4:02:8a:94", port: "lan1", vlan: 0, state: unix.NUD_REACHABLE, updated: 127}, // phy1-ap0: un cliente asociado a la radio de 2,4 GHz. - {mac: "d8:d6:68:32:7c:46", port: "phy1-ap0", vlan: 0, state: unix.NUD_REACHABLE, used: 275}, + {mac: "d8:d6:68:32:7c:46", port: "phy1-ap0", vlan: 0, state: unix.NUD_REACHABLE, updated: 275}, // Entrada estatica del propio puerto WAN, vista en vlan 0 y vlan 1. - {mac: "28:d1:27:f5:99:f2", port: "wan", vlan: 0, state: unix.NUD_PERMANENT, used: 53713774}, - {mac: "28:d1:27:f5:99:f2", port: "wan", vlan: 1, state: unix.NUD_PERMANENT, used: 53713774}, + {mac: "28:d1:27:f5:99:f2", port: "wan", vlan: 0, state: unix.NUD_PERMANENT, updated: 53713774}, + {mac: "28:d1:27:f5:99:f2", port: "wan", vlan: 1, state: unix.NUD_PERMANENT, updated: 53713774}, } clients := assembleL2Clients(entries) if len(clients) != 5 {