import: contenido inicial de la skill unifi

This commit is contained in:
2026-04-26 14:25:15 -06:00
parent b0956e3d62
commit 63a1d58923
8 changed files with 772 additions and 1 deletions

32
.env.example Normal file
View File

@@ -0,0 +1,32 @@
# unifi skill — config local (NO versionar)
#
# Copiar a `.env` en este mismo directorio y completar.
# IP o hostname del UDM Pro (IP cruda recomendado: el cert es self-signed
# y el hostname interno depende de DNS que sirve el mismo UDM).
UNIFI_HOST=192.168.87.5
# ─── Integration API (read-only oficial) ─────────────────────────────────
# API Key generada en UniFi Network UI:
# Settings → Control Plane → Integrations → Create API Key
# Requiere UniFi Network >= v9.3.43.
# Solo se muestra UNA vez al crearla — guárdala bien.
UNIFI_API_KEY=
# UUID del site dentro del UDM (NO el nombre "default" — la Integration API exige UUID).
# Para descubrirlo: ./scripts/query.sh /sites → copiar el campo "id" del site que quieras.
# Si solo tenés un site (lo normal), el primero que aparece es el correcto.
UNIFI_SITE=
# ─── Classic API (login flow con session cookie + CSRF) ──────────────────
# Necesario para endpoints que la Integration API no expone: bytes por cliente,
# signal/SSID, eventos, alarmas, health WAN, DPI, configs (WLANs, firewall).
#
# La cuenta DEBE tener rol "Site View Only" en Network — eso es lo que
# garantiza read-only a nivel server. Sin View Only, esta cuenta podría
# escribir y romper cosas en producción.
UNIFI_USERNAME=
UNIFI_PASSWORD=
# Nombre del site para Classic API (NO el UUID — usa "default" o el que aplique).
UNIFI_SITE_NAME=default

10
.gitignore vendored Normal file
View File

@@ -0,0 +1,10 @@
# Local config con secrets — NUNCA committear
.env
# Cache local: cookies, csrf, session tokens
.cache/
# Temporales
*.tmp
*.bak
*.swp

View File

@@ -1,3 +1,45 @@
# skill-unifi
Skill local read-only de Claude Code para diagnosticar el UDM Pro del beneficio Rio Frio (192.168.87.5) via Integration API + Classic API. Solo accesible desde la LAN.
Skill local read-only de [Claude Code](https://claude.com/claude-code) para
diagnosticar el **UDM Pro** del beneficio Rio Frio (`192.168.87.5`) vía la
Integration API oficial de UniFi Network y la Classic API (cookie + CSRF).
> Mirror público del directorio local `~/.claude/skills/unifi/` en la PC del
> usuario humano. Existe para que el usuario pueda **auditar** lo que el bot
> hace contra el UDM. Clonar y correr en otra máquina **no funciona
> out-of-the-box** — requiere API key del UDM en `.env` (no incluido) y estar
> en la LAN del beneficio (192.168.87.0/24).
## Doc principal
Ver **[SKILL.md](SKILL.md)** — diseño, modelo de seguridad (read-only por
rol del bot + script guard), flujos típicos de diagnóstico.
Cheat sheet en **[endpoints.md](endpoints.md)** y mapa de
MACs/IPs → equipos en **[topology.md](topology.md)**.
## Estructura
```
.
├── SKILL.md ← docs canónicas
├── endpoints.md ← cheat sheet Integration + Classic API
├── topology.md ← mapa MAC/IP → equipo/facility
├── .env.example
├── .gitignore
├── README.md ← este archivo
└── scripts/
├── query.sh ← wrapper Integration API (X-API-KEY)
└── query-classic.sh ← wrapper Classic API (cookie + CSRF, cache 25 min)
```
## Lo que NO está en este repo
- `.env` (API key del UDM)
- `.cache/` (cookies de session de la Classic API)
## Dependencias
- `curl`
- `jq` (recomendado) o `python` para filtrar JSON
- LAN del beneficio (192.168.87.0/24) para alcanzar `192.168.87.5`

174
SKILL.md Normal file
View File

@@ -0,0 +1,174 @@
---
name: unifi
description: Diagnostica y consulta la red UniFi del beneficio Rio Frio (UDM Pro en 192.168.87.5) en lenguaje natural. Úsala cuando el usuario pregunte sobre dispositivos conectados, APs, switches, clientes WiFi, estado de la red local, si tal báscula/impresora/ESP32 está online, ancho de banda, MACs, IPs del LAN 192.168.87.0/24, o cualquier diagnóstico del UDM Pro. Ejemplos: "qué hay conectado al WiFi", "está online la impresora del patio", "qué AP tiene más clientes", "muéstrame los dispositivos del laboratorio", "qué IP tiene la báscula tolva_bodega", "cuántos APs tengo y están todos arriba", "quién está chupándose el internet", "qué pasó con la conexión hace un rato".
allowed-tools: Bash, Read, Grep
---
# UniFi UDM Pro — skill de diagnóstico local
## Qué es esto
Skill 100% local que consulta el **UDM Pro** del beneficio Rio Frio (`192.168.87.5`)
usando la **Integration API oficial** de UniFi Network (≥ v9.3.43, X-API-KEY stateless).
No hay servidor intermedio, no hay MCP server, no hay nada en el Swarm. Solo `curl`
desde tu PC contra el UDM Pro vía la LAN o a través del DNS interno.
- **Base URL**: `https://192.168.87.5/proxy/network/integration/v1`
- **Auth**: header `X-API-KEY: $UNIFI_API_KEY`
- **El API key vive en**: `~/.claude/skills/unifi/.env` (local, no versionado)
- **Scope**: read-only (Integration API write está en preview, no lo tocamos)
## Cómo invocarla correctamente
Antes de cualquier query:
1. Verifica que `~/.claude/skills/unifi/.env` exista. Si no, avísale al usuario y dale el comando para crearlo (ver "Setup inicial" más abajo).
2. Verifica conectividad básica con un `GET /sites`. Si falla con 401/403 → API key inválida o caducada. Si falla con conexión rechazada/timeout → no estás en la LAN del beneficio (recordá que `192.168.87.5` no es ruteable desde fuera).
## Dos APIs, dos scripts
| Script | API | Auth | Cuándo usar |
|---|---|---|---|
| `query.sh` | Integration API v1 | `X-API-KEY` (stateless) | devices, clients (sin bytes/signal), networks, vouchers, statistics/latest |
| `query-classic.sh` | Classic API | session cookie + CSRF (login flow, cache 25 min) | health, eventos, alarmas, **bytes por cliente**, **SSID/signal**, configs (WLANs, firewall, port forwards) |
### query.sh (Integration API)
```bash
~/.claude/skills/unifi/scripts/query.sh /sites
~/.claude/skills/unifi/scripts/query.sh "/sites/{site}/devices"
~/.claude/skills/unifi/scripts/query.sh "/sites/{site}/clients"
```
> El placeholder `{site}` se expande automáticamente al UUID del site (`$UNIFI_SITE`
> en `.env`). La Integration API exige UUID, NO el nombre `default`. **Siempre comillas
> dobles** alrededor del path para que la shell no interprete las llaves.
### query-classic.sh (Classic API — para todo lo que la Integration no expone)
```bash
~/.claude/skills/unifi/scripts/query-classic.sh /stat/health # WAN/WLAN/LAN/UDM stats
~/.claude/skills/unifi/scripts/query-classic.sh /stat/sta # clientes CON bytes/signal/SSID
~/.claude/skills/unifi/scripts/query-classic.sh /stat/event # eventos (login, roaming, etc)
~/.claude/skills/unifi/scripts/query-classic.sh /stat/alarm # alarmas
~/.claude/skills/unifi/scripts/query-classic.sh /list/wlanconf # WLANs / SSIDs
~/.claude/skills/unifi/scripts/query-classic.sh /rest/firewallrule
```
El path corto (`/stat/...`) se prefija a `/proxy/network/api/s/$UNIFI_SITE_NAME` automáticamente.
La sesión se cachea 25 min en `.cache/cookies.txt` + `.cache/csrf` (chmod 600).
## Read-only por diseño — modelo de seguridad
Tres capas, en orden de fuerza:
1. **Server-side (la que importa)**: la cuenta `claudecode0` tiene rol **"Site View Only"** en
Network. El UDM responde **403** a cualquier POST/PUT/DELETE sin importar qué cliente
intente, vía API key o via Classic. Esto es lo que te garantiza no romper nada.
2. **Guard en los scripts**: `query.sh` y `query-classic.sh` rechazan flags
`-X POST/PUT/DELETE/PATCH` y `-d/--data` con exit 3 antes de salir a la red.
Defensa en profundidad — falla rápido con error claro.
3. **Doc**: esta sección. Si Claude (yo o cualquier futura sesión) lee esto y aún
así intenta escribir, el server lo bloqueará igual.
> ⚠️ La API Key del `.env` la generó el Owner del UDM (no `claudecode0`, porque
> View Only no puede crear keys). Técnicamente esa key tiene scope completo —
> el guard del script y la convención son lo único que evitan que alguien la
> use para escribir vía Integration API. Si querés blindaje total ahí también,
> pedile al Owner que regenere una key suya y revoque la actual.
## Si no tenés `jq`
Los ejemplos de `endpoints.md` usan `jq` por brevedad. Si no está instalado
(ej. git bash en Windows sin extras), tenés dos opciones:
1. **Instalar jq** (recomendado, una vez):
```bash
winget install jqlang.jq
# o: choco install jq
```
2. **Usar Python como filtro** (ya viene con la PC):
```bash
~/.claude/skills/unifi/scripts/query.sh "/sites/{site}/devices" \
| python -c "import json,sys; d=json.load(sys.stdin); [print(x['name'], x['state']) for x in d['data']]"
```
## Flujos típicos de diagnóstico
### "¿qué está conectado a la red?"
```bash
~/.claude/skills/unifi/scripts/query.sh /sites/{site}/clients | jq '.data | length'
~/.claude/skills/unifi/scripts/query.sh /sites/{site}/clients | jq '.data[] | {name, mac, ip, type, uplinkDeviceId, lastSeen}'
```
Filtra por `type` (`WIRED` vs `WIRELESS`) y por `uplinkDeviceId` para saber a qué AP/switch está pegado cada uno.
### "¿están todos los APs online?"
```bash
~/.claude/skills/unifi/scripts/query.sh /sites/{site}/devices | jq '.data[] | {name, model, state, ip, mac, uptime}'
```
`state`: `ONLINE | OFFLINE | PROVISIONING | UPGRADING | ...`. Reporta cualquier device con state ≠ ONLINE.
### "¿la báscula `sacos_bodega` (o impresora `patio`, etc.) está conectada?"
1. Busca el equipo en `topology.md` (mapeo nombre → MAC).
2. Si no está mapeado todavía: lista clientes y proponé al usuario el match más probable basándote en el nombre o en la IP fija conocida (las impresoras del manifest tienen IP fija: `printerCentral` cubre `192.168.87.142, .147, .150, .220, .221`).
3. Una vez tengas el MAC: `query.sh /sites/{site}/clients | jq '.data[] | select(.mac == "AA:BB:...")'`
4. Si lo encontrás, agregá el mapeo a `topology.md` para futuras queries.
### "¿qué AP cubre tal facility?"
Combiná `topology.md` (zonas físicas → AP esperado) + el listado real de `devices` filtrado por nombre/ubicación que tengan configurado en el UDM.
### "¿qué carga tiene cada AP / cuál está saturado?"
La Integration API NO devuelve bytes ni count de clientes en `/clients` ni en `/devices`.
Lo que SÍ tenés es `statistics/latest` por device, con CPU%, RAM%, uplink tx/rxBps y
% de retransmisiones por radio (indicador de saturación inalámbrica):
```bash
~/.claude/skills/unifi/scripts/query.sh "/sites/{site}/devices/<deviceId>/statistics/latest"
```
Para ranking de clientes por bytes consumidos NO hay endpoint — necesita Classic API.
### "muéstrame el estado del WAN / uplink"
**No está en la Integration API.** Por device sí podés ver `uplink.txRateBps/rxRateBps`
en `statistics/latest`, pero el health agregado del WAN (latencia, drops, ISP status)
solo está en Classic API. Si el usuario insiste, avisale que toca extender la skill
con `query-classic.sh` (login flow). No lo improvises sin avisar.
## Setup inicial (lo hace el usuario UNA vez)
1. **Generar API Key en el UDM Pro UI:**
- Abrí `https://192.168.87.5` en el navegador (o el dominio interno si lo tenés mapeado).
- Loggéate con admin local (NO cuenta cloud).
- UniFi Network → Settings → **Control Plane** → **Integrations**.
- "Create API Key", copiá el valor (solo se muestra una vez).
2. **Crear el `.env`:**
```bash
cp ~/.claude/skills/unifi/.env.example ~/.claude/skills/unifi/.env
# Editar y pegar el API key:
# UNIFI_API_KEY=<el valor que copiaste>
```
3. **Probar:**
```bash
~/.claude/skills/unifi/scripts/query.sh /sites
```
Esperado: JSON con un array `data` que contenga al menos `default`.
## Notas sobre red
- El UDM Pro está SOLO accesible desde la LAN (192.168.87.0/24). Si vas a usar
esta skill desde fuera del beneficio, necesitás VPN/Tailscale o un túnel.
- El cert TLS del UDM Pro es self-signed → el helper usa `curl -k`. Es aceptable
para tráfico en la LAN, pero no copies ese patrón a apps en producción.
- DNS interno (`*.interno`) lo sirve este mismo UDM. Si la skill empieza a fallar
en resolución, probá apuntar a la IP cruda `192.168.87.5`.
## Qué NO hace esta skill
- No escribe (no bloquea/desbloquea clientes, no provisiona VLANs, no reinicia APs). Read-only por diseño.
- No habla con UniFi Protect, Access ni Talk. Solo Network.
- No mantiene cache local. Cada query golpea el UDM Pro.
- No expone MCP — es Bash + curl. Si más adelante querés exponerla a Claude Desktop o a la `agentUI`, ahí sí justifica un sidecar Bun en el Swarm.
## Referencias
- `endpoints.md` — cheat sheet de los 15 endpoints Integration API v1 + algunos Classic útiles
- `topology.md` — mapa de MACs/IPs → equipo del beneficio (jala del manifest, se llena con uso)
- Doc oficial: https://developer.ui.com/ (Integration API)

190
endpoints.md Normal file
View File

@@ -0,0 +1,190 @@
# UDM Pro — referencia rápida de endpoints
Base URL: `https://192.168.87.5/proxy/network/integration/v1`
Auth: header `X-API-KEY: $UNIFI_API_KEY` en TODAS las requests.
Todas las respuestas vienen como `{ "offset", "limit", "count", "totalCount", "data": [...] }`.
## Sites
| Método | Ruta | Devuelve |
|---|---|---|
| GET | `/sites` | Lista de sites del controller. Para nosotros casi siempre `default`. |
```bash
query.sh /sites
```
## Devices (APs, switches, gateway)
| Método | Ruta | Devuelve |
|---|---|---|
| GET | `/sites/{site}/devices` | Todos los devices UniFi adoptados (APs, switches, UDM). |
| GET | `/sites/{site}/devices/{deviceId}` | Detalle de un device. |
| GET | `/sites/{site}/devices/{deviceId}/statistics/latest` | Stats actuales: uptime, CPU/RAM, load, uplink tx/rx Bps, % retries por radio. |
| POST | `/sites/{site}/devices/{deviceId}/actions` | Acciones (RESTART, etc). **No usar — la skill es read-only.** |
Campos REALES del device (verificado contra el UDM):
- `id`, `name`, `model`, `macAddress`, `ipAddress`
- `state`: `ONLINE | OFFLINE | PROVISIONING | UPGRADING | ADOPTING | ...`
- `firmwareVersion`, `firmwareUpdatable`
- `features`: lista (`accessPoint`, `switching`, `gateway`, ...)
- `interfaces`: lista de interfaces que el device expone (`radios`, `ports`, ...)
- `supported`: bool
> ⚠️ **No incluye**: `uptime` (está en `statistics/latest`), count de clientes conectados,
> temperatura, info por puerto del switch, MAC del WAN. Para esos datos → Classic API.
```bash
query.sh "/sites/{site}/devices" | jq '.data[] | {name, model, state, ipAddress, macAddress}'
```
Campos REALES de `/devices/{id}/statistics/latest` (verificado):
- `uptimeSec`, `lastHeartbeatAt`, `nextHeartbeatAt`
- `loadAverage1Min/5Min/15Min`
- `cpuUtilizationPct`, `memoryUtilizationPct`
- `uplink.txRateBps`, `uplink.rxRateBps`
- `interfaces.radios[]`: `frequencyGHz`, `txRetriesPct` (>10% = saturación inalámbrica)
## Clients (lo conectado a tu red)
| Método | Ruta | Devuelve |
|---|---|---|
| GET | `/sites/{site}/clients` | Clientes activos (wired + wireless). Soporta `?offset=&limit=`. |
| GET | `/sites/{site}/clients/{clientId}` | Detalle de un cliente. |
Campos REALES del cliente (verificado contra el UDM):
- `id`, `name` (alias asignado en la UI; **NO viene `hostname` ni el name del DHCP**)
- `macAddress`, `ipAddress`
- `type`: `WIRED | WIRELESS`
- `connectedAt` (ISO timestamp)
- `uplinkDeviceId` (id del AP/switch al que está pegado)
- `access.type`: `DEFAULT | ...`
> ⚠️ **NO incluye**: `txBytes`/`rxBytes` (bytes consumidos), `signal`/RSSI, `rxRate`/`txRate`,
> `wlanName`/SSID, `lastSeen`, `hostname`. Para esos datos → Classic API (`/stat/sta`).
```bash
# Buscar por MAC (case-insensitive)
query.sh "/sites/{site}/clients" | jq --arg m "AA:BB:CC:DD:EE:FF" '.data[] | select((.macAddress // "") | ascii_upcase == ($m | ascii_upcase))'
# Buscar por IP
query.sh "/sites/{site}/clients" | jq '.data[] | select(.ipAddress == "192.168.87.142")'
# Saber a qué AP/switch está pegado un cliente (resolviendo el uplinkDeviceId)
query.sh "/sites/{site}/clients" | jq '.data[] | {name, ip: .ipAddress, uplinkDeviceId}'
query.sh "/sites/{site}/devices" | jq '.data[] | {id, name}' # mapeo manual
```
## Networks (VLANs / redes definidas en el UDM)
| Método | Ruta | Devuelve |
|---|---|---|
| GET | `/sites/{site}/networks` | Lista de networks configuradas (VLANs). |
Campos: `id`, `name`, `vlanId`, `enabled`, `default`, `management` (`GATEWAY`, `WIFI`, ...),
`zoneId`, `metadata.origin` (`SYSTEM_DEFINED | USER_DEFINED`).
> ⚠️ **NO** devuelve subnet, DHCP range, gateway IP, ni reglas de firewall — solo metadatos.
## Hotspot vouchers
| Método | Ruta |
|---|---|
| GET | `/sites/{site}/hotspot/vouchers` |
> Irrelevante para nosotros — el portal cautivo lo maneja `radiusNucleo` con FreeRADIUS,
> no el hotspot nativo del UDM.
## Info global
| Método | Ruta | Devuelve |
|---|---|---|
| GET | `/info` | `{ applicationVersion }` — versión de UniFi Network App. |
## Paginación
Todos los listados aceptan `?offset=N&limit=M` (default offset=0, limit=25, max 200).
Si `count < totalCount`, hay más páginas:
```bash
query.sh "/sites/{site}/clients?limit=200&offset=0"
query.sh "/sites/{site}/clients?limit=200&offset=200"
```
## Classic API (vía `query-classic.sh`)
Login flow stateful (cookie + CSRF cacheados 25 min). Path corto se prefija a
`/proxy/network/api/s/$UNIFI_SITE_NAME`. Site name = `default` (no UUID).
Respuesta envuelta en `{ "meta": { "rc": "ok" }, "data": [...] }` (vs Integration que
usa `{ "data": [...], "totalCount": N }`).
### Estado del sistema
| Path corto | Devuelve |
|---|---|
| `/stat/health` | Health por subsistema: `wlan`, `wan`, `www`, `lan`, `vpn`. Status `ok/warning/error`, counts, ISP, throughput, uptime. **Lo más útil para diagnóstico rápido.** |
| `/stat/sysinfo` | Info del controller (versión, build, hostname, license). |
| `/stat/dashboard` | Resumen agregado (similar al dashboard de la UI). |
### Clientes (con campos que la Integration NO devuelve)
| Path corto | Devuelve |
|---|---|
| `/stat/sta` | Clientes activos con `tx_bytes`, `rx_bytes`, `signal`, `rssi`, `essid`, `radio`, `radio_proto`, `tx_rate`, `rx_rate`, `hostname`, `uptime`, `last_seen`. |
| `/rest/user` | TODOS los users (incluso offline), con `note`, `usergroup_id`, etc. |
| `/stat/user/<MAC>` | Detalle de un user específico por MAC. |
```bash
# Top 10 consumidores ahora mismo
query-classic.sh /stat/sta | python -c "
import json, sys
d = json.load(sys.stdin)['data']
top = sorted(d, key=lambda c: -(c.get('tx_bytes',0)+c.get('rx_bytes',0)))[:10]
for c in top:
print(c.get('hostname','?'), c.get('mac'), c.get('tx_bytes',0)+c.get('rx_bytes',0))
"
```
### Eventos y alarmas
| Path corto | Devuelve |
|---|---|
| `/stat/event` | Eventos del controller (login, roaming, AP up/down, etc). Acepta `?within=24` (horas) y `?_limit=N`. |
| `/stat/alarm` | Alarmas activas. |
| `/list/alarm` | Histórico de alarmas. |
### Configs (read-only via View Only)
| Path corto | Devuelve |
|---|---|
| `/list/wlanconf` o `/rest/wlanconf` | SSIDs definidas (nombre, banda, security, vlan). |
| `/rest/networkconf` | Networks/VLANs (subnet, DHCP range, gateway). |
| `/rest/firewallrule` | Reglas de firewall. |
| `/rest/portforward` | Port forwards. |
| `/rest/setting/<key>` | Settings del site. |
### DPI / App usage
| Path corto | Devuelve |
|---|---|
| `/stat/stadpi` | DPI por cliente (qué apps consumen, cuántos bytes). |
| `/stat/sitedpi` | DPI agregado del site. |
### Devices con stats completos
| Path corto | Devuelve |
|---|---|
| `/stat/device` | Devices con stats embebidos (CPU, mem, **temperatura**, throughput por puerto, **count de clientes** por AP, etc). Mucho más rico que `/sites/{site}/devices` de Integration. |
| `/stat/device/<MAC>` | Detalle de un device. |
## Errores comunes
| HTTP | Causa probable |
|---|---|
| 401 | API Key inválida o caducada. Regenerá. |
| 403 | El admin que generó la key no tiene permiso sobre ese site. |
| 404 | Path mal escrito o site/device/client no existe. |
| 5xx + cuerpo HTML | Le pegaste a UniFi OS root, no a `/proxy/network/integration/v1`. |
| connection refused / timeout | No estás en la LAN o el UDM está caído. |

139
scripts/query-classic.sh Normal file
View File

@@ -0,0 +1,139 @@
#!/usr/bin/env bash
# UniFi UDM Pro — Classic API helper (login flow con session cookie + CSRF).
#
# Usa user/pass de un admin con rol "Site View Only" para autenticarse vía
# POST /api/auth/login y cachea la sesión por 25 min en .cache/. La protección
# read-only REAL la da ese rol en el UDM, no este script.
#
# Uso:
# query-classic.sh <path> [curl args...]
#
# El <path> puede ser:
# - Path corto: /stat/health → /proxy/network/api/s/<UNIFI_SITE_NAME>/stat/health
# - Path /proxy o /api: se usa tal cual
# - URL completa: se usa tal cual
set -euo pipefail
SKILL_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
ENV_FILE="$SKILL_DIR/.env"
CACHE_DIR="$SKILL_DIR/.cache"
COOKIE_FILE="$CACHE_DIR/cookies.txt"
CSRF_FILE="$CACHE_DIR/csrf"
SESSION_TTL_SEC=1500 # 25 min — UniFi OS sessions duran más, pero margen de seguridad
if [[ ! -f "$ENV_FILE" ]]; then
echo "ERROR: $ENV_FILE no existe." >&2
exit 1
fi
set -a
# shellcheck disable=SC1090
source "$ENV_FILE"
set +a
: "${UNIFI_HOST:?UNIFI_HOST no definido en .env}"
: "${UNIFI_USERNAME:?UNIFI_USERNAME no definido en .env (necesario para Classic API)}"
: "${UNIFI_PASSWORD:?UNIFI_PASSWORD no definido en .env}"
UNIFI_SITE_NAME="${UNIFI_SITE_NAME:-default}"
PATH_ARG="${1:-}"
shift || true
if [[ -z "$PATH_ARG" ]]; then
cat >&2 <<EOF
Uso: query-classic.sh <path> [curl args...]
Ejemplos:
query-classic.sh /stat/health # health WAN/uplink
query-classic.sh /stat/sysinfo # info del controller
query-classic.sh /stat/sta # clientes con bytes/signal/SSID
query-classic.sh /stat/event # eventos recientes
query-classic.sh /stat/alarm # alarmas
query-classic.sh /list/wlanconf # WLANs / SSIDs
query-classic.sh /rest/firewallrule # firewall rules
query-classic.sh /rest/portforward # port forwards
EOF
exit 2
fi
# ──────────────────────────────────────────────────────────────────────────
# READ-ONLY GUARD — espejo del de query.sh. Defensa en profundidad.
# La protección real es el rol "Site View Only" del admin (server-side 403).
# ──────────────────────────────────────────────────────────────────────────
for arg in "$@"; do
case "$arg" in
-X|--request|-XPOST|-XPUT|-XDELETE|-XPATCH)
echo "ERROR: la skill 'unifi' es read-only. -X/--request bloqueado." >&2
exit 3
;;
-d|--data|--data-raw|--data-binary|--data-urlencode|-T|--upload-file)
echo "ERROR: la skill 'unifi' es read-only. -d/--data/-T bloqueado." >&2
exit 3
;;
esac
done
mkdir -p "$CACHE_DIR"
chmod 700 "$CACHE_DIR" 2>/dev/null || true
# ¿Sesión cacheada y fresca?
needs_login=true
if [[ -f "$COOKIE_FILE" && -f "$CSRF_FILE" && -s "$COOKIE_FILE" && -s "$CSRF_FILE" ]]; then
AGE=$(python -c "import os,time; print(int(time.time() - os.path.getmtime('$COOKIE_FILE')))" 2>/dev/null || echo 99999)
if [[ "$AGE" -lt "$SESSION_TTL_SEC" ]]; then
needs_login=false
fi
fi
if $needs_login; then
rm -f "$COOKIE_FILE" "$CSRF_FILE"
# Construimos el body con Python para evitar problemas de escaping con caracteres especiales en el password.
LOGIN_BODY=$(python -c "import json,os; print(json.dumps({'username':os.environ['UNIFI_USERNAME'],'password':os.environ['UNIFI_PASSWORD'],'rememberMe':False}))")
HEADERS_TMP=$(mktemp)
HTTP_CODE=$(curl -sS -k -o /dev/null -D "$HEADERS_TMP" -w "%{http_code}" \
-c "$COOKIE_FILE" \
-X POST \
-H "Content-Type: application/json" \
-H "Accept: application/json" \
-d "$LOGIN_BODY" \
"https://${UNIFI_HOST}/api/auth/login")
if [[ "$HTTP_CODE" != "200" ]]; then
echo "ERROR: login falló (HTTP $HTTP_CODE)" >&2
cat "$HEADERS_TMP" >&2
rm -f "$HEADERS_TMP" "$COOKIE_FILE"
exit 1
fi
CSRF=$(grep -i "^x-csrf-token:" "$HEADERS_TMP" | tail -1 | sed 's/^[^:]*:[[:space:]]*//' | tr -d '\r\n')
rm -f "$HEADERS_TMP"
if [[ -z "$CSRF" ]]; then
echo "ERROR: login OK pero no se recibió X-CSRF-Token." >&2
rm -f "$COOKIE_FILE"
exit 1
fi
printf '%s' "$CSRF" > "$CSRF_FILE"
chmod 600 "$COOKIE_FILE" "$CSRF_FILE" 2>/dev/null || true
fi
CSRF=$(cat "$CSRF_FILE")
# Build URL
if [[ "$PATH_ARG" =~ ^https?:// ]]; then
URL="$PATH_ARG"
elif [[ "$PATH_ARG" == /proxy/* || "$PATH_ARG" == /api/* ]]; then
URL="https://${UNIFI_HOST}${PATH_ARG}"
else
URL="https://${UNIFI_HOST}/proxy/network/api/s/${UNIFI_SITE_NAME}${PATH_ARG}"
fi
exec curl -sS -k \
-b "$COOKIE_FILE" \
-H "X-CSRF-Token: ${CSRF}" \
-H "Accept: application/json" \
"$@" \
"$URL"

107
scripts/query.sh Normal file
View File

@@ -0,0 +1,107 @@
#!/usr/bin/env bash
# UniFi UDM Pro — Integration API helper.
#
# Uso:
# query.sh <path> [curl args...]
#
# El <path> puede ser:
# - Path corto: /sites → se prefija /proxy/network/integration/v1
# - Path /proxy: /proxy/network/... → se usa tal cual (sirve para Classic API)
# - URL completa: https://... → se usa tal cual
set -euo pipefail
SKILL_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
ENV_FILE="$SKILL_DIR/.env"
if [[ ! -f "$ENV_FILE" ]]; then
echo "ERROR: $ENV_FILE no existe." >&2
echo " cp $SKILL_DIR/.env.example $ENV_FILE y completá UNIFI_API_KEY." >&2
exit 1
fi
set -a
# shellcheck disable=SC1090
source "$ENV_FILE"
set +a
: "${UNIFI_HOST:?UNIFI_HOST no definido en .env}"
: "${UNIFI_API_KEY:?UNIFI_API_KEY no definido en .env}"
: "${UNIFI_SITE:?UNIFI_SITE no definido en .env (debe ser el UUID del site, no el nombre)}"
PATH_ARG="${1:-}"
shift || true
if [[ -z "$PATH_ARG" ]]; then
cat >&2 <<EOF
Uso: query.sh <path> [curl args...]
Ejemplos:
query.sh /sites
query.sh /sites/{site}/devices
query.sh /sites/{site}/clients
query.sh /sites/{site}/devices/<deviceId>/statistics/latest
El placeholder {site} se reemplaza por \$UNIFI_SITE (UUID del site, en .env).
Para Classic API usar query-classic.sh (login flow distinto).
EOF
exit 2
fi
# ──────────────────────────────────────────────────────────────────────────
# READ-ONLY GUARD — la skill es read-only por diseño. Bloqueamos cualquier
# intento de cambiar el verbo HTTP o mandar body. La protección REAL la da
# el rol "Site View Only" del admin en el UDM (que también devuelve 403);
# este guard es defensa en profundidad para fallar rápido sin salir a la red.
# ──────────────────────────────────────────────────────────────────────────
for arg in "$@"; do
case "$arg" in
-X|--request|-XPOST|-XPUT|-XDELETE|-XPATCH)
echo "ERROR: la skill 'unifi' es read-only. -X/--request bloqueado." >&2
exit 3
;;
-d|--data|--data-raw|--data-binary|--data-urlencode|-T|--upload-file)
echo "ERROR: la skill 'unifi' es read-only. -d/--data/-T bloqueado." >&2
exit 3
;;
esac
done
# ──────────────────────────────────────────────────────────────────────────
# READ-ONLY GUARD — la skill es read-only por diseño. Bloqueamos cualquier
# intento de cambiar el verbo HTTP o mandar body. La protección REAL la da
# el rol "Site View Only" del admin en el UDM (que también devuelve 403);
# este guard es defensa en profundidad para evitar errores aun antes de
# salir a la red.
# ──────────────────────────────────────────────────────────────────────────
for arg in "$@"; do
case "$arg" in
-X|--request|-XPOST|-XPUT|-XDELETE|-XPATCH)
echo "ERROR: la skill 'unifi' es read-only. -X/--request bloqueado." >&2
echo " Si REALMENTE necesitás escribir, llamá a curl directo (no via este script)" >&2
echo " y sabé lo que estás haciendo." >&2
exit 3
;;
-d|--data|--data-raw|--data-binary|--data-urlencode|-T|--upload-file)
echo "ERROR: la skill 'unifi' es read-only. -d/--data/-T bloqueado." >&2
exit 3
;;
esac
done
# Expandir el placeholder {site} con el UUID del site (Integration API usa UUID, no nombre).
PATH_ARG="${PATH_ARG//\{site\}/$UNIFI_SITE}"
if [[ "$PATH_ARG" =~ ^https?:// ]]; then
URL="$PATH_ARG"
elif [[ "$PATH_ARG" == /proxy/* || "$PATH_ARG" == /api/* ]]; then
URL="https://${UNIFI_HOST}${PATH_ARG}"
else
URL="https://${UNIFI_HOST}/proxy/network/integration/v1${PATH_ARG}"
fi
exec curl -sS -k \
-H "X-API-KEY: ${UNIFI_API_KEY}" \
-H "Accept: application/json" \
"$@" \
"$URL"

77
topology.md Normal file
View File

@@ -0,0 +1,77 @@
# Topología de la red del beneficio Rio Frio
Mapa de MACs / IPs / hostnames a su rol en el beneficio. Sirve para que
las queries en lenguaje natural ("¿está la báscula del laboratorio?") se
resuelvan rápido sin tener que listar TODOS los clientes cada vez.
> Este archivo se llena con uso. Cuando descubras el MAC de un equipo (corriendo
> `query.sh /sites/default/clients`), agregá la fila acá para futuras consultas.
## Red
- **CIDR**: 192.168.87.0/24
- **Gateway / DNS / UDM Pro**: 192.168.87.5
- **DNS interno**: `*.interno` resuelto por el UDM
- **DNS público**: `*.nucleoriofrio.com` → todos resuelven a nucleo001 (192.168.87.133)
## Servidores
| IP | MAC | Hostname | Alias en UniFi | Rol |
|---|---|---|---|---|
| 192.168.87.133 | 7c:83:34:bc:6c:11 | nucleo001 | **`freepbx` ← stale, renombrar** | Swarm leader, todos los servicios infra |
| 192.168.87.76 | 00:e0:4c:68:0e:20 | nucleo002 | `nucleo002` | Legacy manager (futuro Swarm worker) |
| 192.168.87.29 | — | nucleoGamdias | — | Standalone, GPU (Frigate, Immich) |
| 192.168.87.78 | 7c:83:34:bc:6a:e6 | homeassistant | `homeassistant` | Home Assistant (`hassos.nucleoriofrio.com`) |
| 192.168.87.135 | — | — | — | Estación admin (whitelist fail2ban) |
| 192.168.87.137 | 90:09:d0:13:59:3d | — | `Memoria-1 conexion 2` | Share CIFS `//192.168.87.137/home/fotos boda` |
## Impresoras Epson ePOS (IP fija)
| IP | Equipo | Tipo | Ubicación |
|---|---|---|---|
| 192.168.87.142 | patio | thermal | patio (secado) |
| 192.168.87.147 | matricial2 | dot-matrix | oficina |
| 192.168.87.150 | MatricialOficinaMami | dot-matrix | oficina |
| 192.168.87.220 | TermicaRecibidero | thermal | recibidero |
| 192.168.87.221 | termica2 | thermal | sifones |
Puerto: 8043 SSL (Epson ePOS).
## Básculas (ESP32 / ESPHome → MQTT)
Las básculas hablan WiFi y publican por MQTT al broker EMQX en
`homeassistant.interno`. El MAC de cada ESP32 lo descubrís listando clientes
y filtrando por hostname tipo `esphome-*`.
| ID en manifest | Tipo | Ubicación | MAC | IP |
|---|---|---|---|---|
| camion_sifones | truck-scale | sifones | _por descubrir_ | _DHCP_ |
| sacos_sifones | sack-scale | sifones | _por descubrir_ | _DHCP_ |
| camion_recibideronuevo | truck-scale | recibidero-nuevo | _por descubrir_ | _DHCP_ |
| sacos_bodega | sack-scale (default) | bodega | _por descubrir_ | _DHCP_ |
| pesolva | bulk-scale | pesolva | _por descubrir_ | _DHCP_ |
| sacos_lasmarias | sack-scale | las-marias | _por descubrir_ | _DHCP_ |
| tolva_bodega | hopper-scale | bodega | _por descubrir_ | _DHCP_ |
| sacos_laboratorio | sack-scale | laboratorio | _por descubrir_ | _DHCP_ |
## Otros dispositivos conocidos
| Equipo | Notas |
|---|---|
| Govee H5100 (`h5100_6b7f`) | Sensor temp/humedad, **Bluetooth** (NO en WiFi UniFi) |
| Home Assistant | `hassos.nucleoriofrio.com` / `homeassistant.interno`, MAC pendiente |
| MeshCentral | `mesh.nucleoriofrio.com`, corre en alguno de los servidores |
## Cómo descubrir un MAC nuevo
```bash
# Lista clientes con hostname que matchee
~/.claude/skills/unifi/scripts/query.sh /sites/default/clients \
| jq '.data[] | select((.name // .hostname // "") | test("esphome|scale|bascula"; "i")) | {name, hostname, macAddress, ipAddress}'
# Listar TODO con IP fija conocida (ej. impresora del patio)
~/.claude/skills/unifi/scripts/query.sh /sites/default/clients \
| jq '.data[] | select(.ipAddress == "192.168.87.142")'
```
Cuando confirmes un equipo, actualizá la tabla correspondiente acá.