ADR 0044 — OOBI Intrusion Detection + Blacklist¶
- Status: Accepted (2026-05-14)
- Date: 2026-05-14
- Deciders: TLSStress.Art project (operator directive 2026-05-14 second message)
- Builds on: ADR 0038 (DSCP), ADR 0043 (OOBI VLAN),
pkg/oobi/auth/(Bearer-token L2) - Targets: v4.0.0
Context¶
ADR 0043 moved OOBI from a VXLAN overlay to a traditional 802.1Q VLAN
(ID 2777, 100.127.252.0/22). This brought clear simplification but
also collapsed the previous "topology-independent" guarantee: now any
L2-adjacent host on VLAN 2777 can send packets to any MÓDULO. The
attack surface expands by exactly one threat actor: an operator who
plugs a laptop into a Nexus port carrying VLAN 2777 and assigns
themselves an IP from the canonical /22.
The operator captured the requirement verbatim:
Precisamos garantir que os modulos/kernels/runtimes só aceitem comunicacao interna lateral vinda de outro modulos/kernels/runtimes conhecidos pelo nosso software. Se algum operador inserir inadvertidamente na rede OOBI um Laptop dentro da VLAN 2777 e configurar manualmente o endereço IP do seu laptop como sendo algum endereco da rede IP 100.127.252.0/22 (range IP definido por diretiva), mesmo assim, todos elementos modulos/kernels/runtimes não devem aceitar conectividade com esse laptop invasor. Ao tentar receber esse tipo de conexao, o modulos/kernels/runtimes que observou essa tentativa deve emitir um alerta (do tipo push/Webhook, syslog, ou alguma outra maneira) de tentativa de invasao deve ser gerado no Dashboard, no grafana e no Prometheus. O Mac Address de IP Address do laptop invasor deve ser registrado e automaticamente cadastrado em uma black list da rede OOBI.
Decision¶
Introduce pkg/oobi/intrusion/ — a new middleware that runs
BEFORE pkg/oobi/auth/ on every MÓDULO HTTP listener. The filter
enforces three invariants on every incoming connection:
-
Source IP inside
oobi.IPv4SubnetParent(100.127.252.0/22). Out-of-subnet ⇒out_of_subnetrejection. -
When the IP is in the slot
/24(100.127.252.0/24), the host octet must correspond to a slot thatpkg/oobi/canoncurrently allocates. Unknown slot ⇒unknown_slotrejection. (IPs in the parent /22 but outside the slot /24 are HPA pool / HA replicas / reserved — they pass this check and are policed at the K8s NAD level by PR-OOBI-VLAN-3.) -
Source IP not present in the shared blacklist. Blacklisted ⇒
blacklistedrejection.
Any failure produces an immediate HTTP 403 with no body (no
information leak to the attacker), captures the attacker's MAC via
/proc/net/arp, persists the entry in a local + shared blacklist,
and dispatches alerts to four destinations:
- Prometheus counter
oobi_intrusion_attempts_total{reason, detected_by}(cardinality-bounded — IP and MAC NOT in labels) - Dashboard webhook
POST /api/security/intrusion - Grafana panel via Prometheus scrape (lands in
dashboards/oobi-intrusion.json) - Syslog
LOG_ALERT+LOG_AUTHfor SIEM / customer SOC
Alerters are fire-and-forget goroutines — alert delivery NEVER blocks the request path.
Why a separate package, not extension of pkg/oobi/auth/¶
Two responsibilities, two libraries:
| Layer | Question | Implementation |
|---|---|---|
pkg/oobi/intrusion/ |
Is this peer topologically plausible? | Source IP filter, blacklist, MAC capture |
pkg/oobi/auth/ |
Did this peer prove identity? | Bearer-token timing-safe compare |
Diverging the two:
- Lets us count rejections separately (
oobi_intrusion_attempts_totalvs auth counters) - Returns distinct HTTP codes (403 vs 401) which Wireshark and Grafana panels can split on
- Cheap L3 rejection runs first; expensive Bearer crypto only on plausible peers
- Future enhancements (rate-limiting, geo-blocking, ML anomaly detection) belong in the intrusion package, not auth
Middleware order — locked¶
handler := intrusionM.Wrap(authM.Wrap(realHandler))
Reverse order would let the attacker exhaust auth crypto cycles
before being rejected. Order is enforced by convention in every
MÓDULO main.go (PR-OOBI-VLAN-3b wires it everywhere; subsequent
PRs include a go vet check or static-analysis rule).
Blacklist propagation¶
| Layer | Persistence |
|---|---|
| In-process | intrusion.Blacklist struct, atomic snapshot |
| Per-host | /var/run/oobi/blacklist.json, atomic rewrite on every Add |
| Cross-MÓDULO | Dashboard receives webhook → writes Postgres oobi_intrusion_blacklist table → updates ConfigMap oobi-blacklist → MÓDULOs reload via Refresh() every 30 s |
The 30 s reload window is the maximum time between "MÓDULO A detected attacker" and "MÓDULO B begins blocking the same attacker". For most threat models this is acceptable; the Dashboard can publish a SSE stream for sub-second propagation in PR-OOBI-VLAN-6 if needed.
Operator override path¶
The Dashboard exposes /admin/security/oobi-intrusion (lands in
PR-OOBI-VLAN-3c). The operator can:
- View the blacklist sorted by
last_seendesc - Remove an entry (creates audit log row in
audit_actionswithkind=intrusion_unblock,who=<operator>,why=<free-text>) - Export to CSV/JSON for offline SIEM ingestion
- Click through to the Grafana panel filtered by detected_by
Consequences¶
Positive¶
- Defense in depth: ZTP-prem layer-3 (network segmentation) is no longer the only barrier — an L2-pivoting attacker still has to guess a valid slot AND a valid Bearer token.
- Audit trail: every attempt is captured with IP + MAC + reason
- timestamp + which MÓDULO detected it. Forensics gain hard evidence even when the attack fails.
- SIEM integration: syslog ALERT priority is the standard hook; customer SOC tools (Splunk, QRadar, ELK) consume it out of the box.
- Bounded cardinality: Prometheus labels are
reason×detected_byonly. A brute-force probe can generate at most 3 × N_modules ≈ 111 unique label combinations.
Negative¶
- 30 s blacklist propagation window: an attacker hitting MÓDULO A then immediately MÓDULO B sees up to 30 s of "open door" on B before the ConfigMap refresh. Acceptable for v1; SSE / push propagation lands in PR-OOBI-VLAN-6 if needed.
- MAC capture is Linux-only: macOS / Windows fall back to empty MAC. Dashboard handles missing MAC gracefully (renders "—") but forensic value is reduced on non-Linux hosts.
- False positives on operator-managed peers: any host that legitimately speaks OOBI but is NOT in the slot allocation will be blocked. Examples: ad-hoc operator scripts, ansible runners. Those must use the same Bearer token + an allocated slot (operator signs in via Dashboard which proxies through a designated slot).
- No active L2 isolation: the filter rejects at L3; the laptop is still on the VLAN. Active port-shutdown via Nexus SNMP/NETCONF is a separate controller, out of scope for the application layer. PR-OOBI-VLAN-6+ may add an optional controller hook.
Implementation plan (3 PRs)¶
| PR | Scope | Hours | Status |
|---|---|---|---|
| PR-OOBI-VLAN-3a | pkg/oobi/intrusion/ library + 30+ unit tests + ADR + README |
~6 | ✅ #735 merged 2026-05-14 |
| PR-OOBI-VLAN-3b | Wire intrusionM.Wrap in every MÓDULO main.go (8 binaries) |
~3 | ✅ #736 merged 2026-05-14 |
| PR-OOBI-VLAN-3c | Dashboard /admin/security/oobi-intrusion page + POST /api/security/intrusion endpoint + Postgres table + Grafana dashboard JSON + Prometheus alert rules + runbook |
~8 | ✅ #896 merged 2026-05-18 |
Operator surface (post-PR-3c)¶
| Artifact | Path |
|---|---|
| Dashboard page | /admin/security/oobi-intrusion |
| Webhook endpoint | POST /api/security/intrusion (OOBI Bearer auth) |
| Admin list / remove | GET / DELETE /api/admin/security/oobi-intrusion |
| SIEM export | GET /api/admin/security/oobi-intrusion/export?format=json\|csv |
| Postgres table | oobi_intrusion_events (migration 0029) |
| Grafana dashboard | observability/grafana/dashboards/oobi-intrusion.json |
| Prometheus alerts | observability/prometheus/alerts/oobi-intrusion.yml (Burst / UnknownSlot / BlacklistGrowing / SilentMÓDULO) |
| Runbook | pkg/octopus/docs/05-operations/runbooks/oobi-intrusion.md (3 langs) |
References¶
- Memo:
project_oobi_intrusion_detection_2026_05_14.md - Memo:
project_oobi_no_vxlan_directive_2026_05_14.md - ADR 0038: Universal DSCP Marking Policy
- ADR 0043: OOBI VLAN (no VXLAN) Refactor
pkg/oobi/intrusion/sourcepkg/oobi/auth/source (complementary L2 layer)