openapi: 3.0.3 info: title: 'ICSManager Agent API' description: 'API para agentes externos (Marta, etc.) que leen el estado de los servicios contratados en ICSManager y se auto-configuran.' version: 1.0.0 servers: - url: 'https://icsmanager.docloud.es' tags: - name: 'Agent API' description: "\nEndpoints consumidos por agentes externos (Marta y similares) para leer\nservicios contratados y auto-configurarse." - name: 'DNS Slave API' description: "\nEndpoint consumido por los servidores DNS secundarios para auto-configurarse:\ndevuelven la lista de zonas primarias de las que deben hacer de secundarios,\ncon su FQDN, IPs, clave TSIG y usuarios autorizados (para el panel Technium)." components: securitySchemes: default: type: http scheme: bearer description: 'Cada agente externo tiene un token propio, configurado en `config/services.php → agent_api.tokens`. Contacta al admin para solicitar un token.' security: - default: [] paths: /api/agent/services: get: summary: 'Listar servicios por tipo' operationId: listarServiciosPorTipo description: "Devuelve la lista de servicios contratados del tipo indicado, con su slug,\npropietario y emails autorizados. El propietario **siempre aparece** también\ndentro de `autorizados`. Los servicios sin `SLUG` definido no se incluyen\n(salvo que se pase `include_unpublished=1`). Los servicios con `activo:false`\nse siguen devolviendo hasta que el agente confirme la desprovisión vía\n`DELETE /api/agent/services/{id}`.\n\n## Derivación de `activo` para tipos de nodo\n\nPara `type=pve-node` y `type=pbs-node`, el campo `activo` no es literalmente\n`servicios.activo` de BD — se cruza con la existencia de un dashboard activo\nque lo referencie, garantizando coherencia con `?type=dashboard-*`:\n\n- **pve-node**: `activo:true` ⇔ `servicios.activo=true` Y el propietario\n de al menos un `dashboard-pve` activo tiene autorización (en cualquier\n scope: perfil, contrato o servicio) sobre este nodo.\n- **pbs-node**: misma regla simétrica con `dashboard-pbs`. El cruce es\n por **autorizaciones** (no por `perfil_fiscal`), porque el perfil del\n nodo refleja facturación (a menudo el proveedor de infraestructura,\n ej. OVH) y no necesariamente coincide con el perfil del dashboard.\n\nCuando se aplica el override, el payload añade\n`\"activo_origen\":\"no_referenciado_en_dashboard\"` para que el agente pueda\ndistinguir \"desactivado en BD\" de \"derivado desde dashboards\". En ese caso\n`deactivated_at` se rellena con `updated_at` del servicio como **proxy**\n(no es el momento exacto en que perdió la referencia del dashboard, pero\nmantiene el contrato `activo:false ⇒ deactivated_at siempre poblado`).\n\nLos reconcilers que filtran por `activo:true` (Marta/pulso, generación\nde scrape configs) se autocorrigen sin tocar BD." parameters: - in: query name: type description: 'Tipo de servicio. Valores: `dashboard-pbs`, `dashboard-pve`, `pve-node`, `pbs-node`.' example: dashboard-pbs required: true schema: type: string description: 'Tipo de servicio. Valores: `dashboard-pbs`, `dashboard-pve`, `pve-node`, `pbs-node`.' example: dashboard-pbs - in: query name: include_unpublished description: 'Incluye servicios sin SLUG (vista admin). Por defecto solo se devuelven los publicables (con SLUG).' example: false required: false schema: type: boolean description: 'Incluye servicios sin SLUG (vista admin). Por defecto solo se devuelven los publicables (con SLUG).' example: false - in: query name: include description: 'Lista separada por comas de embeds: `pve_nodes`, `datastores`. Cada servicio incluirá los recursos del propietario, evitando llamadas N+1.' example: 'pve_nodes,datastores' required: false schema: type: string description: 'Lista separada por comas de embeds: `pve_nodes`, `datastores`. Cada servicio incluirá los recursos del propietario, evitando llamadas N+1.' example: 'pve_nodes,datastores' responses: 200: description: '' content: application/json: schema: oneOf: - description: 'OK pve-node' type: array items: type: object properties: id: type: integer example: 2382 slug: type: string example: JRM04-ns31275378 activo: type: boolean example: true deactivated_at: type: string example: null nullable: true last_modified: type: string example: '2026-05-09T12:21:00+02:00' propietario: type: object properties: email: type: string example: juanjo@reyesinformatica.com nombre: type: string example: 'Juanjo Reyes' autorizados: type: array example: - email: juanjo@reyesinformatica.com items: type: object properties: email: type: string example: juanjo@reyesinformatica.com fqdn: type: string example: ns31275378.core.com.es example: - id: 2382 slug: JRM04-ns31275378 activo: true deactivated_at: null last_modified: '2026-05-09T12:21:00+02:00' propietario: email: juanjo@reyesinformatica.com nombre: 'Juanjo Reyes' autorizados: - email: juanjo@reyesinformatica.com fqdn: ns31275378.core.com.es - description: 'activo derivado a false' type: array items: type: object properties: id: type: integer example: 2382 slug: type: string example: JRM04-ns31275378 activo: type: boolean example: false activo_origen: type: string example: no_referenciado_en_dashboard deactivated_at: type: string example: null nullable: true last_modified: type: string example: '2026-05-09T12:21:00+02:00' propietario: type: object properties: email: type: string example: juanjo@reyesinformatica.com nombre: type: string example: 'Juanjo Reyes' autorizados: type: array example: - email: juanjo@reyesinformatica.com items: type: object properties: email: type: string example: juanjo@reyesinformatica.com fqdn: type: string example: ns31275378.core.com.es example: - id: 2382 slug: JRM04-ns31275378 activo: false activo_origen: no_referenciado_en_dashboard deactivated_at: null last_modified: '2026-05-09T12:21:00+02:00' propietario: email: juanjo@reyesinformatica.com nombre: 'Juanjo Reyes' autorizados: - email: juanjo@reyesinformatica.com fqdn: ns31275378.core.com.es 400: description: 'type inválido' content: application/json: schema: type: object example: error: bad_request message: 'Query param "type" is required and must be one of: dashboard-pbs, dashboard-pve, pve-node, pbs-node' properties: error: type: string example: bad_request message: type: string example: 'Query param "type" is required and must be one of: dashboard-pbs, dashboard-pve, pve-node, pbs-node' 401: description: 'sin token' content: application/json: schema: type: object example: error: unauthorized message: 'Missing Authorization: Bearer header' properties: error: type: string example: unauthorized message: type: string example: 'Missing Authorization: Bearer header' tags: - 'Agent API' '/api/agent/services/{id}': delete: summary: 'Confirmar desprovisión de un servicio' operationId: confirmarDesprovisinDeUnServicio description: "El agente llama este endpoint tras haber desprovisionado el recurso asociado\n(ej: borrar la carpeta Grafana + grupo Authentik del dashboard). Marca el\nservicio con `desprovisionado_at = now()` para que no vuelva a aparecer en\nel endpoint de listado. Idempotente.\n\nReglas:\n- Sólo se permite sobre servicios con `activo=false` — un servicio activo\n devuelve `409 Conflict`.\n- No borra el servicio en ICSManager." parameters: [] responses: 204: description: OK content: text/plain: schema: type: string example: '' 401: description: 'sin token' content: application/json: schema: type: object example: error: unauthorized message: 'Missing Authorization: Bearer header' properties: error: type: string example: unauthorized message: type: string example: 'Missing Authorization: Bearer header' 404: description: 'servicio inexistente' content: application/json: schema: type: object example: error: not_found message: 'Service 99999999 not found' properties: error: type: string example: not_found message: type: string example: 'Service 99999999 not found' 409: description: 'servicio activo' content: application/json: schema: type: object example: error: conflict message: 'Cannot deprovision an active service — set activo=false first' properties: error: type: string example: conflict message: type: string example: 'Cannot deprovision an active service — set activo=false first' tags: - 'Agent API' requestBody: required: false content: application/json: schema: type: object properties: reason: type: string description: 'Motivo opcional de la desprovisión (queda en activity_log).' example: '"Carpeta Grafana eliminada tras baja de contrato"' parameters: - in: path name: id description: 'ID numérico del servicio.' example: 2821 required: true schema: type: integer '/api/agent/users/{email}/datastores': get: summary: 'Datastores accesibles para un usuario' operationId: datastoresAccesiblesParaUnUsuario description: "Devuelve los datastores a los que el email tiene acceso — sea como propietario\no como autorizado (en scope perfil, contrato o servicio específico).\nUn mismo `nombre` puede aparecer varias veces si hay acceso desde distintos\ncontratos; el cliente debe deduplicar por `nombre` si lo necesita." parameters: - in: query name: type description: 'Tipo de recurso. Actualmente sólo `pbs`.' example: pbs required: true schema: type: string description: 'Tipo de recurso. Actualmente sólo `pbs`.' example: pbs responses: 200: description: '' content: application/json: schema: oneOf: - description: OK type: object example: user_email: soft4ebusiness@gmail.com datastores: - nombre: mooring contratado_gb: 1250 contrato: CTR-1318 - nombre: track contratado_gb: 1000 contrato: CTR-1294 properties: user_email: type: string example: soft4ebusiness@gmail.com datastores: type: array example: - nombre: mooring contratado_gb: 1250 contrato: CTR-1318 - nombre: track contratado_gb: 1000 contrato: CTR-1294 items: type: object properties: nombre: type: string example: mooring contratado_gb: type: integer example: 1250 contrato: type: string example: CTR-1318 - description: 'sin datastores' type: object example: user_email: foo@bar.example datastores: [] properties: user_email: type: string example: foo@bar.example datastores: type: array example: [] 400: description: 'type inválido' content: application/json: schema: type: object example: error: bad_request message: 'Query param "type" is required and must be one of: pbs' properties: error: type: string example: bad_request message: type: string example: 'Query param "type" is required and must be one of: pbs' tags: - 'Agent API' parameters: - in: path name: email description: 'Email del usuario (URL-encoded).' example: soft4ebusiness@gmail.com required: true schema: type: string '/api/agent/users/{email}/pve-nodes': get: summary: 'Nodos PVE accesibles para un usuario' operationId: nodosPVEAccesiblesParaUnUsuario description: "Devuelve los nodos PVE (servicios `plugin=pve` activos con contrato activo)\na los que el email tiene acceso — sea como propietario o como autorizado en\nscope perfil, contrato o servicio específico.\n\nUn mismo nodo puede aparecer varias veces si hay acceso desde distintos\ncontratos; el cliente debe deduplicar por `fqdn` si lo necesita.\n\nEl campo `pve_vms` está reservado para el futuro caso de VMs individuales\nen PVEs compartidos — hoy devuelve array vacío." parameters: [] responses: 200: description: '' content: application/json: schema: oneOf: - description: OK type: object example: user_email: juanjo@reyesinformatica.com pve_nodes: - fqdn: ns3189882.ip-152-228-223.eu slug: reyes-pve-01 - fqdn: ns31275378.ip-51-210-117.eu slug: '' pve_vms: [] properties: user_email: type: string example: juanjo@reyesinformatica.com pve_nodes: type: array example: - fqdn: ns3189882.ip-152-228-223.eu slug: reyes-pve-01 - fqdn: ns31275378.ip-51-210-117.eu slug: '' items: type: object properties: fqdn: type: string example: ns3189882.ip-152-228-223.eu slug: type: string example: reyes-pve-01 pve_vms: type: array example: [] - description: 'sin nodos' type: object example: user_email: foo@bar.example pve_nodes: [] pve_vms: [] properties: user_email: type: string example: foo@bar.example pve_nodes: type: array example: [] pve_vms: type: array example: [] 401: description: 'sin token' content: application/json: schema: type: object example: error: unauthorized message: 'Missing Authorization: Bearer header' properties: error: type: string example: unauthorized message: type: string example: 'Missing Authorization: Bearer header' tags: - 'Agent API' parameters: - in: path name: email description: 'Email del usuario (URL-encoded).' example: juanjo@reyesinformatica.com required: true schema: type: string /api/agent/dns-slaves: get: summary: 'Listar zonas dns-slave' operationId: listarZonasDnsSlave description: "Devuelve las zonas DNS del plugin `dns-slave` que un servidor secundario\ndebe gestionar. Incluye las activas y también las dadas de BAJA (servicio\ninactivo o contrato no activo) marcadas con `activo:false` y su\n`deactivated_at`, para que el secundario pueda retirarlas sin dejar zonas\nhuérfanas. Solo se omiten las desprovisionadas definitivamente. Cada zona\nincluye FQDN del primario, IPs (IPv4/IPv6), puerto, clave TSIG (AXFR) y los\nusuarios autorizados (propietario + autorizaciones lectura/escritura/técnico).\nTodos los secundarios autenticados reciben la misma lista." parameters: [] responses: 200: description: ok content: application/json: schema: type: object example: generated_at: '2026-07-25T12:00:00+02:00' count: 2 zonas: - servicio_id: 2868 fqdn: jrm1.plesk.do ipv4: 51.254.54.217 ipv6: '2001:41d0:303:f2dc::23' port: 53 tsig: 'hmac-sha256:clave...' activo: true deactivated_at: null autorizados: - email: juanjo@reyesinformatica.com nombre: 'Juanjo Reyes' propietario: true - email: soporte@reyesinformatica.com nombre: Soporte - servicio_id: 2900 fqdn: antigua.example.com ipv4: 203.0.113.10 ipv6: null port: 53 tsig: null activo: false deactivated_at: '2026-06-01T09:00:00+02:00' autorizados: [] properties: generated_at: type: string example: '2026-07-25T12:00:00+02:00' count: type: integer example: 2 zonas: type: array example: - servicio_id: 2868 fqdn: jrm1.plesk.do ipv4: 51.254.54.217 ipv6: '2001:41d0:303:f2dc::23' port: 53 tsig: 'hmac-sha256:clave...' activo: true deactivated_at: null autorizados: - email: juanjo@reyesinformatica.com nombre: 'Juanjo Reyes' propietario: true - email: soporte@reyesinformatica.com nombre: Soporte - servicio_id: 2900 fqdn: antigua.example.com ipv4: 203.0.113.10 ipv6: null port: 53 tsig: null activo: false deactivated_at: '2026-06-01T09:00:00+02:00' autorizados: [] items: type: object properties: servicio_id: type: integer example: 2868 fqdn: type: string example: jrm1.plesk.do ipv4: type: string example: 51.254.54.217 ipv6: type: string example: '2001:41d0:303:f2dc::23' port: type: integer example: 53 tsig: type: string example: 'hmac-sha256:clave...' activo: type: boolean example: true deactivated_at: type: string example: null nullable: true autorizados: type: array example: - email: juanjo@reyesinformatica.com nombre: 'Juanjo Reyes' propietario: true - email: soporte@reyesinformatica.com nombre: Soporte items: type: object properties: email: type: string example: juanjo@reyesinformatica.com nombre: type: string example: 'Juanjo Reyes' propietario: type: boolean example: true 401: description: 'sin token' content: application/json: schema: type: object example: error: unauthorized message: 'Missing Authorization: Bearer header' properties: error: type: string example: unauthorized message: type: string example: 'Missing Authorization: Bearer header' tags: - 'DNS Slave API' /api/agent/dns-slaves/informe: post: summary: 'Recibir informe de estado de un secundario' operationId: recibirInformeDeEstadoDeUnSecundario description: "Un servidor DNS secundario envía (fire-and-forget, idempotente por hash\nlocal) el estado de un servicio cuando cambia — incluido el estado verde\n(`correcto: true`). Es **advisory**: solo se retiene el último informe por\n(servicio, secundario) y se muestra en el panel; no factura ni modela\nzonas. El cuerpo lleva la clave en sí mismo (`servicio_id`, `secundario`).\n\n`bloques` es una lista de avisos tipados y autodescriptivos; el panel los\npinta en genérico, así que se pueden añadir tipos nuevos sin cambiar el\ncontrato (arranque: dns_externo, sin_delegacion, transferencia, ruido)." parameters: [] responses: 200: description: ok content: application/json: schema: type: object example: ok: true servicio_id: 2868 secundario: ns1.tecnium.example correcto: false properties: ok: type: boolean example: true servicio_id: type: integer example: 2868 secundario: type: string example: ns1.tecnium.example correcto: type: boolean example: false 401: description: 'sin token' content: application/json: schema: type: object example: error: unauthorized message: 'Missing Authorization: Bearer header' properties: error: type: string example: unauthorized message: type: string example: 'Missing Authorization: Bearer header' 404: description: 'servicio inexistente' content: application/json: schema: type: object example: error: not_found message: 'Servicio no encontrado' properties: error: type: string example: not_found message: type: string example: 'Servicio no encontrado' 422: description: 'cuerpo inválido' content: application/json: schema: type: object example: error: unprocessable message: 'The servicio_id field is required.' properties: error: type: string example: unprocessable message: type: string example: 'The servicio_id field is required.' tags: - 'DNS Slave API' requestBody: required: true content: application/json: schema: type: object properties: servicio_id: type: integer description: 'ID del servicio (zona).' example: 2868 secundario: type: string description: 'Identificador del servidor secundario.' example: ns1.tecnium.example correcto: type: boolean description: 'Estado global: true = verde, false = con avisos.' example: false reported_at: type: string description: 'Marca de tiempo ISO-8601 del informe (opcional).' example: '2026-07-25T12:00:00+02:00' bloques: type: array description: 'Avisos tipados.' example: - codigo: transferencia titulo: 'Transferencia fallida' severidad: error accion: 'Revisar AXFR' dominios: - dominio: example.com diagnostico: syncFailed info: serial: 123 items: type: object required: - servicio_id - secundario - correcto