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..ca85074 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: "Last seen", + 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..349cc10 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: "Visto hace", + 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..8c87c73 --- /dev/null +++ b/internal/modules/l2clients.go @@ -0,0 +1,259 @@ +package modules + +import ( + "os" + "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. 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 + port string + vlan int + flags int + state int + updated 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() +} + +// 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() + notApplicable := &L2Probe{Applicable: false, Bridge: bridge, Ports: []string{}, Clients: []L2Client{}} + if bridge == "" { + return notApplicable + } + ports := make([]string, 0, 4) + for p := range bridgePortsOf(bridge) { + ports = append(ports, p) + } + sort.Strings(ports) + notApplicable.Ports = ports + if len(ports) == 0 { + return notApplicable + } + + 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 + } + 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, + updated: n.Updated, + }) + } + } + if readable == 0 { + return notApplicable + } + return &L2Probe{Applicable: true, Bridge: bridge, Ports: ports, Clients: assembleL2Clients(entries)} +} + +// 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 strings.ToLower(strings.TrimSpace(string(data))) +} + +// 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) []L2Client { + // Key is MAC + "\x00" + port; accumulate VLANs 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 := e.port + if port == "" { + continue + } + key := e.mac + "\x00" + port + g, ok := groups[key] + if !ok { + g = &acc{ + client: L2Client{ + MAC: e.mac, + Port: port, + Static: isStaticFdbState(e.state), + Age: ageOf(e.state, e.updated), + }, + 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.Vlans = normalizeVlans(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 +} + +// 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 "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 ageFromTicks(updated) +} 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..322b4ca --- /dev/null +++ b/internal/modules/l2clients_test.go @@ -0,0 +1,213 @@ +package modules + +import ( + "testing" + + "golang.org/x/sys/unix" +) + +func TestAssembleL2ClientsExcludesSelf(t *testing.T) { + entries := []l2Neigh{ + {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) + 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", 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) + 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", port: "lan1", vlan: 0}, + {mac: "aa:bb:cc:00:00:01", port: "lan2", vlan: 0}, + } + 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. + // + // 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, 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 { + 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 != 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 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}, + {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) + 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 TestAssembleL2ClientsSkipsEntryWithoutPort(t *testing.T) { + entries := []l2Neigh{ + {mac: "aa:bb:cc:00:00:01", vlan: 0}, + } + clients := assembleL2Clients(entries) + if len(clients) != 0 { + 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, 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, 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, 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 { + 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) + } +} 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