Skip to content

Commit 53b001b

Browse files
authored
feat(agent): lldpd physical-port binding at install + bridge-only hint (#1369) (#1392)
On OpenWrt (verified qualcommax/ipq807x nss-dp; DSA likely affected too) an lldpd bound to the bridge (the stock br-lan config) reports zero neighbors forever: LLDP frames never reach a listener on the bridge master, and topology silently falls back to FDB inference. - install-agent.sh: when lldpd is already installed, ensure it listens on physical ports (lan*,wan via UCI) and restart it. Idempotent: a binding with physical ports is left untouched; lldpd itself is never installed by the script (installing packages stays the admin's call). - Agent probe: with an honest zero-neighbor round plus a bridge-only UCI binding, the payload carries lldp.hint=lldpd-bridge-only. UCI is only queried on empty rounds (no extra fork when neighbors exist). - Server propagates it as router.lldpHint; RouterDetail shows a warning banner with the exact fix (i18n es/en). - Docs: binding requirement next to the agent LLDP section. Closes #1369
1 parent 28f2ece commit 53b001b

12 files changed

Lines changed: 156 additions & 6 deletions

File tree

‎agent/probe/lldp.go‎

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,18 @@ const CmdLldpCheck = "command -v lldpcli"
2323
// CmdLldpNeighbors: vecinos LLDP en JSON (lldpd). Igual que la sonda SSH.
2424
const CmdLldpNeighbors = "lldpcli -f json show neighbors"
2525

26+
// CmdLldpdIfaces: binding de lldpd en OpenWrt (UCI). Vacío si no hay UCI
27+
// (Linux genérico) o la opción no existe (lldpd escucha en todas las
28+
// físicas por defecto). Solo se consulta cuando hay 0 vecinos (#1369).
29+
const CmdLldpdIfaces = "uci -q get lldpd.config.interface 2>/dev/null || true"
30+
31+
// HintLldpdBridgeOnly: valor de LldpData.Hint cuando lldpd está instalado,
32+
// reporta 0 vecinos y su binding UCI es SOLO a interfaces bridge (br-*):
33+
// en varios targets (qualcommax nss-dp verificado; DSA probable) los frames
34+
// LLDP nunca suben al listener del bridge master y lldpd no ve a nadie sin
35+
// avisar (#1369). La UI lo traduce a "configura lldpd en puertos físicos".
36+
const HintLldpdBridgeOnly = "lldpd-bridge-only"
37+
2638
// LldpData: sección lldp del payload del agente (#489).
2739
type LldpData struct {
2840
// Available: false cuando lldpd no está instalado en el equipo.
@@ -32,6 +44,27 @@ type LldpData struct {
3244
// SIN omitempty a propósito: hay que distinguir nil de vacío en el
3345
// wire format.
3446
Neighbors []LldpNeighbor `json:"neighbors"`
47+
// Hint: aviso de configuración subóptima detectada (p. ej.
48+
// HintLldpdBridgeOnly). Código estable, la UI lo traduce (i18n).
49+
Hint string `json:"hint,omitempty"`
50+
}
51+
52+
// LldpdBridgeOnly: true si el binding UCI de lldpd (CSV de patrones, p. ej.
53+
// "br-lan" o "lan*,wan") nombra SOLO interfaces bridge. Vacío (sin opción:
54+
// lldpd escucha en todas las físicas) o cualquier patrón físico → false.
55+
func LldpdBridgeOnly(ifaces string) bool {
56+
any := false
57+
for _, tok := range strings.Split(ifaces, ",") {
58+
tok = strings.TrimSpace(tok)
59+
if tok == "" {
60+
continue
61+
}
62+
any = true
63+
if !strings.HasPrefix(tok, "br-") {
64+
return false
65+
}
66+
}
67+
return any
3568
}
3669

3770
// LldpNeighbor: un vecino LLDP anunciado en un puerto local. Todos los

‎agent/probe/lldp_test.go‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -289,3 +289,26 @@ func TestPayloadLldpWire(t *testing.T) {
289289
t.Fatalf("wire vacío honesto: %s", s)
290290
}
291291
}
292+
293+
// #1369: el hint bridge-only sale solo cuando el binding UCI es SOLO bridge.
294+
func TestLldpdBridgeOnly(t *testing.T) {
295+
cases := []struct {
296+
ifaces string
297+
want bool
298+
}{
299+
{"", false}, // sin opción UCI: lldpd escucha en físicas
300+
{"br-lan", true}, // el caso del issue (stock qualcommax)
301+
{"br-lan,br-guest", true}, // varios bridges: igual de ciego
302+
{"lan*,wan", false}, // el fix aplicado en la flota
303+
{"lan1", false}, // puerto físico suelto
304+
{"br-lan,lan1", false}, // mixto: ya escucha en físicas
305+
{" lan1 , wan ", false}, // espacios tolerados
306+
{"eth0", false}, // Linux genérico con nombre eth
307+
{",,,", false}, // tokens vacíos: sin binding efectivo
308+
}
309+
for _, c := range cases {
310+
if got := LldpdBridgeOnly(c.ifaces); got != c.want {
311+
t.Errorf("LldpdBridgeOnly(%q) = %v, want %v", c.ifaces, got, c.want)
312+
}
313+
}
314+
}

‎agent/probe/local.go‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -630,7 +630,15 @@ func (p *Prober) probeLldp(ctx context.Context) *LldpData {
630630
if err != nil {
631631
return &LldpData{Available: true}
632632
}
633-
return &LldpData{Available: true, Neighbors: neighbors}
633+
d := &LldpData{Available: true, Neighbors: neighbors}
634+
// #1369: 0 vecinos honestos + binding SOLO a bridge (br-lan) = el caso
635+
// silencioso de OpenWrt (los frames LLDP no suben al bridge master en
636+
// nss-dp/DSA). Solo entonces consultamos UCI (un fork extra por ronda
637+
// cuando sí hay vecinos sería desperdicio).
638+
if len(neighbors) == 0 && LldpdBridgeOnly(p.runBest(ctx, CmdLldpdIfaces, 0)) {
639+
d.Hint = HintLldpdBridgeOnly
640+
}
641+
return d
634642
}
635643

636644
// probeClientBw: contadores por MAC de nlbwmon (#551). Contrato: Available=

‎app/public/locales/en/translation.json‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -861,6 +861,8 @@
861861
"hostKeyConfirm": "Confirm new key",
862862
"hostKeyBusy": "Confirming…",
863863
"hostKeyDone": "Key accepted: reconnecting…",
864+
"lldpBridgeOnlyTitle": "lldpd sees no neighbors: it listens on the bridge only",
865+
"lldpBridgeOnlyBody": "lldpd is installed but bound to br-lan, and on this hardware LLDP frames never reach the bridge master: topology loses its direct evidence silently. Fix it with: uci set lldpd.config.interface='lan*,wan' && uci commit lldpd && /etc/init.d/lldpd restart",
864866
"perf": {
865867
"chartAria": "{{label}} of {{name}}, range {{range}}. Current value {{value}} {{unit}}.",
866868
"maxTemp": "max {{temp}}",

‎app/public/locales/es/translation.json‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -861,6 +861,8 @@
861861
"hostKeyConfirm": "Confirmar nueva clave",
862862
"hostKeyBusy": "Confirmando…",
863863
"hostKeyDone": "Clave aceptada: reconectando…",
864+
"lldpBridgeOnlyTitle": "lldpd no ve vecinos: escucha solo en el bridge",
865+
"lldpBridgeOnlyBody": "lldpd está instalado pero configurado para escuchar en br-lan, y en este hardware los mensajes LLDP no llegan al bridge: la topología se queda sin evidencia directa. Arréglalo con: uci set lldpd.config.interface='lan*,wan' && uci commit lldpd && /etc/init.d/lldpd restart",
864866
"perf": {
865867
"chartAria": "{{label}} de {{name}}, rango {{range}}. Valor actual {{value}} {{unit}}.",
866868
"maxTemp": "máx {{temp}}",

‎app/src/data/types.ts‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,12 @@ export interface Router {
9191
* sondea (agent-only/external/SNMP): la tabla de enlaces pinta "—".
9292
*/
9393
latencyMs?: number
94+
/**
95+
* #1369: aviso de config subóptima del lldpd del router, reportado por el
96+
* agente. Código estable ("lldpd-bridge-only"); se traduce en la UI.
97+
* Ausente = sin aviso.
98+
*/
99+
lldpHint?: string
94100
}
95101

96102
export interface WanInfo {

‎app/src/pages/RouterDetail.tsx‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,25 @@ export default function RouterDetail() {
147147
</motion.div>
148148
)}
149149

150+
{/* #1369: lldpd escuchando solo en el bridge (br-lan) → 0 vecinos LLDP
151+
silencioso en nss-dp/DSA. El agente lo detecta y avisa aquí. */}
152+
{router.lldpHint === 'lldpd-bridge-only' && (
153+
<motion.div
154+
initial={reduce ? false : { opacity: 0, y: -8 }}
155+
animate={{ opacity: 1, y: 0 }}
156+
transition={{ duration: 0.3, ease: 'easeOut', delay: 0.38 }}
157+
className="lg:col-span-12"
158+
>
159+
<div role="alert" className="flex items-start gap-3 rounded-2xl border border-warn/50 bg-warn/10 px-5 py-4">
160+
<AlertTriangle className="mt-0.5 h-5 w-5 shrink-0 text-warn" strokeWidth={1.75} />
161+
<div className="min-w-0 flex-1">
162+
<div className="text-sm font-semibold text-warn">{t('routerDetail.lldpBridgeOnlyTitle')}</div>
163+
<p className="mt-0.5 text-sm text-text-secondary">{t('routerDetail.lldpBridgeOnlyBody')}</p>
164+
</div>
165+
</div>
166+
</motion.div>
167+
)}
168+
150169
{/* Banner contextual (Patio) */}
151170
{router.status === 'warn' && router.hotMetric === 'temp' && (
152171
<motion.div

‎docs/AGENTE-OPENWRT.md‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,30 @@ push, escalado y resultado), para poder seguir la recuperación sin mirar
117117
código. Los agentes NetGrip embebidos y los rearmes manuales desde la UI
118118
siguen el mismo camino y también quedan registrados.
119119

120+
## lldpd en puertos físicos (#1369)
121+
122+
La sonda LLDP del agente lee vecinos de `lldpd`. OJO con el binding: en
123+
varios targets (qualcommax nss-dp verificado; DSA probable) un lldpd bound
124+
al bridge (`lldpd.config.interface='br-lan'`, config típica tras un
125+
`opkg install lldpd` stock) reporta CERO vecinos para siempre - los frames
126+
LLDP (01:80:c2:00:00:0e) no llegan al listener del bridge master y no hay
127+
aviso en ningún sitio. La topología cae a inferencia FDB en silencio.
128+
129+
Fix: escuchar en los puertos físicos:
130+
131+
```sh
132+
uci set lldpd.config.interface='lan*,wan'
133+
uci commit lldpd
134+
/etc/init.d/lldpd restart
135+
```
136+
137+
`install-agent.sh` lo deja así automáticamente cuando lldpd ya está
138+
instalado (idempotente: un binding con puertos físicos no se toca; no
139+
instala lldpd por sí solo). Cuando el agente detecta el caso (0 vecinos
140+
honestos + binding solo bridge) lo reporta en el payload como
141+
`lldp.hint="lldpd-bridge-only"` y la UI muestra un aviso en la ficha del
142+
router. TX (anunciarse) funciona en ambas configs; solo RX está afectado.
143+
120144
## Qué NO es el agente
121145

122146
- No es obligatorio: NetPulse sigue funcionando 100% en Tier 0 (SSH) +

‎install-agent.sh‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -465,6 +465,28 @@ fi
465465
ssh $SSH_IDENT_ARGS "$SSH" "chmod 0755 $INIT_DST && $INIT_DST enable && $INIT_DST restart"
466466
ok "servicio $INIT_NAME habilitado y arrancado"
467467

468+
# ------------------------------------------------- lldpd en puertos físicos (#1369) --
469+
# lldpd bound al bridge (br-lan) no recibe vecinos en varios targets
470+
# (qualcommax nss-dp verificado; DSA probable): los frames LLDP nunca suben
471+
# al listener del bridge master y la topología queda sin evidencia directa
472+
# sin avisar. Si lldpd está instalado y su binding es SOLO bridge, lo pasamos
473+
# a los puertos físicos (patrón OpenWrt estándar). Idempotente: un binding
474+
# que ya incluye puertos físicos (lan*/wan/eth*) no se toca. NO instalamos
475+
# lldpd aquí: instalar paquetes es decisión del usuario (el agente detecta
476+
# lldpd solo si ya está).
477+
if ssh $SSH_IDENT_ARGS "$SSH" "command -v lldpcli >/dev/null 2>&1"; then
478+
LLDP_IF=$(ssh $SSH_IDENT_ARGS "$SSH" "uci -q get lldpd.config.interface 2>/dev/null")
479+
if printf '%s' "$LLDP_IF" | grep -qE '(^|,)[[:space:]]*(lan|wan|eth)'; then
480+
ok "lldpd ya escucha en puertos físicos (${LLDP_IF:-auto})"
481+
else
482+
ssh $SSH_IDENT_ARGS "$SSH" "uci set lldpd.config.interface='lan*,wan' && uci commit lldpd && /etc/init.d/lldpd restart >/dev/null 2>&1" \
483+
&& ok "lldpd reconfigurado: escucha en puertos físicos (lan*,wan)${LLDP_IF:+ - antes: $LLDP_IF}" \
484+
|| warn "no se pudo reconfigurar lldpd; a mano: uci set lldpd.config.interface='lan*,wan' && uci commit lldpd && /etc/init.d/lldpd restart"
485+
fi
486+
else
487+
info "lldpd no instalado: sin evidencia LLDP directa (con 'opkg install lldpd' la activas; reejecuta este instalador y lo deja en puertos físicos)"
488+
fi
489+
468490
# Watchdog cron: NO se instala (#851). Si el agente crashea en bucle, un
469491
# reinicio a ciegas cada 2 min no lo arregla y ensucia el log; la detección
470492
# efectiva es el dead-man del servidor (alerta) y el crash puntual lo cubre

‎server-go/internal/adapters/agent.go‎

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -735,7 +735,7 @@ func (l *Live) polledFromAgent(cfg RouterConfig, p *probe.Payload) *routerPolled
735735
// fallida (Available=true con Neighbors nil) o ausente (push
736736
// event-driven) recicla la última cacheada. Available=false explícito =
737737
// lldpd no instalado (hint de UI), y eso SÍ pisa lo cacheado.
738-
resolveLldp := func() (nbs []LldpNeighbor, unavailable bool) {
738+
resolveLldp := func() (nbs []LldpNeighbor, unavailable bool, hint string) {
739739
ld := p.Data.Lldp
740740
if ld == nil || (ld.Available && ld.Neighbors == nil) {
741741
ld = nil
@@ -744,14 +744,14 @@ func (l *Live) polledFromAgent(cfg RouterConfig, p *probe.Payload) *routerPolled
744744
}
745745
}
746746
if ld == nil {
747-
return nil, false
747+
return nil, false, ""
748748
}
749749
if !ld.Available {
750-
return nil, true
750+
return nil, true, ""
751751
}
752-
return lldpFromProbe(ld.Neighbors), false
752+
return lldpFromProbe(ld.Neighbors), false, ld.Hint
753753
}
754-
out.lldp, out.lldpUnavailable = resolveLldp()
754+
out.lldp, out.lldpUnavailable, out.lldpHint = resolveLldp()
755755
// ClientBw (#551): contadores nlbwmon por MAC. Anti-parpadeo idéntico a
756756
// LLDP: la sección buena del payload manda; una sección fallida
757757
// (Available=true con Hosts nil) o ausente (push event-driven) recicla

0 commit comments

Comments
 (0)