diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..5ef38bc --- /dev/null +++ b/.env.example @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..39697f5 --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +# Local config con secrets — NUNCA committear +.env + +# Cache local: cookies, csrf, session tokens +.cache/ + +# Temporales +*.tmp +*.bak +*.swp diff --git a/README.md b/README.md index 3c677a1..8e7cff9 100644 --- a/README.md +++ b/README.md @@ -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. \ No newline at end of file +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` diff --git a/SKILL.md b/SKILL.md new file mode 100644 index 0000000..5829fa1 --- /dev/null +++ b/SKILL.md @@ -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//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= + ``` +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) diff --git a/endpoints.md b/endpoints.md new file mode 100644 index 0000000..865cbfa --- /dev/null +++ b/endpoints.md @@ -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/` | 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/` | 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/` | 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. | diff --git a/scripts/query-classic.sh b/scripts/query-classic.sh new file mode 100644 index 0000000..7e9e3b2 --- /dev/null +++ b/scripts/query-classic.sh @@ -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 [curl args...] +# +# El puede ser: +# - Path corto: /stat/health → /proxy/network/api/s//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 < [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" diff --git a/scripts/query.sh b/scripts/query.sh new file mode 100644 index 0000000..c907160 --- /dev/null +++ b/scripts/query.sh @@ -0,0 +1,107 @@ +#!/usr/bin/env bash +# UniFi UDM Pro — Integration API helper. +# +# Uso: +# query.sh [curl args...] +# +# El 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 < [curl args...] + +Ejemplos: + query.sh /sites + query.sh /sites/{site}/devices + query.sh /sites/{site}/clients + query.sh /sites/{site}/devices//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" diff --git a/topology.md b/topology.md new file mode 100644 index 0000000..349f008 --- /dev/null +++ b/topology.md @@ -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á.