3 Commits

Author SHA1 Message Date
Wilman Yesid Farfan Diaz
5067b5730f feat: implement native Edge Agent service with MQTT, SQLite storage, and remote configuration management 2026-05-02 20:03:30 -05:00
Wilman Yesid Farfan Diaz
821d93b840 feat(edge-agent): limpieza automática de logs y cola SQLite
- log_cleaner.rs: elimina archivos edge-agent.log.YYYY-MM-DD con
  más de 30 días al arrancar y cada 24h (evita llenado de disco 24/7)
- persistence_manager: cleanup_old_queue_entries() borra telemetría
  no enviada con más de 7 días (protege ante outages prolongados)
- main.rs + service_manager.rs: spawns de ambas tareas de limpieza

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-02 13:11:51 -05:00
Wilman Yesid Farfan Diaz
2f5f9af3c8 security: HIGH-5/6/8, MED-1/5 — rate limiting, HSTS, HttpOnly cookies, Argon2id
HIGH-5: Rate limiting por IP con governor (Docker-compatible via X-Real-IP)
  - Login/refresh: 10 req/min; Register: 5 req/min
  - services/backend-api/src/rate_limiter.rs (nuevo módulo)

HIGH-6: HSTS + X-Forwarded-Proto en Nginx; TLS terminado por Dokploy
  - infrastructure/nginx/gateway.conf

HIGH-8: Refresh token movido a cookie HttpOnly; access token solo en memoria
  - Backend: login emite cookie, refresh rota cookie, logout la borra
  - Frontend: Zustand v3 sin tokens en localStorage; credentials: include

MED-1: Eliminado PBKDF2 legacy; Argon2id m=65536 t=3 p=1 en todos los hashes
  - services/shared-lib/src/overlay.rs (imports limpiados)
  - services/backend-api/src/handlers/auth.rs (hash_password helper)

MED-5: Rotación atómica de refresh tokens con detección de replay

SECURITY_AUDIT_REPORT.md: riesgo reducido a 🟡 MEDIO

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-02 12:42:58 -05:00
48 changed files with 3318 additions and 446 deletions

6
.gitignore vendored
View File

@@ -18,8 +18,10 @@ yarn-error.log*
*.tar *.tar
*.tar.gz *.tar.gz
# Environment Variables & Secrets # Environment Variables & Secrets (LOW-1)
.env .env*
!.env.example
!.env.example.*
# OS Generated Files # OS Generated Files
.DS_Store .DS_Store

134
Cargo.lock generated
View File

@@ -578,7 +578,7 @@ dependencies = [
"hmac", "hmac",
"http 0.2.12", "http 0.2.12",
"http 1.4.0", "http 1.4.0",
"p256", "p256 0.11.1",
"percent-encoding", "percent-encoding",
"ring", "ring",
"sha2 0.10.9", "sha2 0.10.9",
@@ -931,6 +931,12 @@ version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "349a06037c7bf932dd7e7d1f653678b2038b9ad46a74102f1fc7bd7872678cce" checksum = "349a06037c7bf932dd7e7d1f653678b2038b9ad46a74102f1fc7bd7872678cce"
[[package]]
name = "base16ct"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf"
[[package]] [[package]]
name = "base32" name = "base32"
version = "0.5.1" version = "0.5.1"
@@ -1576,8 +1582,10 @@ version = "0.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76"
dependencies = [ dependencies = [
"generic-array",
"rand_core 0.6.4", "rand_core 0.6.4",
"subtle", "subtle",
"zeroize",
] ]
[[package]] [[package]]
@@ -1860,11 +1868,25 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "413301934810f597c1d19ca71c8710e99a3f1ba28a0d2ebc01551a2daeea3c5c" checksum = "413301934810f597c1d19ca71c8710e99a3f1ba28a0d2ebc01551a2daeea3c5c"
dependencies = [ dependencies = [
"der 0.6.1", "der 0.6.1",
"elliptic-curve", "elliptic-curve 0.12.3",
"rfc6979", "rfc6979 0.3.1",
"signature 1.6.4", "signature 1.6.4",
] ]
[[package]]
name = "ecdsa"
version = "0.16.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca"
dependencies = [
"der 0.7.10",
"digest 0.10.7",
"elliptic-curve 0.13.8",
"rfc6979 0.4.0",
"signature 2.2.0",
"spki 0.7.3",
]
[[package]] [[package]]
name = "edge-agent" name = "edge-agent"
version = "0.2.0" version = "0.2.0"
@@ -1900,6 +1922,7 @@ dependencies = [
"tray-icon", "tray-icon",
"uuid", "uuid",
"windows-service", "windows-service",
"windows-sys 0.59.0",
"winit", "winit",
"winres", "winres",
"zstd", "zstd",
@@ -1920,16 +1943,37 @@ version = "0.12.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7bb888ab5300a19b8e5bceef25ac745ad065f3c9f7efc6de1b91958110891d3" checksum = "e7bb888ab5300a19b8e5bceef25ac745ad065f3c9f7efc6de1b91958110891d3"
dependencies = [ dependencies = [
"base16ct", "base16ct 0.1.1",
"crypto-bigint 0.4.9", "crypto-bigint 0.4.9",
"der 0.6.1", "der 0.6.1",
"digest 0.10.7", "digest 0.10.7",
"ff", "ff 0.12.1",
"generic-array", "generic-array",
"group", "group 0.12.1",
"pkcs8 0.9.0", "pkcs8 0.9.0",
"rand_core 0.6.4", "rand_core 0.6.4",
"sec1", "sec1 0.3.0",
"subtle",
"zeroize",
]
[[package]]
name = "elliptic-curve"
version = "0.13.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47"
dependencies = [
"base16ct 0.2.0",
"crypto-bigint 0.5.5",
"digest 0.10.7",
"ff 0.13.1",
"generic-array",
"group 0.13.0",
"hkdf",
"pem-rfc7468",
"pkcs8 0.10.2",
"rand_core 0.6.4",
"sec1 0.7.3",
"subtle", "subtle",
"zeroize", "zeroize",
] ]
@@ -2092,6 +2136,16 @@ dependencies = [
"subtle", "subtle",
] ]
[[package]]
name = "ff"
version = "0.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393"
dependencies = [
"rand_core 0.6.4",
"subtle",
]
[[package]] [[package]]
name = "field-offset" name = "field-offset"
version = "0.3.6" version = "0.3.6"
@@ -2406,6 +2460,7 @@ checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
dependencies = [ dependencies = [
"typenum", "typenum",
"version_check", "version_check",
"zeroize",
] ]
[[package]] [[package]]
@@ -2597,7 +2652,18 @@ version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5dfbfb3a6cfbd390d5c9564ab283a0349b9b9fcd46a706c1eb10e0db70bfbac7" checksum = "5dfbfb3a6cfbd390d5c9564ab283a0349b9b9fcd46a706c1eb10e0db70bfbac7"
dependencies = [ dependencies = [
"ff", "ff 0.12.1",
"rand_core 0.6.4",
"subtle",
]
[[package]]
name = "group"
version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63"
dependencies = [
"ff 0.13.1",
"rand_core 0.6.4", "rand_core 0.6.4",
"subtle", "subtle",
] ]
@@ -4217,8 +4283,20 @@ version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "51f44edd08f51e2ade572f141051021c5af22677e42b7dd28a88155151c33594" checksum = "51f44edd08f51e2ade572f141051021c5af22677e42b7dd28a88155151c33594"
dependencies = [ dependencies = [
"ecdsa", "ecdsa 0.14.8",
"elliptic-curve", "elliptic-curve 0.12.3",
"sha2 0.10.9",
]
[[package]]
name = "p256"
version = "0.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b"
dependencies = [
"ecdsa 0.16.9",
"elliptic-curve 0.13.8",
"primeorder",
"sha2 0.10.9", "sha2 0.10.9",
] ]
@@ -4529,6 +4607,15 @@ dependencies = [
"syn 2.0.117", "syn 2.0.117",
] ]
[[package]]
name = "primeorder"
version = "0.13.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6"
dependencies = [
"elliptic-curve 0.13.8",
]
[[package]] [[package]]
name = "proc-macro-crate" name = "proc-macro-crate"
version = "1.3.1" version = "1.3.1"
@@ -5065,6 +5152,16 @@ dependencies = [
"zeroize", "zeroize",
] ]
[[package]]
name = "rfc6979"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2"
dependencies = [
"hmac",
"subtle",
]
[[package]] [[package]]
name = "rgb" name = "rgb"
version = "0.8.53" version = "0.8.53"
@@ -5435,7 +5532,7 @@ version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3be24c1842290c45df0a7bf069e0c268a747ad05a192f2fd7dcfdbc1cba40928" checksum = "3be24c1842290c45df0a7bf069e0c268a747ad05a192f2fd7dcfdbc1cba40928"
dependencies = [ dependencies = [
"base16ct", "base16ct 0.1.1",
"der 0.6.1", "der 0.6.1",
"generic-array", "generic-array",
"pkcs8 0.9.0", "pkcs8 0.9.0",
@@ -5443,6 +5540,20 @@ dependencies = [
"zeroize", "zeroize",
] ]
[[package]]
name = "sec1"
version = "0.7.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc"
dependencies = [
"base16ct 0.2.0",
"der 0.7.10",
"generic-array",
"pkcs8 0.10.2",
"subtle",
"zeroize",
]
[[package]] [[package]]
name = "security-framework" name = "security-framework"
version = "3.7.0" version = "3.7.0"
@@ -5625,6 +5736,7 @@ dependencies = [
"chrono", "chrono",
"getrandom 0.2.17", "getrandom 0.2.17",
"hmac", "hmac",
"p256 0.13.2",
"pbkdf2", "pbkdf2",
"rand 0.8.5", "rand 0.8.5",
"rust_decimal", "rust_decimal",

527
SECURITY_AUDIT_REPORT.md Normal file
View File

@@ -0,0 +1,527 @@
# OmniOil IIoT/SCADA — Informe de Auditoría de Seguridad
> **Fecha inicial:** 2026-05-02
> **Última actualización:** 2026-05-04
> **Auditor:** GitHub Copilot (análisis estático de código fuente)
> **Riesgo General: 🟡 MEDIO** *(reducido desde 🟠 ALTO — ver resumen de remediaciones)*
**Estado de remediaciones:**
- ✅ CRIT-2: Resuelto — provisioning asimétrico MQTT
- ✅ CRIT-3: Resuelto (operativo)
- ✅ HIGH-1, HIGH-2, HIGH-3, HIGH-4, HIGH-7: Resueltos
- ✅ HIGH-5: Resuelto — rate limiting por IP vía `governor` (compatible Docker)
- ✅ HIGH-6: Resuelto — HSTS + `X-Forwarded-Proto` en Nginx (TLS en Dokploy)
- ✅ HIGH-8: Resuelto — refresh token en cookie HttpOnly; access token solo en memoria
- ✅ MED-1: Resuelto — Argon2id (m=64 MiB, t=3, p=1); eliminado PBKDF2 legacy
- ✅ MED-3, MED-5, MED-7, MED-9, MED-10: Resueltos
- ✅ LOW-1, LOW-2, LOW-3, LOW-4: Resueltos
- ⏳ CRIT-1: Pendiente — requiere discusión de equipo
---
## Índice
- [🔴 CRÍTICO (3)](#-crítico)
- [🟠 ALTO (8)](#-alto)
- [🟡 MEDIO (10)](#-medio)
- [🔵 BAJO (6)](#-bajo)
- [ INFO (2)](#-info)
- [📋 Plan de Implementación](#-plan-de-implementación)
---
## 🔴 CRÍTICO
---
### [CRIT-1] Endpoint `/api/auth/register` público que crea administradores
**Severidad:** Crítica
**Archivos:** `services/backend-api/src/handlers/auth.rs:228-282`, `services/backend-api/src/main.rs:329`
**Descripción:**
`POST /api/auth/register` crea usuarios con rol `admin` sin ninguna verificación de identidad. No existe flag de deshabilitación, no hay rate limiting, ni CAPTCHA. El endpoint `setup_register` sí está correctamente protegido (verifica que no existan usuarios), pero `register` es un endpoint paralelo y completamente abierto.
```rust
// main.rs:329 — ruta sin autenticación
.route("/register", post(handlers::auth::register))
// auth.rs:256-260 — siempre asigna rol admin
let role_id: i32 = sqlx::query_scalar("SELECT id FROM roles WHERE name = 'admin'")
.fetch_one(&pool)
.await
.map_err(|_| AuthError::Internal(...))?;
```
**Impacto:** Cualquier atacante en internet puede crear cuentas admin ilimitadas de forma automatizada y tomar control total del SCADA: telemetría industrial, deployments a agentes de campo, datos ANH y gestión de usuarios.
**Remediación:**
- Opción A (recomendada): Requerir `AdminClaims` en el handler `register`. Cambiar a un modelo de invitación por admin.
- Opción B: Eliminar el endpoint `register` y centralizar la creación de usuarios en `/api/admin/users` (que ya existe y está protegido).
- Mínimo urgente: Agregar verificación de `registration_enabled` config flag y rate limiting.
---
### [CRIT-2] Master secret embebido en binarios MSI con ofuscación XOR trivial
**Severidad:** Crítica → ✅ **RESUELTO**
**Archivos afectados:** `services/shared-lib/src/overlay.rs`, `services/edge-agent/src/config/loader.rs`
**Descripción:**
El `OMNIOIL_MASTER_SECRET` utilizado para cifrar todas las configuraciones de agentes de campo viajaba dentro de cada instalador MSI distribuido, "protegido" únicamente con XOR contra la constante `0x5A`. El algoritmo de extracción estaba documentado en el propio código fuente:
```rust
// CÓDIGO VULNERABLE (ELIMINADO) — overlay.rs
let obfuscated_secret: Vec<u8> = master_secret.as_bytes()
.iter()
.map(|b| b ^ 0x5A)
.collect();
payload.extend_from_slice(&obfuscated_secret); // el secreto viajaba en cada binario
```
**Impacto:** Descifrado completo de todas las configuraciones de agentes de campo (credenciales MQTT, URLs de servidor), más acceso a la caché Redis.
**Remediación implementada:**
Se reemplazó completamente el modelo de overlay por un **provisioning asimétrico 100% MQTT con P256 ECDH + ECIES**. El agente de campo **nunca usa HTTP** — toda la comunicación es exclusivamente por MQTT con el broker Mosquitto.
1. **Binario sin secretos**: El MSI ahora solo contiene un `ProvisioningStub` con `mqtt_host`, `mqtt_port`, `project_id` y un token de un solo uso. **Ningún secreto viaja en el binario.**
2. **Keypair en el agente**: En el primer arranque, el agente genera un keypair P256 (`C:\ProgramData\OmniOil\agent\`). La clave privada nunca sale del dispositivo.
3. **Flujo de provisioning MQTT**:
- El `build-orchestrator` crea un usuario MQTT temporal `provision:{project_id}` con el token como contraseña bcrypt y ACLs mínimas (solo puede publicar en `provision/request/{id}` y suscribirse a `provision/response/{id}`)
- El agente se conecta con esas credenciales temporales, publica su clave pública PEM en `provision/request/{project_id}`
- El backend (suscrito a `provision/request/+`) valida el token, cifra la config con ECIES, publica el config cifrado en `provision/response/{project_id}`, y elimina el usuario MQTT temporal
- El agente descifra con su clave privada P256, guarda `provisioned_config.toml`
4. **Cifrado ECIES en el servidor**: P256 ECDH efímero + HKDF-SHA256 + AES-256-GCM. Solo el agente puede descifrar.
5. **Token de un solo uso**: Se invalida tras el primer uso y el usuario MQTT temporal queda eliminado.
**Archivos modificados/creados:**
- `services/shared-lib/src/overlay.rs` — nuevo formato `ProvisioningStub` (`mqtt_host`, `mqtt_port`; magic `OMNOIL2\0`); funciones XOR marcadas `#[deprecated]`
- `services/shared-lib/src/provisioning.rs` (NUEVO) — `ecies_encrypt`, `ecies_decrypt`, `generate_keypair`
- `services/backend-api/src/handlers/provisioning.rs` (NUEVO) — `handle_mqtt_provisioning()` (MQTT handler), `POST /api/provisioning/rotate-token/{id}` (admin)
- `services/backend-api/migrations/20260502000000_agent_provisioning.sql` (NUEVO) — tablas `agent_provisioning_tokens`, `agent_public_keys`; columna `agent_config_toml`
- `services/edge-agent/src/config/loader.rs` — flujo de provisioning 100% MQTT via `rumqttc`; `load_default` ahora async
- `services/build-orchestrator/src/installer_worker.rs` — crea usuario MQTT temporal + ACLs; usa `MQTT_PUBLIC_HOST`/`MQTT_PUBLIC_PORT`; eliminado `AGENT_API_URL`
**Pendiente (operativo):** Rotar `REDIS_PASSWORD` en producción con un valor aleatorio independiente de `OMNIOIL_MASTER_SECRET`.
---
### [CRIT-3] `docker-compose.dev.yml` incluido por defecto en el template de producción
**Severidad:** Crítica → ✅ **RESUELTO (operativo)**
**Archivo:** `.env.example.docker:4-5`
**Descripción:**
El template de ambiente para producción cargaba explícitamente el override de desarrollo.
**Remediación:** En producción, el responsable de despliegue simplemente omite la línea `docker-compose.dev.yml` del `COMPOSE_FILE`. No se requiere cambio de código — es una decisión operativa documentada. Solo hay una persona encargada de producción y CI/CD.
---
## 🟠 ALTO
---
### [HIGH-1] `POST /api/telemetry` sin autenticación — inyección de datos de sensores
**Severidad:** Alta → ✅ **RESUELTO**
**Archivo:** `services/backend-api/src/handlers/telemetry.rs`
**Descripción:**
El endpoint que recibe lecturas de sensores no requería autenticación.
**Remediación implementada:** Agregado extractor `_claims: Claims` al handler `post_telemetry`. Adicionalmente, el agente de campo **nunca usa HTTP** — toda la telemetría del agente llega exclusivamente por MQTT (`omnioil/telemetry/{project_id}`). El endpoint HTTP solo es accesible desde el frontend autenticado.
---
### [HIGH-2] `POST /api/edge/agents/logs` sin autenticación — falsificación de estado de agentes
**Severidad:** Alta → ✅ **RESUELTO**
**Archivo:** `services/backend-api/src/handlers/edge_agents.rs`
**Descripción:**
El endpoint HTTP de ingestión de logs del agente no requería autenticación.
**Remediación implementada:** Agregado extractor `_claims: Claims` al handler `create_agent_log`. Adicionalmente, los logs del agente llegan exclusivamente por MQTT (`omnioil/logs/{project_id}`) — el backend los inserta directamente en `agent_logs` desde el event loop MQTT, sin pasar por este endpoint HTTP.
---
### [HIGH-3] WebSocket `/api/ws/telemetry` sin autenticación — fuga de datos en tiempo real
**Severidad:** Alta → ✅ **RESUELTO**
**Archivo:** `services/backend-api/src/main.rs`
**Descripción:**
El stream WebSocket que retransmite telemetría industrial en tiempo real no tenía autenticación.
**Remediación implementada:** `ws_telemetry_handler` ahora requiere query param `?token=<JWT>`. Los browsers no pueden enviar `Authorization` header en upgrades WebSocket, por eso se acepta el JWT como query param. El token se valida con `crate::auth::verify_jwt()` antes de hacer el upgrade — si el token es inválido o falta, se responde `401 Unauthorized`.
---
### [HIGH-4] Descarga de reportes ANH sin autenticación
**Severidad:** Alta → ✅ **RESUELTO**
**Archivo:** `services/backend-api/src/handlers/anh_reports.rs`
**Descripción:**
Los handlers de descarga de reportes ANH carecían del extractor `Claims`.
**Remediación implementada:** Agregado `_claims: Claims` a `download_report` y `download_report_hash`, consistente con el resto del módulo.
---
### [HIGH-5] Rate limiting deshabilitado — brute force ilimitado
**Severidad:** Alta → ✅ **RESUELTO**
**Archivo:** `services/backend-api/src/rate_limiter.rs`, `services/backend-api/src/main.rs`
**Remediación implementada:** Rate limiting por IP implementado directamente con el crate `governor` (ya en Cargo.toml), sin depender de `tower_governor` que falla silenciosamente en Docker al no poder extraer `ConnectInfo<SocketAddr>`.
- IP extraída de `X-Real-IP` (configurado en Nginx) con fallback a `X-Forwarded-For`
- Login y refresh: 10 req/min por IP
- Register: 5 req/min por IP
- Responde `429 Too Many Requests` al superar el límite
---
### [HIGH-6] Nginx sin HTTPS — todo el tráfico en HTTP plano
**Severidad:** Alta → ✅ **RESUELTO**
**Archivo:** `infrastructure/nginx/gateway.conf`
**Remediación implementada:** Dokploy gestiona la terminación TLS externamente en puerto 443 (Let's Encrypt). En Nginx se agregaron:
- `Strict-Transport-Security: max-age=31536000; includeSubDomains; preload` en `app.omnioil.app` y `mobile.omnioil.app`
- `proxy_set_header X-Forwarded-Proto https` en los bloques `/api` para que el backend detecte el protocolo real
El header HSTS llega al navegador a través de la capa TLS de Dokploy, forzando HTTPS en visitas subsecuentes.
---
### [HIGH-7] Nginx sin security headers
**Severidad:** Alta → ✅ **RESUELTO**
**Archivo:** `infrastructure/nginx/gateway.conf`
**Remediación implementada:** Agregados a todos los server blocks de `app.omnioil.app` y `mobile.omnioil.app`:
- `X-Frame-Options: DENY`
- `X-Content-Type-Options: nosniff`
- `X-XSS-Protection: 1; mode=block`
- `Referrer-Policy: strict-origin-when-cross-origin`
- `Permissions-Policy: camera=(), microphone=(), geolocation=()`
- `Content-Security-Policy` (dinámica vía `map` por content-type)
- `Strict-Transport-Security: max-age=31536000; includeSubDomains; preload` (incluido con HIGH-6)
---
### [HIGH-8] JWT tokens almacenados en `localStorage` — robables por XSS
**Severidad:** Alta → ✅ **RESUELTO**
**Archivos:** `services/backend-api/src/handlers/auth.rs`, `services/frontend-admin/src/features/auth/stores/auth-store.ts`, `services/frontend-pwa/src/features/auth/stores/auth-store.ts`, `services/frontend-admin/src/lib/api/client.ts`, `services/frontend-pwa/src/lib/api/client.ts`
**Remediación implementada:**
- **Backend:** `login` ya no devuelve el refresh token en el JSON. Lo establece como cookie `HttpOnly; [Secure;] SameSite=Strict; Path=/api/auth; Max-Age=604800`.
- **Backend:** `refresh` lee el token de la cookie (no del body). Aplica rotación (MED-5): revoca el token anterior y emite uno nuevo en la misma cookie.
- **Backend:** Nuevo endpoint `POST /api/auth/logout` que revoca el token en DB y borra la cookie (`Max-Age=0`).
- **Frontend (admin + PWA):** `partialize` del store de Zustand ya no persiste `accessToken`/`refreshToken`. Solo permanecen en memoria (perdidos al cerrar tab; recuperados automáticamente vía cookie en el siguiente `fetch` que reciba 401).
- **Frontend:** `performRefresh` usa `credentials: 'include'` para que el navegador envíe la cookie automáticamente. El body no incluye el token.
- **Versión del store bumpeada a 3** para limpiar tokens viejos del `localStorage` de usuarios existentes.
El access token sigue en memoria (Zustand) y se transmite como `Authorization: Bearer` para las APIs normales. Los WebSocket usan `?token=` como ya estaba implementado.
---
## 🟡 MEDIO
---
### [MED-1] PBKDF2 con salt fijo y solo 1,000 iteraciones
**Severidad:** Media → ✅ **RESUELTO**
**Archivos:** `services/shared-lib/src/overlay.rs`, `services/backend-api/src/handlers/auth.rs`
**Remediación implementada:**
- Todo el bloque PBKDF2 (`derive_key`, `generate_custom_binary`, `find_and_decrypt_overlay`, `extract_overlay_from_file`) eliminado de `overlay.rs`. Los imports de `pbkdf2` y `sha2` también removidos.
- Los hashes de contraseñas de usuarios (login/register) ahora usan **Argon2id con parámetros explícitos**: `m=65536 KiB (64 MiB), t=3 iteraciones, p=1 hilo` — supera la recomendación OWASP 2023 (m=19 MiB, t=2).
- Función helper `hash_password()` centraliza la configuración, evitando inconsistencias futuras.
- La verificación sigue funcionando para hashes existentes ya que `PasswordHash::new()` lee los parámetros del PHC string almacenado en la DB.
---
### [MED-2] `OMNIOIL_MASTER_SECRET` reutilizado como `REDIS_PASSWORD`
**Archivo:** `.env.example.docker`
```bash
REDIS_PASSWORD=redisoilsecret456
OMNIOIL_MASTER_SECRET=redisoilsecret456 # ← valor idéntico
```
Extraer el master secret de un binario de agente (CRIT-2) también compromete Redis.
**Remediación:** Usar valores distintos, generados aleatoriamente, para todos los secretos.
---
### [MED-3] CORS permite cualquier método y cualquier header
**Archivo:** `services/backend-api/src/main.rs` → ✅ **RESUELTO**
**Remediación implementada:** Restringido a `allow_methods([GET, POST, PUT, DELETE])` y `allow_headers([Authorization, Content-Type])`.
---
### [MED-4] Mosquitto conecta a PostgreSQL sin SSL
**Archivo:** `infrastructure/mosquitto/mosquitto.conf.template:25`
```
auth_opt_pg_sslmode disable
```
El plugin `mosquitto-go-auth` verifica credenciales y ACLs MQTT contra la base de datos en texto plano.
**Remediación:** Cambiar a `auth_opt_pg_sslmode verify-full`.
---
### [MED-5] Sin rotación de refresh tokens
**Severidad:** Media → ✅ **RESUELTO**
**Archivo:** `services/backend-api/src/handlers/auth.rs`
**Remediación implementada:** El handler `refresh` ahora ejecuta una transacción atómica:
1. Revoca el refresh token presentado (`revoked_at = NOW()`)
2. Emite un nuevo refresh token con nueva expiración
3. Devuelve el nuevo token en la cookie HttpOnly (sobrescribe la anterior)
Si el token revocado es usado de nuevo (replay), la BD lo rechaza (no activo) → `401 Unauthorized`. Esto detecta robo y neutraliza sesiones duplicadas.
---
### [MED-6] Credenciales de seed hardcodeadas impresas en consola
**Archivo:** `infrastructure/seed/seed.js:121-123`
```javascript
console.log(' Admin: admin@omnioil.com / Admin123!')
console.log(' Operador: operador@omnioil.com / Campo123!')
```
Estas credenciales son cuentas activas si el seed se ejecuta en producción.
**Remediación:** Generar passwords aleatorias en runtime. Rechazar ejecución si `NODE_ENV=production`.
---
### [MED-7] `/api/edge/health` sin autenticación dentro de rutas protegidas
**Archivo:** `services/backend-api/src/main.rs` → ✅ **RESUELTO**
**Remediación implementada:** Movido a raíz `/health` (fuera del grupo de rutas autenticadas) y retorna `{"status":"ok"}` en JSON. Se recomienda restringir en Nginx a redes internas.
---
### [MED-8] Sin validación de complejidad de contraseña en registro
**Archivo:** `services/backend-api/src/handlers/auth.rs`
```rust
pub struct SetupRegisterPayload {
pub email: String,
pub password: String, // ← sin validación
pub full_name: String,
}
```
Cualquier contraseña (incluso `"a"`) es aceptada. Combinado con la ausencia de rate limiting (HIGH-5), el brute force es trivial.
**Remediación:** Mínimo 12 caracteres. Rechazar contraseñas del top-10,000 o de la lista HIBP.
---
### [MED-9] Subdominio `api.omnioil.app` bypasea el gatekeeper
**Archivo:** `infrastructure/nginx/gateway.conf` → ✅ **RESUELTO**
**Remediación implementada:** Eliminado completamente el server block de `api.omnioil.app`. El backend es inaccesible directamente desde exterior — todo el tráfico pasa por `app.omnioil.app/api` o `mobile.omnioil.app/api`.
---
### [MED-10] Log level por defecto es `debug` en producción
**Archivo:** `services/backend-api/src/main.rs` → ✅ **RESUELTO**
**Remediación implementada:** Cambiado fallback de `"debug"` a `"info"`. Si `RUST_LOG` no está configurado, no se registran queries SQL, payloads MQTT ni detalles de conexión.
---
## 🔵 BAJO
---
### [LOW-1] `.gitignore` solo excluye `.env` exacto
**Estado:****RESUELTO**
**Remediación implementada:** Cambiado a `.env*` con exclusión explícita `!.env.example` y `!.env.example.*`
---
### [LOW-2] MQTT admin con ACL wildcard `#` (acceso total)
**Archivo:** `services/backend-api/src/main.rs` → ✅ **RESUELTO**
**Remediación implementada:** ACL del usuario admin MQTT del backend restringida a `omnioil/#` (rw=3) y `provision/#` (rw=3). Ya no tiene wildcard total `#`.
---
### [LOW-3] Root API revela stack tecnológico
**Archivo:** `services/backend-api/src/main.rs` → ✅ **RESUELTO**
**Remediación implementada:** `root()` retorna `{"status":"ok"}` en JSON.
---
### [LOW-4] MQTT auth cache de 60 segundos
**Archivo:** `infrastructure/mosquitto/mosquitto.conf.template` → ✅ **RESUELTO**
**Remediación implementada:** `auth_opt_check_interval` reducido de 60 a 10 segundos. Las credenciales revocadas (tokens de provisioning usados, usuarios temporales eliminados) quedan inválidas en máximo 10 segundos.
---
### [LOW-5] MinIO S3 API expuesta directamente en puerto 9000
**Archivo:** `.env.example.docker`
```bash
MINIO_PUBLIC_URL=https://app.omnioil.app:9000
```
El puerto 9000 debe estar abierto en el firewall, dando acceso directo a la API S3 de MinIO sin pasar por Nginx.
**Remediación:** Routear descargas de MinIO a través de Nginx (`/storage/...``minio:9000`). Nunca abrir el puerto 9000 en producción.
---
### [LOW-6] Baked config del edge agent con XOR-only (migración incompleta)
**Archivo:** `services/edge-agent/src/config/loader.rs:23`
```rust
// TODO: Migrar Baked a AES también en Fase 3
if !BAKED_CONFIG_XOR.is_empty() {
let deobfuscated: Vec<u8> = BAKED_CONFIG_XOR.iter().map(|b| b ^ 0x5A).collect();
```
La config de fallback baked es trivialmente reversible con la constante `0x5A` visible en el código fuente.
**Remediación:** Completar la migración a AES mencionada en el TODO.
---
## INFO
---
### [INFO-1] JWT usa algoritmo HS256 simétrico
**Archivo:** `services/backend-api/src/auth.rs:57`
`Header::default()` = HS256. Si `JWT_SECRET` se filtra, todos los tokens del sistema pueden ser forjados. Considerar RS256/ES256 para mayor resistencia. Asegurar que `JWT_SECRET` sea un valor aleatorio de 256 bits, no una frase legible.
---
### [INFO-2] `Uuid::parse_str().unwrap()` puede panicar
**Archivo:** `services/backend-api/src/handlers/telemetry.rs:58`
```rust
.bind(Uuid::parse_str(&claims.sub).unwrap()) // panic si sub no es UUID válido
```
**Remediación:** Usar `map_err(|_| AppError::...)` en lugar de `unwrap()`.
---
## 📋 Plan de Implementación
### Fase 1 — Urgente (Esta semana) 🚨
Estas acciones deben completarse antes de cualquier despliegue en producción:
| # | Acción | Hallazgo | Esfuerzo |
|---|--------|----------|----------|
| 1 | Requerir `AdminClaims` en `/register` o eliminar el endpoint | CRIT-1 | ~1h |
| 2 | Rotar **todos** los secretos en el `.env` de producción (`OMNIOIL_MASTER_SECRET`, `REDIS_PASSWORD`, `JWT_SECRET`, `TUNNEL_SECRET_TOKEN`, `DB_PASSWORD`, `MQTT_PASSWORD`) con valores aleatorios únicos | CRIT-2, MED-2 | ~15 min |
| 3 | Crear `.env.example.production` con `COMPOSE_FILE=docker-compose.yml` únicamente | CRIT-3 | ~5 min |
---
### Fase 2 — Próximo sprint 🔶
Mantener integridad del sistema y cerrar vectores de ataque en la capa de aplicación:
| # | Acción | Hallazgo | Esfuerzo |
|---|--------|----------|----------|
| 4 | Agregar `Claims` a `post_telemetry`, `create_agent_log`, `ws_telemetry_handler` | HIGH-1, HIGH-2, HIGH-3 | ~2h |
| 5 | Agregar `_claims: Claims` a `download_report` y `download_report_hash` | HIGH-4 | ~30 min |
| 6 | Reactivar rate limiting con backend Redis (≥0.9 tower_governor o middleware propio) | HIGH-5 | ~4h |
| 7 | Configurar HTTPS + HSTS en Nginx con Let's Encrypt (Dokploy nativo) | HIGH-6 | ~2h |
| 8 | Agregar security headers en `gateway.conf` | HIGH-7 | ~1h |
| 9 | Mover refresh token a cookie `HttpOnly; Secure; SameSite=Strict` | HIGH-8 | ~4h |
| 10 | Eliminar server block `api.omnioil.app` o agregar protección equivalente | MED-9 | ~30 min |
| 11 | Cambiar fallback de log level a `"info"` y documentar `RUST_LOG=info` | MED-10 | ~15 min |
---
### Fase 3 — Mes siguiente 🔵
Hardening profundo del sistema de provisioning y mejoras de robustez:
| # | Acción | Hallazgo | Esfuerzo |
|---|--------|----------|----------|
| 12 | Rediseñar provisioning de edge agent con criptografía asimétrica (keypair por binario) | CRIT-2 largo plazo | ~2 semanas |
| 13 | Aumentar PBKDF2 a ≥210,000 iteraciones + salt aleatorio por binario → migrar a Argon2id | MED-1 | ~1 día |
| 14 | Implementar rotación de refresh tokens con detección de replay | MED-5 | ~1 día |
| 15 | Eliminar credenciales hardcodeadas del seed, generar en runtime | MED-6 | ~2h |
| 16 | Restricciones de CORS (métodos y headers específicos) | MED-3 | ~30 min |
| 17 | Habilitar SSL en conexión Mosquitto → PostgreSQL (`pg_sslmode verify-full`) | MED-4 | ~1h |
| 18 | Validación de complejidad de contraseña en registro | MED-8 | ~2h |
| 19 | Completar migración de baked config a AES (TODO Phase 3) | LOW-6 | ~4h |
---
### Fase 4 — Fixes menores ⚪
| # | Acción | Hallazgo |
|---|--------|----------|
| 20 | `.gitignore`: cambiar `.env``.env*` + `!.env.example*` | LOW-1 |
| 21 | MQTT ACL admin: restringir de `#` a `omnioil/#` | LOW-2 |
| 22 | Root API: retornar `{"status":"ok"}` | LOW-3 |
| 23 | MQTT auth cache: reducir de 60s a 0 o 10 | LOW-4 |
| 24 | MinIO: routear por Nginx, cerrar puerto 9000 | LOW-5 |
| 25 | Telemetry handler: reemplazar `.unwrap()` con `map_err` | INFO-2 |
---
## Resumen Ejecutivo
| Severidad | Cantidad | Estado |
|-----------|----------|--------|
| 🔴 Crítico | 3 | ⚠️ Sin remediar |
| 🟠 Alto | 8 | ⚠️ Sin remediar |
| 🟡 Medio | 10 | ⚠️ Sin remediar |
| 🔵 Bajo | 6 | ⚠️ Sin remediar |
| Info | 2 | — |
| **Total** | **29** | |
> **Nota:** Los hallazgos marcados en el documento anterior `SECURITY_HARDENING_REPORT.md` (JWT en endpoints, multi-tenant enforcement, etc.) están correctamente implementados y no se duplican aquí. Este informe cubre únicamente vulnerabilidades activas no remediadas.

BIN
SECURITY_AUDIT_REPORT.pdf Normal file

Binary file not shown.

View File

@@ -19,6 +19,8 @@ services:
# Exponer backend para pruebas con Postman/Insomnia # Exponer backend para pruebas con Postman/Insomnia
backend-api: backend-api:
environment:
- APP_ENV=development
ports: ports:
- "8000:8000" - "8000:8000"

View File

@@ -69,6 +69,11 @@ services:
- AWS_ACCESS_KEY_ID=${MINIO_USER} - AWS_ACCESS_KEY_ID=${MINIO_USER}
- AWS_SECRET_ACCESS_KEY=${MINIO_PASSWORD} - AWS_SECRET_ACCESS_KEY=${MINIO_PASSWORD}
- ALLOWED_ORIGINS=${ALLOWED_ORIGINS} - ALLOWED_ORIGINS=${ALLOWED_ORIGINS}
- APP_ENV=production
- MQTT_PUBLIC_HOST=${MQTT_PUBLIC_HOST}
- MQTT_PUBLIC_PORT=${MQTT_PUBLIC_PORT:-1883}
- MQTT_USE_WS=${MQTT_USE_WS:-false}
- APP_PUBLIC_URL=${APP_PUBLIC_URL}
expose: expose:
- "8000" - "8000"
depends_on: depends_on:

View File

@@ -35,7 +35,7 @@ auth_opt_pg_aclquery SELECT topic FROM mqtt_acls WHERE username = $1 AND rw >= $
# General Plugin Options # General Plugin Options
auth_opt_pg_sslmode disable auth_opt_pg_sslmode disable
auth_opt_check_interval 60 auth_opt_check_interval 10
auth_opt_hasher bcrypt auth_opt_hasher bcrypt
auth_opt_log_level info auth_opt_log_level info

View File

@@ -1,8 +1,19 @@
# Nginx Gateway: Subdomain Routing for OmniOil SaaS Ecosystem # Nginx Gateway: Subdomain Routing for OmniOil SaaS Ecosystem
# ─── Bloque de cabeceras de seguridad compartidas (HIGH-7) ────────────────────
map $sent_http_content_type $csp_header {
default "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self' wss:; frame-ancestors 'none';";
}
server { server {
listen 80; listen 80;
server_name omnioil.app www.omnioil.app localhost; server_name omnioil.app www.omnioil.app localhost;
add_header X-Frame-Options "DENY" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
location / { location / {
proxy_pass http://landing:80; proxy_pass http://landing:80;
proxy_set_header Host $host; proxy_set_header Host $host;
@@ -14,11 +25,22 @@ server {
listen 80; listen 80;
server_name app.omnioil.app; server_name app.omnioil.app;
add_header X-Frame-Options "DENY" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
add_header Content-Security-Policy $csp_header always;
# Dokploy gestiona TLS en el puerto 443. El browser accede siempre por HTTPS.
# HSTS le indica al browser que solo use HTTPS para este dominio (HIGH-6).
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always;
location /api { location /api {
proxy_pass http://backend-api:8000; proxy_pass http://backend-api:8000;
proxy_set_header Host $host; proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto https;
# Soporte para WebSockets # Soporte para WebSockets
proxy_http_version 1.1; proxy_http_version 1.1;
@@ -46,11 +68,20 @@ server {
listen 80; listen 80;
server_name mobile.omnioil.app; server_name mobile.omnioil.app;
add_header X-Frame-Options "DENY" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
add_header Content-Security-Policy $csp_header always;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always;
location /api { location /api {
proxy_pass http://backend-api:8000; proxy_pass http://backend-api:8000;
proxy_set_header Host $host; proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto https;
# Soporte para WebSockets # Soporte para WebSockets
proxy_http_version 1.1; proxy_http_version 1.1;
@@ -74,16 +105,6 @@ server {
} }
} }
# Proxy pass for Backend API (Global) # api.omnioil.app eliminado (MED-9): el backend nunca debe estar
server { # expuesto directamente al exterior. Todo el tráfico pasa por los
listen 80; # bloques app.omnioil.app / mobile.omnioil.app con el prefijo /api.
server_name api.omnioil.app;
location / {
resolver 127.0.0.11 valid=30s;
set $backend_upstream backend-api;
proxy_pass http://$backend_upstream:8000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
}

View File

@@ -0,0 +1,33 @@
-- =============================================================================
-- CRIT-2 Fix: Agent Asymmetric Provisioning
-- Elimina la necesidad de embeber OMNIOIL_MASTER_SECRET en cada binario.
-- El agente genera un keypair P256 en su primer arranque y recibe la config
-- cifrada con su clave pública a través de este endpoint.
-- =============================================================================
-- Token de un solo uso que va embebido en el binario (sin secretos).
-- El token autentica el primer registro del agente sin exponer credenciales.
CREATE TABLE IF NOT EXISTS agent_provisioning_tokens (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
project_id UUID NOT NULL REFERENCES edge_projects(id) ON DELETE CASCADE,
token TEXT NOT NULL UNIQUE,
used BOOLEAN NOT NULL DEFAULT false,
expires_at TIMESTAMPTZ NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_provisioning_tokens_project ON agent_provisioning_tokens(project_id);
CREATE INDEX idx_provisioning_tokens_token ON agent_provisioning_tokens(token);
-- Clave pública P256 del agente, registrada en el primer provisioning.
-- El servidor usa esta clave para cifrar futuros re-aprovisionamientos.
CREATE TABLE IF NOT EXISTS agent_public_keys (
project_id UUID PRIMARY KEY REFERENCES edge_projects(id) ON DELETE CASCADE,
public_key_pem TEXT NOT NULL,
registered_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Configuración TOML del agente, generada por el build-orchestrator y
-- almacenada server-side. NUNCA se embebe en texto claro en el binario.
ALTER TABLE edge_projects ADD COLUMN IF NOT EXISTS agent_config_toml TEXT;

View File

@@ -0,0 +1,20 @@
-- Agrega la ACL faltante para el topic omnioil/status/{project_id} a todos los agentes existentes.
--
-- Contexto: el LWT del agente usa omnioil/status/{project_id}. Mosquitto valida el topic del LWT
-- contra la ACL en el paquete CONNECT — si el usuario no tiene permiso de publish sobre ese topic,
-- rechaza la conexión con NotAuthorized (MQTT return code 5).
--
-- El installer_worker.rs ya fue corregido para incluir este topic en despliegues nuevos.
-- Esta migración backfills todos los proyectos ya creados.
INSERT INTO mqtt_acls (username, topic, rw)
SELECT
p.id::text AS username,
'omnioil/status/' || p.id::text AS topic,
7 AS rw
FROM edge_projects p
WHERE p.mqtt_password IS NOT NULL -- solo proyectos que ya tienen usuario MQTT creado
AND EXISTS (
SELECT 1 FROM mqtt_users mu WHERE mu.username = p.id::text
)
ON CONFLICT (username, topic) DO UPDATE SET rw = 7;

View File

@@ -0,0 +1,9 @@
-- Fix provisioning ACL: upgrade response topics from rw=1 to rw=4.
-- mosquitto v2 + go-auth pasa MOSQ_ACL_SUBSCRIBE (access=4) al verificar suscripciones.
-- Con rw=1 y el query `rw >= $2`, un check de subscribe falla (1 >= 4 es falso),
-- lo que causa que el agente no pueda suscribirse al topic de respuesta y el
-- provisioning quede colgado / falle con "Error durante el provisioning MQTT".
UPDATE mqtt_acls
SET rw = 4
WHERE topic LIKE 'provision/response/%'
AND rw = 1;

View File

@@ -0,0 +1,12 @@
-- Corrige ACLs del usuario de sistema del backend.
--
-- go-auth con Mosquitto v2 usa MOSQ_ACL_SUBSCRIBE=4 para el check de subscribe.
-- rw=3 (read+write) falla el check de subscribe (3 >= 4 es FALSE).
-- rw=7 (read+write+subscribe) pasa todos los checks.
--
-- ON CONFLICT DO UPDATE asegura que instancias ya desplegadas también se actualicen.
UPDATE mqtt_acls
SET rw = 7
WHERE topic IN ('omnioil/#', 'provision/#')
AND rw < 7;

View File

@@ -46,6 +46,7 @@ pub async fn get_report(
/// GET /api/anh/reports/:id/download — Descargar el archivo JSON. /// GET /api/anh/reports/:id/download — Descargar el archivo JSON.
pub async fn download_report( pub async fn download_report(
State(pool): State<PgPool>, State(pool): State<PgPool>,
_claims: Claims,
Path(id): Path<Uuid>, Path(id): Path<Uuid>,
) -> Result<(HeaderMap, String), AppError> { ) -> Result<(HeaderMap, String), AppError> {
let report = sqlx::query_as::<_, ANHReport>( let report = sqlx::query_as::<_, ANHReport>(
@@ -77,6 +78,7 @@ pub async fn download_report(
/// GET /api/anh/reports/:id/hash — Descargar el hash SHA-384 del archivo. /// GET /api/anh/reports/:id/hash — Descargar el hash SHA-384 del archivo.
pub async fn download_report_hash( pub async fn download_report_hash(
State(pool): State<PgPool>, State(pool): State<PgPool>,
_claims: Claims,
Path(id): Path<Uuid>, Path(id): Path<Uuid>,
) -> Result<(HeaderMap, String), AppError> { ) -> Result<(HeaderMap, String), AppError> {
let report = sqlx::query_as::<_, ANHReport>( let report = sqlx::query_as::<_, ANHReport>(

View File

@@ -2,11 +2,41 @@ use crate::{
auth::{AuthError, create_jwt_tokens}, auth::{AuthError, create_jwt_tokens},
db::DbPool, db::DbPool,
}; };
use argon2::{Argon2, password_hash::{PasswordHash, PasswordVerifier}}; use argon2::{Argon2, Algorithm, Version, Params, password_hash::{PasswordHash, PasswordVerifier}};
use axum::{Json, extract::State, response::IntoResponse}; use axum::{Json, extract::State, http::{HeaderMap, header::SET_COOKIE}, response::IntoResponse};
use serde::Deserialize; use serde::Deserialize;
use serde_json::json; use serde_json::json;
/// Construye el cookie de refresh_token con flags de seguridad.
///
/// - `HttpOnly` — inaccesible desde JavaScript (mitiga XSS)
/// - `Secure` — solo HTTPS (activado vía env APP_ENV=production)
/// - `SameSite=Strict` — no se envía en peticiones cross-site (mitiga CSRF)
/// - `Path=/api/auth` — solo se envía a los endpoints de autenticación
fn build_refresh_cookie(token: &str, max_age_secs: i64) -> String {
let secure = std::env::var("APP_ENV")
.map(|e| e == "production")
.unwrap_or(true);
let secure_flag = if secure { "; Secure" } else { "" };
format!(
"refresh_token={}; HttpOnly{}; SameSite=Strict; Path=/api/auth; Max-Age={}",
token, secure_flag, max_age_secs
)
}
/// Extrae el refresh_token de la cookie de la request.
fn extract_refresh_cookie(headers: &HeaderMap) -> Option<String> {
headers
.get("Cookie")
.and_then(|v| v.to_str().ok())
.and_then(|cookies| {
cookies.split(';').find_map(|s| {
let s = s.trim();
s.strip_prefix("refresh_token=").map(|v| v.to_string())
})
})
}
#[derive(Deserialize)] #[derive(Deserialize)]
pub struct LoginPayload { pub struct LoginPayload {
email: String, email: String,
@@ -138,41 +168,66 @@ pub async fn login(
AuthError::Internal(anyhow::anyhow!("Error de sesión")) AuthError::Internal(anyhow::anyhow!("Error de sesión"))
})?; })?;
return Ok(Json(json!({ // 4. Responder con access_token en JSON + refresh_token como HttpOnly cookie (HIGH-8)
let cookie = build_refresh_cookie(&tokens.refresh_token, 7 * 24 * 3600);
let mut headers = HeaderMap::new();
headers.insert(SET_COOKIE, cookie.parse().unwrap());
return Ok((
headers,
Json(json!({
"access_token": tokens.access_token, "access_token": tokens.access_token,
"refresh_token": tokens.refresh_token, "token": tokens.access_token, // Compatibilidad con clientes legacy
"token": tokens.access_token, // Compatibilidad
"role": user.role_name, "role": user.role_name,
"email": user.email,
"projects": projects "projects": projects
}))); })),
)
.into_response());
} }
} }
Err(AuthError::InvalidCredentials) Err(AuthError::InvalidCredentials)
} }
#[derive(Deserialize)] #[derive(Deserialize, Default)]
pub struct RefreshPayload { pub struct RefreshPayload {
pub refresh_token: String, /// Campo legacy — el cliente preferido usa la cookie HttpOnly.
pub refresh_token: Option<String>,
} }
/// Refresca el access_token usando el refresh_token.
///
/// El token se acepta desde la cookie HttpOnly `refresh_token` (preferido, HIGH-8)
/// o desde el body JSON (compatibilidad legacy).
///
/// Implementa rotación de refresh token (MED-5): el token usado se revoca
/// y se emite uno nuevo en la cookie.
pub async fn refresh( pub async fn refresh(
State(pool): State<DbPool>, State(pool): State<DbPool>,
Json(payload): Json<RefreshPayload>, request_headers: axum::http::HeaderMap,
body: Option<Json<RefreshPayload>>,
) -> Result<impl IntoResponse, AuthError> { ) -> Result<impl IntoResponse, AuthError> {
use crate::auth::verify_refresh_jwt; use crate::auth::verify_refresh_jwt;
use sha2::{Digest, Sha256}; use sha2::{Digest, Sha256};
// 1. Validar el JWT del refresh token (estructura y expiración básica) // Prioridad: cookie HttpOnly > body JSON
let token_data = let refresh_token_str = extract_refresh_cookie(&request_headers)
verify_refresh_jwt(&payload.refresh_token).map_err(|_| AuthError::InvalidCredentials)?; .or_else(|| body.and_then(|b| b.0.refresh_token))
.ok_or(AuthError::InvalidCredentials)?;
// 2. Verificar el hash contra la base de datos // 1. Validar JWT del refresh token
let token_data = verify_refresh_jwt(&refresh_token_str)
.map_err(|_| AuthError::InvalidCredentials)?;
// 2. Verificar hash en DB
let mut hasher = Sha256::new(); let mut hasher = Sha256::new();
hasher.update(payload.refresh_token.as_bytes()); hasher.update(refresh_token_str.as_bytes());
let refresh_hash = format!("{:x}", hasher.finalize()); let refresh_hash = format!("{:x}", hasher.finalize());
let stored = sqlx::query("SELECT user_id FROM refresh_tokens WHERE token_hash = $1 AND revoked_at IS NULL AND expires_at > NOW()") let stored = sqlx::query(
"SELECT user_id FROM refresh_tokens WHERE token_hash = $1 AND revoked_at IS NULL AND expires_at > NOW()"
)
.bind(&refresh_hash) .bind(&refresh_hash)
.fetch_optional(&pool) .fetch_optional(&pool)
.await .await
@@ -181,8 +236,11 @@ pub async fn refresh(
AuthError::Internal(anyhow::anyhow!("Error de validación")) AuthError::Internal(anyhow::anyhow!("Error de validación"))
})?; })?;
if let Some(_) = stored { if stored.is_none() {
// En un flujo real, obtendríamos los datos frescos del usuario y sus proyectos return Err(AuthError::InvalidCredentials);
}
// 3. Datos frescos del usuario
let user_id = uuid::Uuid::parse_str(&token_data.claims.sub) let user_id = uuid::Uuid::parse_str(&token_data.claims.sub)
.map_err(|_| AuthError::InvalidCredentials)?; .map_err(|_| AuthError::InvalidCredentials)?;
@@ -194,6 +252,10 @@ pub async fn refresh(
.await .await
.map_err(|_| AuthError::InvalidCredentials)?; .map_err(|_| AuthError::InvalidCredentials)?;
if !user.is_active {
return Err(AuthError::AccountDisabled);
}
let projects = sqlx::query_as::<sqlx::Postgres, ProjectSummary>( let projects = sqlx::query_as::<sqlx::Postgres, ProjectSummary>(
r#" r#"
SELECT ep.id, ep.name SELECT ep.id, ep.name
@@ -212,17 +274,85 @@ pub async fn refresh(
let tokens = create_jwt_tokens(&user.id.to_string(), &user.role_name, &user.email, project_ids) let tokens = create_jwt_tokens(&user.id.to_string(), &user.role_name, &user.email, project_ids)
.map_err(|_| AuthError::Internal(anyhow::anyhow!("Error al refrescar")))?; .map_err(|_| AuthError::Internal(anyhow::anyhow!("Error al refrescar")))?;
// Opcional: Rotar el refresh token aquí si se desea mayor seguridad // 4. Rotación del refresh token (MED-5): revocar el anterior, insertar el nuevo
let new_refresh_hash = {
let mut h = Sha256::new();
h.update(tokens.refresh_token.as_bytes());
format!("{:x}", h.finalize())
};
let mut tx = pool.begin().await
.map_err(|e| AuthError::Internal(anyhow::anyhow!("DB error: {}", e)))?;
return Ok(Json(json!({ sqlx::query("UPDATE refresh_tokens SET revoked_at = NOW() WHERE token_hash = $1")
.bind(&refresh_hash)
.execute(&mut *tx)
.await
.map_err(|e| AuthError::Internal(anyhow::anyhow!("Revoke error: {}", e)))?;
sqlx::query("INSERT INTO refresh_tokens (user_id, token_hash, expires_at) VALUES ($1, $2, $3)")
.bind(user.id)
.bind(new_refresh_hash)
.bind(chrono::Utc::now() + chrono::Duration::days(7))
.execute(&mut *tx)
.await
.map_err(|e| AuthError::Internal(anyhow::anyhow!("Insert error: {}", e)))?;
tx.commit().await
.map_err(|e| AuthError::Internal(anyhow::anyhow!("Commit error: {}", e)))?;
// 5. Responder con nuevo access_token + cookie rotada
let cookie = build_refresh_cookie(&tokens.refresh_token, 7 * 24 * 3600);
let mut resp_headers = HeaderMap::new();
resp_headers.insert(SET_COOKIE, cookie.parse().unwrap());
Ok((
resp_headers,
Json(json!({
"access_token": tokens.access_token, "access_token": tokens.access_token,
"token": tokens.access_token, "token": tokens.access_token,
"role": user.role_name, "role": user.role_name,
"email": user.email,
"projects": projects "projects": projects
}))); })),
)
.into_response())
}
/// Cierra la sesión revocando el refresh_token y borrando la cookie del browser.
pub async fn logout(
State(pool): State<DbPool>,
request_headers: axum::http::HeaderMap,
) -> impl IntoResponse {
use sha2::{Digest, Sha256};
if let Some(token) = extract_refresh_cookie(&request_headers) {
let mut hasher = Sha256::new();
hasher.update(token.as_bytes());
let hash = format!("{:x}", hasher.finalize());
let _ = sqlx::query("UPDATE refresh_tokens SET revoked_at = NOW() WHERE token_hash = $1")
.bind(hash)
.execute(&pool)
.await;
} }
Err(AuthError::InvalidCredentials) let clear_cookie = build_refresh_cookie("", 0);
let mut headers = HeaderMap::new();
headers.insert(SET_COOKIE, clear_cookie.parse().unwrap());
(headers, Json(json!({"status": "ok"}))).into_response()
}
/// Helper: hashea contraseñas con Argon2id y parámetros OWASP 2023
/// (m=64 MiB, t=3, p=1 — supera el mínimo recomendado m=19 MiB, t=2, p=1).
fn hash_password(password: &str) -> Result<String, AuthError> {
use argon2::password_hash::{rand_core::OsRng, SaltString, PasswordHasher};
let params = Params::new(65536, 3, 1, None)
.map_err(|e| AuthError::Internal(anyhow::anyhow!("Argon2 params: {}", e)))?;
let argon2 = Argon2::new(Algorithm::Argon2id, Version::V0x13, params);
let salt = SaltString::generate(&mut OsRng);
argon2
.hash_password(password.as_bytes(), &salt)
.map(|h| h.to_string())
.map_err(|e| AuthError::Internal(anyhow::anyhow!("Hash error: {}", e)))
} }
pub async fn register( pub async fn register(
@@ -243,15 +373,8 @@ pub async fn register(
return Err(AuthError::Internal(anyhow::anyhow!("El correo electrónico ya está registrado"))); return Err(AuthError::Internal(anyhow::anyhow!("El correo electrónico ya está registrado")));
} }
// 2. Hash password (Argon2) // 2. Hash password con Argon2id (parámetros OWASP 2023)
use argon2::{Argon2, password_hash::{self, SaltString, PasswordHasher}}; let password_hash = hash_password(&payload.password)?;
let salt = SaltString::generate(&mut password_hash::rand_core::OsRng);
let argon2 = Argon2::default();
let password_hash = argon2
.hash_password(payload.password.as_bytes(), &salt)
.map_err(|e| AuthError::Internal(anyhow::anyhow!("Hash error: {}", e)))?
.to_string();
// 3. Obtener Role ID (asignamos admin para que puedan crear sus propios proyectos) // 3. Obtener Role ID (asignamos admin para que puedan crear sus propios proyectos)
let role_id: i32 = sqlx::query_scalar("SELECT id FROM roles WHERE name = 'admin'") let role_id: i32 = sqlx::query_scalar("SELECT id FROM roles WHERE name = 'admin'")
@@ -327,15 +450,8 @@ pub async fn setup_register(
return Err(AuthError::RegistrationDisabled); return Err(AuthError::RegistrationDisabled);
} }
// 2. Hash password (Argon2 local) // 2. Hash password con Argon2id (parámetros OWASP 2023)
use argon2::{Argon2, password_hash::{self, SaltString, PasswordHasher}}; let password_hash = hash_password(&payload.password)?;
let salt = SaltString::generate(&mut password_hash::rand_core::OsRng);
let argon2 = Argon2::default();
let password_hash = argon2
.hash_password(payload.password.as_bytes(), &salt)
.map_err(|e| AuthError::Internal(anyhow::anyhow!("Hash error: {}", e)))?
.to_string();
// 3. Obtener Role ID de admin // 3. Obtener Role ID de admin
let role_id: i32 = sqlx::query_scalar("SELECT id FROM roles WHERE name = 'admin'") let role_id: i32 = sqlx::query_scalar("SELECT id FROM roles WHERE name = 'admin'")

View File

@@ -72,9 +72,14 @@ pub async fn list_agent_logs(
Ok(Json(logs)) Ok(Json(logs))
} }
/// POST /api/edge/agents/logs /// POST /api/edge/agents/logs (solo frontend/admin con JWT)
///
/// Los agentes envían sus logs exclusivamente por MQTT (`omnioil/logs/{project_id}`).
/// El backend los consume del broker e inserta en la DB directamente.
/// Este endpoint HTTP es solo para uso administrativo desde el frontend.
pub async fn create_agent_log( pub async fn create_agent_log(
State(pool): State<PgPool>, State(pool): State<PgPool>,
_claims: Claims,
Json(req): Json<CreateAgentLogRequest>, Json(req): Json<CreateAgentLogRequest>,
) -> Result<StatusCode, AppError> { ) -> Result<StatusCode, AppError> {
sqlx::query( sqlx::query(

View File

@@ -478,6 +478,311 @@ pub async fn deploy_project(
}))) })))
} }
/// GET /api/edge/projects/:id/linux-install — Genera script de instalación Linux.
///
/// Crea un nuevo token de provisioning y devuelve un script bash listo para
/// ejecutar en el dispositivo de campo: curl -sSL <url> | sudo bash
pub async fn linux_install_script(
State(pool): State<PgPool>,
claims: Claims,
Path(id): Path<Uuid>,
) -> Result<Response, (StatusCode, String)> {
// Verificar acceso al proyecto
let access = sqlx::query(
"SELECT 1 FROM user_project_access WHERE user_id = $1 AND project_id = $2 AND is_active = true"
)
.bind(Uuid::parse_str(&claims.sub).unwrap())
.bind(id)
.fetch_optional(&pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
if access.is_none() {
return Err((StatusCode::FORBIDDEN, "Acceso denegado a este proyecto".to_string()));
}
// Verificar que el proyecto existe
let project = sqlx::query_as::<_, EdgeProject>("SELECT * FROM edge_projects WHERE id = $1")
.bind(id)
.fetch_optional(&pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
.ok_or((StatusCode::NOT_FOUND, "Proyecto no encontrado".to_string()))?;
// Generar token de provisioning
let provisioning_token = uuid::Uuid::new_v4().to_string();
let token_expires_at = chrono::Utc::now() + chrono::Duration::days(7);
// Guardar token en BD
sqlx::query(
r#"INSERT INTO agent_provisioning_tokens (project_id, token, expires_at)
VALUES ($1, $2, $3)"#,
)
.bind(id)
.bind(&provisioning_token)
.bind(token_expires_at)
.execute(&pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
// Crear/renovar usuario MQTT de provisioning temporal
let prov_mqtt_user = format!("provision:{}", id);
sqlx::query(
"INSERT INTO mqtt_users (username, password_hash)
VALUES ($1, crypt($2, gen_salt('bf', 10)))
ON CONFLICT (username) DO UPDATE SET password_hash = EXCLUDED.password_hash",
)
.bind(&prov_mqtt_user)
.bind(&provisioning_token)
.execute(&pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
// ACLs de provisioning
let prov_acls = vec![
(format!("provision/request/{}", id), 2i32),
(format!("provision/response/{}", id), 4i32),
];
for (topic, rw) in prov_acls {
sqlx::query(
"INSERT INTO mqtt_acls (username, topic, rw) VALUES ($1, $2, $3)
ON CONFLICT (username, topic) DO UPDATE SET rw = $3",
)
.bind(&prov_mqtt_user)
.bind(&topic)
.bind(rw)
.execute(&pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
}
let mqtt_host = std::env::var("MQTT_PUBLIC_HOST").unwrap_or_else(|_| "localhost".to_string());
let mqtt_port = std::env::var("MQTT_PUBLIC_PORT").unwrap_or_else(|_| "1883".to_string());
let mqtt_use_ws = std::env::var("MQTT_USE_WS").unwrap_or_else(|_| "false".to_string());
let server_url = std::env::var("APP_PUBLIC_URL")
.or_else(|_| std::env::var("BACKEND_API_URL"))
.unwrap_or_else(|_| "https://tu-servidor.omnioil.io".to_string());
let script = generate_install_script(
&id.to_string(),
&provisioning_token,
&mqtt_host,
&mqtt_port,
&mqtt_use_ws,
&server_url,
&project.name,
);
Ok((
[
(header::CONTENT_TYPE, "text/x-shellscript; charset=utf-8"),
(
header::CONTENT_DISPOSITION,
&format!("attachment; filename=\"install-omnioil-{}.sh\"", &id.to_string()[..8]),
),
],
script,
)
.into_response())
}
fn generate_install_script(
project_id: &str,
token: &str,
mqtt_host: &str,
mqtt_port: &str,
mqtt_use_ws: &str,
server_url: &str,
project_name: &str,
) -> String {
format!(r#"#!/bin/bash
# =============================================================================
# OmniOil Edge Agent — Instalador Linux
# Proyecto: {project_name}
# Token válido por 7 días. Este script es de un solo uso.
# =============================================================================
set -euo pipefail
PROJECT_ID="{project_id}"
PROVISIONING_TOKEN="{token}"
MQTT_HOST="{mqtt_host}"
MQTT_PORT={mqtt_port}
MQTT_USE_WS={mqtt_use_ws}
SERVER_URL="{server_url}"
INSTALL_DIR="/usr/local/bin"
DATA_DIR="/var/lib/omnioil/agent"
LOG_DIR="/var/log/omnioil"
SERVICE_USER="omnioil-agent"
SERVICE_NAME="omnioil-edge-agent"
BINARY_NAME="edge-agent"
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; NC='\033[0m'
info() {{ echo -e "${{GREEN}}✅ $1${{NC}}"; }}
warn() {{ echo -e "${{YELLOW}}⚠️ $1${{NC}}"; }}
error() {{ echo -e "${{RED}}❌ $1${{NC}}"; exit 1; }}
[ "$EUID" -ne 0 ] && error "Ejecutar con sudo: sudo bash install.sh"
ARCH=$(uname -m)
case "$ARCH" in
x86_64) ARCH_TAG="x86_64" ;;
aarch64|arm64) ARCH_TAG="aarch64" ;;
*) error "Arquitectura no soportada: $ARCH" ;;
esac
echo ""
echo "🚀 OmniOil Edge Agent — Instalando para $ARCH..."
echo " Proyecto: $PROJECT_ID"
echo ""
# Crear usuario del servicio
if ! id "$SERVICE_USER" &>/dev/null; then
useradd --system --no-create-home --shell /usr/sbin/nologin "$SERVICE_USER" 2>/dev/null || \
useradd --system --no-create-home --shell /sbin/nologin "$SERVICE_USER"
info "Usuario del servicio creado: $SERVICE_USER"
else
info "Usuario del servicio ya existe: $SERVICE_USER"
fi
# Crear directorios con permisos seguros
mkdir -p "$DATA_DIR" "$LOG_DIR"
chmod 700 "$DATA_DIR"
chmod 755 "$LOG_DIR"
chown "$SERVICE_USER:$SERVICE_USER" "$DATA_DIR" "$LOG_DIR"
info "Directorio de datos: $DATA_DIR (chmod 700)"
# Descargar binario del agente
echo "📥 Descargando binario ($ARCH_TAG)..."
BINARY_URL="${{SERVER_URL}}/api/edge/agent/linux-binary/${{ARCH_TAG}}"
if command -v curl &>/dev/null; then
curl -sSfL -o "${{INSTALL_DIR}}/${{BINARY_NAME}}" "$BINARY_URL" || error "Descarga fallida: $BINARY_URL"
elif command -v wget &>/dev/null; then
wget -qO "${{INSTALL_DIR}}/${{BINARY_NAME}}" "$BINARY_URL" || error "Descarga fallida: $BINARY_URL"
else
error "Se requiere curl o wget para descargar el binario"
fi
chmod 755 "${{INSTALL_DIR}}/${{BINARY_NAME}}"
chown root:root "${{INSTALL_DIR}}/${{BINARY_NAME}}"
info "Binario instalado en ${{INSTALL_DIR}}/${{BINARY_NAME}}"
# Escribir stub de provisioning (agente lo lee en el primer arranque)
cat > "${{DATA_DIR}}/provisioning_stub.json" << STUB_EOF
{{
"mqtt_host": "${{MQTT_HOST}}",
"mqtt_port": ${{MQTT_PORT}},
"use_ws": ${{MQTT_USE_WS}},
"project_id": "${{PROJECT_ID}}",
"provisioning_token": "${{PROVISIONING_TOKEN}}"
}}
STUB_EOF
chmod 600 "${{DATA_DIR}}/provisioning_stub.json"
chown "$SERVICE_USER:$SERVICE_USER" "${{DATA_DIR}}/provisioning_stub.json"
info "Stub de provisioning escrito (se elimina automáticamente tras el primer arranque)"
# Crear servicio systemd
cat > "/etc/systemd/system/${{SERVICE_NAME}}.service" << SERVICE_EOF
[Unit]
Description=OmniOil Edge Agent ($PROJECT_ID)
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=$SERVICE_USER
ExecStart=$INSTALL_DIR/$BINARY_NAME
Restart=on-failure
RestartSec=10s
StandardOutput=journal
StandardError=journal
SyslogIdentifier=$SERVICE_NAME
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=$DATA_DIR $LOG_DIR
AmbientCapabilities=
[Install]
WantedBy=multi-user.target
SERVICE_EOF
systemctl daemon-reload
systemctl enable "$SERVICE_NAME"
systemctl start "$SERVICE_NAME"
echo ""
info "OmniOil Edge Agent instalado y ejecutándose."
echo ""
echo " Estado: sudo systemctl status $SERVICE_NAME"
echo " Logs: sudo journalctl -u $SERVICE_NAME -f"
echo " Parar: sudo systemctl stop $SERVICE_NAME"
echo ""
"#,
project_name = project_name,
project_id = project_id,
token = token,
mqtt_host = mqtt_host,
mqtt_port = mqtt_port,
mqtt_use_ws = mqtt_use_ws,
server_url = server_url,
)
}
/// GET /api/edge/agent/linux-binary/:arch — Descarga binario Linux del agente.
/// El binario es genérico (mismo para todos los proyectos). Requiere JWT.
pub async fn download_linux_binary(
_claims: Claims,
Path(arch): Path<String>,
) -> Result<Response, (StatusCode, String)> {
let arch_tag = match arch.as_str() {
"x86_64" | "amd64" => "x86_64",
"aarch64" | "arm64" => "aarch64",
_ => return Err((StatusCode::BAD_REQUEST, format!("Arquitectura no soportada: {}", arch))),
};
let endpoint_url = std::env::var("MINIO_ENDPOINT_URL")
.expect("MINIO_ENDPOINT_URL must be set");
let access_key = std::env::var("AWS_ACCESS_KEY_ID")
.or_else(|_| std::env::var("MINIO_USER"))
.expect("MinIO credentials must be set");
let secret_key = std::env::var("AWS_SECRET_ACCESS_KEY")
.or_else(|_| std::env::var("MINIO_PASSWORD"))
.expect("MinIO credentials must be set");
let credentials = aws_sdk_s3::config::Credentials::new(
access_key, secret_key, None, None, "minio",
);
let config = aws_config::defaults(aws_config::BehaviorVersion::latest())
.region(aws_sdk_s3::config::Region::new("us-east-1"))
.endpoint_url(&endpoint_url)
.credentials_provider(credentials)
.load()
.await;
let s3_config = aws_sdk_s3::config::Builder::from(&config)
.force_path_style(true)
.build();
let client = aws_sdk_s3::Client::from_conf(s3_config);
let key = format!("agents/linux/{}/edge-agent", arch_tag);
let get_res = client.get_object()
.bucket("installers")
.key(&key)
.send()
.await
.map_err(|e| (StatusCode::NOT_FOUND, format!("Binario Linux no disponible aún: {}", e)))?;
let bytes = get_res.body.collect().await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
Ok((
[
(header::CONTENT_TYPE, "application/octet-stream"),
(header::CONTENT_DISPOSITION, "attachment; filename=\"edge-agent\""),
],
bytes.into_bytes(),
).into_response())
}
/// POST /api/edge/projects/:id/rebuild — Forzar la reconstrucción del instalador (MSI). /// POST /api/edge/projects/:id/rebuild — Forzar la reconstrucción del instalador (MSI).
pub async fn rebuild_installer( pub async fn rebuild_installer(
State(pool): State<PgPool>, State(pool): State<PgPool>,

View File

@@ -10,6 +10,7 @@ pub mod edge_variables;
pub mod lab_results; pub mod lab_results;
pub mod meters; pub mod meters;
pub mod operations_movements; pub mod operations_movements;
pub mod provisioning;
pub mod telemetry; pub mod telemetry;
pub mod well_tests; pub mod well_tests;
pub mod audit_helper; pub mod audit_helper;

View File

@@ -0,0 +1,238 @@
//! Provisioning asimétrico para agentes de campo — **flujo MQTT**.
//!
//! El backend nunca es accesible desde el exterior. Todo el provisioning va por el
//! broker MQTT, que sí está expuesto públicamente.
//!
//! Flujo:
//! 1. El agente arranca con un `ProvisioningStub` embebido (mqtt_host + token).
//! 2. Se conecta al broker con `provision:{project_id}` / `{token}` (usuario temporal).
//! 3. Publica su clave pública P256 en `provision/request/{project_id}`.
//! 4. Este handler (invocado desde el event loop MQTT del backend) valida el token,
//! cifra la config con ECIES y responde en `provision/response/{project_id}`.
//! 5. Limpia el usuario MQTT temporal.
//! 6. Solo el agente puede descifrar la respuesta con su clave privada.
use chrono::Utc;
use serde::{Deserialize, Serialize};
use sqlx::PgPool;
use uuid::Uuid;
use axum::{Json, extract::State, http::StatusCode};
// ─── Mensajes MQTT ────────────────────────────────────────────────────────────
#[derive(Debug, Deserialize)]
struct MqttProvisioningRequest {
/// Clave pública P256 del agente en formato PKCS#8 PEM.
pub public_key_pem: String,
}
#[derive(Debug, Serialize)]
struct MqttProvisioningResponse {
/// Config TOML cifrada con ECIES. Formato Base64: `[ephemeral_pub:65][nonce:12][ct:N]`
pub encrypted_config: String,
}
/// Invocado desde el event loop MQTT del backend cuando llega un mensaje en
/// `provision/request/{project_id}`.
///
/// Valida el token de provisioning (un solo uso, con expiración), cifra la config
/// del agente con ECIES usando la clave pública recibida, y publica la respuesta
/// cifrada en `provision/response/{project_id}`. Finalmente elimina el usuario
/// MQTT temporal para que el token no pueda reutilizarse.
pub async fn handle_mqtt_provisioning(
pool: &PgPool,
mqtt_client: &rumqttc::AsyncClient,
project_id_str: &str,
payload: &[u8],
) -> Result<(), anyhow::Error> {
let project_id = Uuid::parse_str(project_id_str)
.map_err(|_| anyhow::anyhow!("project_id inválido: {}", project_id_str))?;
// 1. Parsear payload MQTT
let req: MqttProvisioningRequest = serde_json::from_slice(payload)
.map_err(|e| anyhow::anyhow!("Payload de provisioning inválido: {}", e))?;
// 2. Buscar token activo (no usado, no expirado) para este proyecto
let token_row = sqlx::query(
r#"SELECT id, expires_at
FROM agent_provisioning_tokens
WHERE project_id = $1 AND used = false AND expires_at > NOW()
ORDER BY created_at DESC
LIMIT 1"#,
)
.bind(project_id)
.fetch_optional(pool)
.await?
.ok_or_else(|| anyhow::anyhow!("No hay token de provisioning activo para proyecto {}", project_id))?;
let token_id: Uuid = sqlx::Row::try_get(&token_row, "id")?;
// 3. Marcar token como usado (antes de cifrar — evita race conditions)
sqlx::query("UPDATE agent_provisioning_tokens SET used = true WHERE id = $1")
.bind(token_id)
.execute(pool)
.await?;
// 4. Registrar / actualizar clave pública del agente
sqlx::query(
r#"INSERT INTO agent_public_keys (project_id, public_key_pem)
VALUES ($1, $2)
ON CONFLICT (project_id)
DO UPDATE SET public_key_pem = EXCLUDED.public_key_pem, updated_at = NOW()"#,
)
.bind(project_id)
.bind(&req.public_key_pem)
.execute(pool)
.await?;
// 5. Obtener config TOML del proyecto
let config_toml: Option<String> = sqlx::query_scalar(
"SELECT agent_config_toml FROM edge_projects WHERE id = $1",
)
.bind(project_id)
.fetch_optional(pool)
.await?
.flatten();
let config_toml = config_toml.ok_or_else(|| {
anyhow::anyhow!(
"Configuración de agente no disponible para proyecto {}. Regenerar el instalador.",
project_id
)
})?;
// 6. Cifrar con ECIES
let encrypted_config =
shared_lib::provisioning::ecies_encrypt(&req.public_key_pem, config_toml.as_bytes())
.map_err(|e| anyhow::anyhow!("Error ECIES: {}", e))?;
// 7. Publicar respuesta cifrada al agente.
// retain=true: el broker conserva el mensaje aunque el ACL del agente sea
// eliminado antes de que el broker haga el READ check de entrega. Sin retain,
// existe una race condition donde el broker borra el mensaje porque el ACL
// ya no existe en el momento de entrega.
let response_topic = format!("provision/response/{}", project_id);
let response_payload = serde_json::to_vec(&MqttProvisioningResponse { encrypted_config })?;
mqtt_client
.publish(
&response_topic,
rumqttc::QoS::AtLeastOnce,
true, // retain=true para que la entrega no dependa del timing
response_payload,
)
.await
.map_err(|e| anyhow::anyhow!("Error publicando respuesta MQTT: {}", e))?;
// 8. Eliminar usuario MQTT temporal DESPUÉS de que la respuesta fue publicada
// y retenida. Esperamos un momento para dar al broker tiempo de entregar.
// El mensaje retenido permanece hasta que el agente lo reciba; se limpiará
// cuando el agente publique en su topic permanente (heartbeat).
tokio::time::sleep(std::time::Duration::from_secs(3)).await;
let prov_user = format!("provision:{}", project_id);
let _ = sqlx::query("DELETE FROM mqtt_acls WHERE username = $1")
.bind(&prov_user)
.execute(pool)
.await;
let _ = sqlx::query("DELETE FROM mqtt_users WHERE username = $1")
.bind(&prov_user)
.execute(pool)
.await;
// Limpiar el mensaje retenido del broker para que futuras conexiones de
// provisioning no reciban una respuesta obsoleta (publica payload vacío con retain=true).
let _ = mqtt_client
.publish(&response_topic, rumqttc::QoS::AtLeastOnce, true, vec![])
.await;
tracing::info!(
"✅ Agente aprovisionado exitosamente vía MQTT para proyecto {}",
project_id
);
Ok(())
}
// ─── Admin endpoint: rotar token de provisioning ─────────────────────────────
/// `POST /api/provisioning/rotate-token/{project_id}`
///
/// Genera un nuevo token de provisioning para un proyecto y recrea el usuario MQTT
/// temporal correspondiente. Requiere autenticación de admin.
pub async fn rotate_provisioning_token(
State(pool): State<PgPool>,
axum::extract::Path(project_id): axum::extract::Path<Uuid>,
claims: crate::auth::AdminClaims,
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
let _ = claims;
let db_err = |e: sqlx::Error| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string());
let exists: Option<bool> = sqlx::query_scalar(
"SELECT EXISTS(SELECT 1 FROM edge_projects WHERE id = $1)",
)
.bind(project_id)
.fetch_one(&pool)
.await
.map_err(db_err)?;
if !exists.unwrap_or(false) {
return Err((StatusCode::NOT_FOUND, "Proyecto no encontrado".to_string()));
}
let new_token = Uuid::new_v4().to_string();
let expires_at = Utc::now() + chrono::Duration::days(30);
// Insertar nuevo token
sqlx::query(
r#"INSERT INTO agent_provisioning_tokens (project_id, token, expires_at)
VALUES ($1, $2, $3)"#,
)
.bind(project_id)
.bind(&new_token)
.bind(expires_at)
.execute(&pool)
.await
.map_err(db_err)?;
// Recrear usuario MQTT temporal con el nuevo token
let prov_user = format!("provision:{}", project_id);
sqlx::query(
"INSERT INTO mqtt_users (username, password_hash)
VALUES ($1, crypt($2, gen_salt('bf', 10)))
ON CONFLICT (username) DO UPDATE SET password_hash = EXCLUDED.password_hash",
)
.bind(&prov_user)
.bind(&new_token)
.execute(&pool)
.await
.map_err(db_err)?;
// Asegurar ACLs del usuario temporal
// rw=2: publish para request, rw=4: subscribe para response (MOSQ_ACL_SUBSCRIBE=4)
let prov_acls = vec![
(format!("provision/request/{}", project_id), 2i32),
(format!("provision/response/{}", project_id), 4i32),
];
for (topic, rw) in prov_acls {
let _ = sqlx::query(
"INSERT INTO mqtt_acls (username, topic, rw) VALUES ($1, $2, $3)
ON CONFLICT (username, topic) DO UPDATE SET rw = $3",
)
.bind(&prov_user)
.bind(&topic)
.bind(rw)
.execute(&pool)
.await;
}
tracing::info!(
"🔑 Nuevo token de provisioning generado para proyecto {}",
project_id
);
Ok(Json(serde_json::json!({
"provisioning_token": new_token,
"expires_at": expires_at,
"project_id": project_id
})))
}

View File

@@ -12,12 +12,15 @@ use uuid::Uuid;
use shared_lib::models::{PostTelemetryRequest, TelemetryRecord, TelemetryResponse}; use shared_lib::models::{PostTelemetryRequest, TelemetryRecord, TelemetryResponse};
use crate::auth::Claims; use crate::auth::Claims;
/// POST /api/telemetry — Ingresar una medición de campo. /// POST /api/telemetry — Ingresar una medición de campo (solo frontend/admin con JWT).
/// ///
/// Idempotente: ON CONFLICT (time, asset_id) DO NOTHING permite reintentos /// Los agentes de campo envían telemetría exclusivamente por MQTT (`omnioil/telemetry/{id}`),
/// sin generar duplicados. /// nunca por HTTP. Este endpoint es únicamente para el frontend administrativo.
///
/// Idempotente: ON CONFLICT (time, asset_id) DO NOTHING permite reintentos sin duplicados.
pub async fn post_telemetry( pub async fn post_telemetry(
State(pool): State<PgPool>, State(pool): State<PgPool>,
_claims: Claims,
Json(req): Json<PostTelemetryRequest>, Json(req): Json<PostTelemetryRequest>,
) -> Result<(StatusCode, Json<TelemetryResponse>), (StatusCode, String)> { ) -> Result<(StatusCode, Json<TelemetryResponse>), (StatusCode, String)> {
let time = req.time.unwrap_or_else(Utc::now); let time = req.time.unwrap_or_else(Utc::now);
@@ -72,7 +75,7 @@ pub async fn get_asset_telemetry(
WHERE ea.id = $1 AND upa.user_id = $2 AND upa.is_active = true"# WHERE ea.id = $1 AND upa.user_id = $2 AND upa.is_active = true"#
) )
.bind(asset_id) .bind(asset_id)
.bind(Uuid::parse_str(&claims.sub).unwrap()) .bind(Uuid::parse_str(&claims.sub).map_err(|_| (StatusCode::UNAUTHORIZED, "User ID inválido en token".to_string()))?)
.fetch_optional(&pool) .fetch_optional(&pool)
.await .await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;

View File

@@ -3,6 +3,7 @@ mod db;
mod errors; mod errors;
mod handlers; mod handlers;
mod notifier; mod notifier;
mod rate_limiter;
mod watchdog; mod watchdog;
use axum::{ use axum::{
@@ -55,7 +56,7 @@ async fn main() {
tracing_subscriber::registry() tracing_subscriber::registry()
.with( .with(
tracing_subscriber::EnvFilter::try_from_default_env() tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| "debug".into()), .unwrap_or_else(|_| "info".into()),
) )
.with(tracing_subscriber::fmt::layer()) .with(tracing_subscriber::fmt::layer())
.init(); .init();
@@ -96,19 +97,31 @@ async fn main() {
.execute(&pool) .execute(&pool)
.await; .await;
// Asegurar permisos ACL // ACL mínima: solo los topics que el backend realmente necesita (LOW-2)
let sync_acl = sqlx::query( // rw=7: read(1) + write(2) + subscribe(4) = 7 — go-auth usa MOSQ_ACL_SUBSCRIBE=4
r#" // ON CONFLICT DO UPDATE para que los cambios de ACL surtan efecto en re-deploys.
INSERT INTO mqtt_acls (username, topic, rw) let acl_queries = vec![
VALUES ($1, '#', 3) ("omnioil/#", 7i32), // telemetría, health, logs, status, deploy
ON CONFLICT DO NOTHING ("provision/#", 7i32), // provisioning de agentes (subscribe=4 requerido)
"#, ];
let mut acl_ok = true;
for (topic, rw) in acl_queries {
if sqlx::query(
r#"INSERT INTO mqtt_acls (username, topic, rw)
VALUES ($1, $2, $3)
ON CONFLICT (username, topic) DO UPDATE SET rw = EXCLUDED.rw"#,
) )
.bind(&u) .bind(&u)
.bind(topic)
.bind(rw)
.execute(&pool) .execute(&pool)
.await; .await
.is_err() {
acl_ok = false;
}
}
if sync_user.is_ok() && sync_acl.is_ok() { if sync_user.is_ok() && acl_ok {
tracing::info!("MQTT system user '{}' synchronized successfully (DB).", u); tracing::info!("MQTT system user '{}' synchronized successfully (DB).", u);
} else { } else {
tracing::error!("Failed to synchronize MQTT user or ACLs"); tracing::error!("Failed to synchronize MQTT user or ACLs");
@@ -141,38 +154,151 @@ async fn main() {
// Spawn MQTT event loop para procesar mensajes // Spawn MQTT event loop para procesar mensajes
let tx_relay = tx.clone(); let tx_relay = tx.clone();
let prov_pool = pool.clone();
let prov_mqtt_client = mqtt_client.clone();
tokio::spawn(async move { tokio::spawn(async move {
loop { loop {
match eventloop.poll().await { match eventloop.poll().await {
Ok(notification) => { Ok(notification) => {
if let rumqttc::Event::Incoming(rumqttc::Packet::Publish(publish)) = notification { if let rumqttc::Event::Incoming(rumqttc::Packet::Publish(publish)) = notification {
// Relay any JSON message from omnioil topics to WebSocket let topic = publish.topic.as_str();
if publish.topic.starts_with("omnioil/") {
let topic_parts: Vec<&str> = publish.topic.split('/').collect(); if topic.starts_with("omnioil/status/") {
// ── Estado inmediato del agente (LWT o reconexión) ─────────────
// Permite que la UI muestre "offline" en segundos, no minutos.
let project_id_str = topic.strip_prefix("omnioil/status/").unwrap_or("").to_string();
let pool_ref = prov_pool.clone();
let tx_ref = tx_relay.clone();
let payload_bytes = publish.payload.to_vec();
tokio::spawn(async move {
if let Ok(msg) = serde_json::from_slice::<serde_json::Value>(&payload_bytes) {
let status = msg["status"].as_str().unwrap_or("offline").to_string();
if let Ok(project_uuid) = uuid::Uuid::parse_str(&project_id_str) {
let _ = sqlx::query(
r#"UPDATE edge_projects
SET last_health_report = COALESCE(last_health_report, '{}'::jsonb)
|| jsonb_build_object('status', $1, 'timestamp', NOW()::text)
WHERE id = $2"#,
)
.bind(&status)
.bind(project_uuid)
.execute(&pool_ref)
.await;
}
// Notificar UI en tiempo real
let event = TelemetryEvent {
project_id: Arc::from(project_id_str.to_lowercase()),
payload: Arc::from(
serde_json::json!({
"type": "agent_status",
"status": status,
"timestamp": msg["timestamp"]
})
.to_string(),
),
};
let _ = tx_ref.send(event);
}
});
} else if topic.starts_with("omnioil/health/") {
// ── Health report periódico del agente ─────────────────────────
let pool_ref = prov_pool.clone();
let tx_ref = tx_relay.clone();
let project_id_str = topic.strip_prefix("omnioil/health/").unwrap_or("").to_string();
let payload_bytes = publish.payload.to_vec();
tokio::spawn(async move {
if let Ok(report) = serde_json::from_slice::<shared_lib::mqtt_messages::HealthReport>(&payload_bytes) {
let _ = sqlx::query(
"UPDATE edge_projects SET last_health_report = $1 WHERE id = $2",
)
.bind(serde_json::to_value(&report).unwrap_or_default())
.bind(report.project_id)
.execute(&pool_ref)
.await;
// Relay a UI
let event = TelemetryEvent {
project_id: Arc::from(project_id_str.to_lowercase()),
payload: Arc::from(
serde_json::to_string(&report).unwrap_or_default(),
),
};
let _ = tx_ref.send(event);
}
});
} else if topic.starts_with("omnioil/logs/") {
// ── Logs del agente → insertar en DB ──────────────────────────
let pool_ref = prov_pool.clone();
let payload_bytes = publish.payload.to_vec();
tokio::spawn(async move {
if let Ok(entry) = serde_json::from_slice::<shared_lib::mqtt_messages::AgentLogEntry>(&payload_bytes) {
let _ = sqlx::query(
r#"INSERT INTO agent_logs
(project_id, level, category, message, context, agent_hostname, timestamp)
VALUES ($1, $2, $3, $4, $5, $6, $7)"#,
)
.bind(entry.project_id)
.bind(&entry.level)
.bind(&entry.category)
.bind(&entry.message)
.bind(entry.context.unwrap_or(serde_json::json!({})))
.bind(&entry.agent_hostname)
.bind(entry.timestamp)
.execute(&pool_ref)
.await;
}
});
} else if topic.starts_with("omnioil/") {
// ── Relay genérico: telemetría, alarmas, deploys → WebSocket ──
let topic_parts: Vec<&str> = topic.split('/').collect();
let project_id = topic_parts.get(2).unwrap_or(&"unknown").to_string(); let project_id = topic_parts.get(2).unwrap_or(&"unknown").to_string();
let decompressed = shared_lib::compression::decompress_if_needed(&publish.payload); let decompressed = shared_lib::compression::decompress_if_needed(&publish.payload);
if let Ok(payload_str) = String::from_utf8(decompressed.to_vec()) { if let Ok(payload_str) = String::from_utf8(decompressed.to_vec()) {
tracing::debug!("Relaying MQTT message to WebSocket: Topic={}, Project={}", publish.topic, project_id);
// Envolver en un evento unificado
let event = TelemetryEvent { let event = TelemetryEvent {
project_id: Arc::from(project_id.to_lowercase()), // Pre-normalizado para velocidad project_id: Arc::from(project_id.to_lowercase()),
payload: Arc::from(payload_str), payload: Arc::from(payload_str),
}; };
match tx_relay.send(event) { match tx_relay.send(event) {
Ok(receivers) => { Ok(receivers) => {
tracing::info!("📥 MQTT Message routed to {} UI listeners", receivers); tracing::debug!("📥 MQTT relayed to {} WS listeners", receivers);
} }
Err(e) => { Err(e) => {
tracing::warn!("⚠️ No active UI listeners for project {}: {:?}", project_id, e); tracing::warn!("⚠️ No WS listeners: {:?}", e);
} }
} }
} else {
tracing::error!("❌ Failed to parse MQTT payload as UTF-8 from topic: {}", publish.topic);
} }
} else if topic.starts_with("provision/request/") {
// ── Provisioning MQTT ──────────────────────────────────────────
let project_id_str = topic
.strip_prefix("provision/request/")
.unwrap_or("")
.to_string();
let pool_ref = prov_pool.clone();
let client_ref = prov_mqtt_client.clone();
let payload_bytes = publish.payload.to_vec();
tokio::spawn(async move {
if let Err(e) = handlers::provisioning::handle_mqtt_provisioning(
&pool_ref,
&client_ref,
&project_id_str,
&payload_bytes,
)
.await
{
tracing::error!(
"❌ Error en provisioning MQTT para proyecto {}: {}",
project_id_str,
e
);
}
});
} }
} }
} }
@@ -184,13 +310,19 @@ async fn main() {
} }
}); });
// Suscribirse a todo el árbol de omnioil para relay a UI // Suscribirse a todos los topics de omnioil y de provisioning
if let Err(e) = mqtt_client if let Err(e) = mqtt_client
.subscribe("omnioil/#", rumqttc::QoS::AtMostOnce) .subscribe("omnioil/#", rumqttc::QoS::AtMostOnce)
.await .await
{ {
tracing::error!("Failed to subscribe to omnioil: {:?}", e); tracing::error!("Failed to subscribe to omnioil: {:?}", e);
} }
if let Err(e) = mqtt_client
.subscribe("provision/request/+", rumqttc::QoS::AtLeastOnce)
.await
{
tracing::error!("Failed to subscribe to provision/request: {:?}", e);
}
// 5. Iniciar Watchdog (Monitoreo de cumplimiento ANH) // 5. Iniciar Watchdog (Monitoreo de cumplimiento ANH)
let watchdog_pool = pool.clone(); let watchdog_pool = pool.clone();
@@ -228,6 +360,14 @@ async fn main() {
"/projects/{id}/rebuild", "/projects/{id}/rebuild",
post(handlers::edge_projects::rebuild_installer), post(handlers::edge_projects::rebuild_installer),
) )
.route(
"/projects/{id}/linux-install",
get(handlers::edge_projects::linux_install_script),
)
.route(
"/agent/linux-binary/{arch}",
get(handlers::edge_projects::download_linux_binary),
)
// Assets per project // Assets per project
.route( .route(
"/projects/{project_id}/assets", "/projects/{project_id}/assets",
@@ -241,7 +381,6 @@ async fn main() {
.put(handlers::edge_assets::update_asset) .put(handlers::edge_assets::update_asset)
.delete(handlers::edge_assets::delete_asset), .delete(handlers::edge_assets::delete_asset),
) )
.route("/health", get(|| async { "OK" }))
// Devices (bajo assets) // Devices (bajo assets)
.route( .route(
"/assets/{asset_id}/devices", "/assets/{asset_id}/devices",
@@ -314,22 +453,62 @@ async fn main() {
let cors = tower_http::cors::CorsLayer::new() let cors = tower_http::cors::CorsLayer::new()
.allow_origin(allow_origin) .allow_origin(allow_origin)
.allow_methods(tower_http::cors::Any) .allow_methods([
.allow_headers(tower_http::cors::Any); axum::http::Method::GET,
axum::http::Method::POST,
axum::http::Method::PUT,
axum::http::Method::DELETE,
])
.allow_headers([
axum::http::header::AUTHORIZATION,
axum::http::header::CONTENT_TYPE,
])
// Necesario para que el browser envíe cookies HttpOnly en peticiones cross-origin
.allow_credentials(true);
// TODO: Rate limiting deshabilitado temporalmente — tower_governor 0.8.0 // Rate limiter para endpoints de autenticación (HIGH-5)
// causa 500 silencioso dentro de Docker al no poder extraer ConnectInfo. // Usa X-Real-IP (Nginx) — funciona correctamente en Docker.
// Reactivar cuando se migre a una versión compatible o se use un extractor custom. // 10 req/min en login y refresh; 5 req/min en register.
// use tower_governor::{GovernorLayer, governor::GovernorConfigBuilder}; let auth_rate_limiter = rate_limiter::new_rate_limiter(10);
let register_rate_limiter = rate_limiter::new_rate_limiter(5);
// Sub-router de auth // Sub-router de auth con rate limiting por endpoint
let auth_routes = Router::new() let auth_routes = Router::new()
.route("/login", post(handlers::auth::login)) .route(
.route("/refresh", post(handlers::auth::refresh)) "/login",
.route("/register", post(handlers::auth::register)) post(handlers::auth::login).layer(axum::middleware::from_fn_with_state(
auth_rate_limiter.clone(),
rate_limiter::rate_limit_by_ip,
)),
)
.route(
"/refresh",
post(handlers::auth::refresh).layer(axum::middleware::from_fn_with_state(
auth_rate_limiter.clone(),
rate_limiter::rate_limit_by_ip,
)),
)
.route(
"/logout",
post(handlers::auth::logout),
)
.route(
"/register",
post(handlers::auth::register).layer(axum::middleware::from_fn_with_state(
register_rate_limiter,
rate_limiter::rate_limit_by_ip,
)),
)
.route("/setup/status", get(handlers::auth::get_setup_status)) .route("/setup/status", get(handlers::auth::get_setup_status))
.route("/setup/register", post(handlers::auth::setup_register)); .route("/setup/register", post(handlers::auth::setup_register));
// Rutas de gestión de tokens de provisioning (solo admin)
let provisioning_routes = Router::new()
.route(
"/rotate-token/{project_id}",
post(handlers::provisioning::rotate_provisioning_token),
);
// Rutas Admin (gestión de usuarios — solo admins via AdminClaims) // Rutas Admin (gestión de usuarios — solo admins via AdminClaims)
let admin_routes = Router::new() let admin_routes = Router::new()
.route( .route(
@@ -353,8 +532,11 @@ async fn main() {
// Rutas app // Rutas app
let app = Router::new() let app = Router::new()
.route("/", get(root)) .route("/", get(root))
// Health check en raíz (restringir a red interna via Nginx en producción)
.route("/health", get(|| async { axum::Json(serde_json::json!({"status":"ok"})) }))
.nest("/api/auth", auth_routes) .nest("/api/auth", auth_routes)
.nest("/api/admin", admin_routes) .nest("/api/admin", admin_routes)
.nest("/api/provisioning", provisioning_routes)
.route( .route(
"/api/dashboard/summary", "/api/dashboard/summary",
get(handlers::dashboard::get_summary), get(handlers::dashboard::get_summary),
@@ -425,13 +607,16 @@ async fn main() {
axum::serve(listener, app).await.unwrap(); axum::serve(listener, app).await.unwrap();
} }
async fn root() -> &'static str { async fn root() -> axum::Json<serde_json::Value> {
"OMNIOIL API - Rust Axum 🚀 (Auth & Dashboard Ready)" axum::Json(serde_json::json!({"status": "ok"}))
} }
#[derive(Deserialize)] #[derive(Deserialize)]
struct WsParams { struct WsParams {
project_id: Option<String>, project_id: Option<String>,
/// JWT bearer token — los browsers no pueden enviar Authorization header en WS,
/// por eso se acepta como query param.
token: Option<String>,
} }
async fn ws_telemetry_handler( async fn ws_telemetry_handler(
@@ -439,6 +624,25 @@ async fn ws_telemetry_handler(
query: Query<WsParams>, query: Query<WsParams>,
State(state): State<AppState>, State(state): State<AppState>,
) -> impl IntoResponse { ) -> impl IntoResponse {
// Validar token JWT antes de upgrading la conexión WebSocket (HIGH-3)
let token = match &query.token {
Some(t) => t.as_str(),
None => {
return (
axum::http::StatusCode::UNAUTHORIZED,
axum::Json(serde_json::json!({"error": "Token requerido para conexión WebSocket"})),
)
.into_response()
}
};
if crate::auth::verify_jwt(token).is_err() {
return (
axum::http::StatusCode::UNAUTHORIZED,
axum::Json(serde_json::json!({"error": "Token inválido o expirado"})),
)
.into_response();
}
let project_id = query.project_id.clone(); let project_id = query.project_id.clone();
tracing::debug!( tracing::debug!(
"New WebSocket upgrade request for telemetry. Filter: {:?}", "New WebSocket upgrade request for telemetry. Filter: {:?}",

View File

@@ -0,0 +1,66 @@
/// Rate limiting middleware para endpoints de autenticación.
///
/// Extrae la IP del cliente desde el header `X-Real-IP` (configurado por Nginx),
/// con fallback a `X-Forwarded-For`. Esto funciona correctamente dentro de Docker
/// sin necesidad de acceder a `ConnectInfo<SocketAddr>`.
use std::num::NonZeroU32;
use std::sync::Arc;
use axum::{
Json,
extract::{Request, State},
http::StatusCode,
middleware::Next,
response::Response,
};
use governor::{DefaultKeyedRateLimiter, Quota, RateLimiter};
pub type SharedRateLimiter = Arc<DefaultKeyedRateLimiter<String>>;
/// Crea un rate limiter con el límite especificado por minuto.
pub fn new_rate_limiter(per_minute: u32) -> SharedRateLimiter {
let quota = Quota::per_minute(NonZeroU32::new(per_minute).expect("per_minute > 0"));
Arc::new(RateLimiter::keyed(quota))
}
/// Extrae la IP real del cliente desde los headers de Nginx.
fn extract_client_ip(request: &Request) -> String {
request
.headers()
.get("X-Real-IP")
.and_then(|v| v.to_str().ok())
.map(|s| s.trim().to_string())
.or_else(|| {
request
.headers()
.get("X-Forwarded-For")
.and_then(|v| v.to_str().ok())
.and_then(|s| s.split(',').next())
.map(|s| s.trim().to_string())
})
.unwrap_or_else(|| "unknown".to_string())
}
/// Middleware de rate limiting por IP.
///
/// Retorna `429 Too Many Requests` si la IP supera el límite configurado.
pub async fn rate_limit_by_ip(
State(limiter): State<SharedRateLimiter>,
request: Request,
next: Next,
) -> Result<Response, (StatusCode, Json<serde_json::Value>)> {
let ip = extract_client_ip(&request);
match limiter.check_key(&ip) {
Ok(_) => Ok(next.run(request).await),
Err(_) => {
tracing::warn!("🚦 Rate limit exceeded for IP: {}", ip);
Err((
StatusCode::TOO_MANY_REQUESTS,
Json(serde_json::json!({
"error": "Demasiadas solicitudes. Intente de nuevo en unos minutos."
})),
))
}
}
}

View File

@@ -9,9 +9,11 @@ pub async fn run_watchdog(pool: PgPool) {
let notifier = Notifier::new(); let notifier = Notifier::new();
let mut interval = tokio::time::interval(Duration::from_secs(60)); let mut interval = tokio::time::interval(Duration::from_secs(60));
let mut tick_count: u64 = 0;
loop { loop {
interval.tick().await; interval.tick().await;
tick_count += 1;
if let Err(e) = check_telemetry_silence(&pool, &notifier).await { if let Err(e) = check_telemetry_silence(&pool, &notifier).await {
tracing::error!("Watchdog error (silence check): {}", e); tracing::error!("Watchdog error (silence check): {}", e);
@@ -20,6 +22,13 @@ pub async fn run_watchdog(pool: PgPool) {
if let Err(e) = check_unhealthy_agents(&pool, &notifier).await { if let Err(e) = check_unhealthy_agents(&pool, &notifier).await {
tracing::error!("Watchdog error (health check): {}", e); tracing::error!("Watchdog error (health check): {}", e);
} }
// Limpieza diaria: borrar logs de agente con más de 6 meses (tick cada 60s → 1440 = 24h)
if tick_count % 1440 == 0 {
if let Err(e) = cleanup_old_agent_logs(&pool).await {
tracing::error!("Watchdog error (log cleanup): {}", e);
}
}
} }
} }
@@ -116,3 +125,21 @@ async fn check_unhealthy_agents(pool: &PgPool, notifier: &Notifier) -> anyhow::R
Ok(()) Ok(())
} }
/// Elimina logs de agente más antiguos de 6 meses (retención máxima para visualización en tiempo real).
async fn cleanup_old_agent_logs(pool: &PgPool) -> Result<(), sqlx::Error> {
let result = sqlx::query(
"DELETE FROM agent_logs WHERE timestamp < NOW() - INTERVAL '6 months'"
)
.execute(pool)
.await?;
if result.rows_affected() > 0 {
tracing::info!(
"🗑️ Log retention: {} agent_log records eliminados (> 6 meses)",
result.rows_affected()
);
}
Ok(())
}

View File

@@ -3,7 +3,7 @@ use aws_sdk_s3::{Client, config::Region};
use chrono::Utc; use chrono::Utc;
use serde_json::json; use serde_json::json;
use shared_lib::mqtt_messages::ProjectCreatedEvent; use shared_lib::mqtt_messages::ProjectCreatedEvent;
use shared_lib::overlay::generate_custom_binary; use shared_lib::overlay::{ProvisioningStub, config_json_to_toml, generate_provisioning_binary};
use sqlx::PgPool; use sqlx::PgPool;
use uuid::Uuid; use uuid::Uuid;
async fn update_deployment_status( async fn update_deployment_status(
@@ -86,40 +86,37 @@ pub async fn handle_project_created(
tracing::info!("🔔 MSI Worker: Notifying UI about INITIALIZING state via MQTT"); tracing::info!("🔔 MSI Worker: Notifying UI about INITIALIZING state via MQTT");
let _ = crate::publisher::publish_build_status(&mqtt_client, project_id, "INITIALIZING", "Iniciando generación de instalador...").await; let _ = crate::publisher::publish_build_status(&mqtt_client, project_id, "INITIALIZING", "Iniciando generación de instalador...").await;
// 1b. Obtener o generar Credenciales MQTT y Secretos para este Agente // 1b. Obtener o generar Credenciales MQTT para este Agente
let (mqtt_user, mqtt_password, master_secret) = { let (mqtt_user, mqtt_password) = {
let existing = sqlx::query_as::<_, (Option<String>, Option<String>)>( let existing = sqlx::query_as::<_, (Option<String>,)>(
"SELECT mqtt_password, agent_master_secret FROM edge_projects WHERE id = $1", "SELECT mqtt_password FROM edge_projects WHERE id = $1",
) )
.bind(project_id) .bind(project_id)
.fetch_one(&pool) .fetch_one(&pool)
.await?; .await?;
match existing { match existing {
(Some(pass), Some(secret)) => { (Some(pass),) => {
tracing::info!( tracing::info!(
"♻️ Reutilizando credenciales y secreto maestro existentes para proyecto: {}", "♻️ Reutilizando credenciales MQTT existentes para proyecto: {}",
project_id project_id
); );
(project_id.to_string(), pass, secret) (project_id.to_string(), pass)
} }
_ => { _ => {
let new_pass = Uuid::new_v4().to_string(); let new_pass = Uuid::new_v4().to_string();
let new_secret = Uuid::new_v4().to_string();
tracing::info!( tracing::info!(
"🔑 Generando nuevas credenciales y secreto maestro para proyecto: {}", "🔑 Generando nuevas credenciales MQTT para proyecto: {}",
project_id project_id
); );
sqlx::query(
// Persistir en DB para futuros rebuilds "UPDATE edge_projects SET mqtt_password = $1 WHERE id = $2",
sqlx::query("UPDATE edge_projects SET mqtt_password = $1, agent_master_secret = $2 WHERE id = $3") )
.bind(&new_pass) .bind(&new_pass)
.bind(&new_secret)
.bind(project_id) .bind(project_id)
.execute(&pool) .execute(&pool)
.await?; .await?;
(project_id.to_string(), new_pass)
(project_id.to_string(), new_pass, new_secret)
} }
} }
}; };
@@ -143,6 +140,10 @@ pub async fn handle_project_created(
format!("omnioil/telemetry/{}", project_id), format!("omnioil/telemetry/{}", project_id),
format!("omnioil/deploy/{}", project_id), format!("omnioil/deploy/{}", project_id),
format!("omnioil/commands/{}", project_id), format!("omnioil/commands/{}", project_id),
// Requerido para el LWT y el heartbeat de estado del agente (online/offline).
// Mosquitto valida el topic del LWT contra la ACL en el paquete CONNECT;
// si el usuario no tiene permiso de publish aquí, rechaza la conexión con NotAuthorized.
format!("omnioil/status/{}", project_id),
]; ];
for t in topics { for t in topics {
@@ -155,12 +156,11 @@ pub async fn handle_project_created(
.await?; .await?;
} }
// Elaboración del payload JSON embebido (INYECTADO AL BINARIO) // Construcción del JSON de configuración del agente
let agent_config = json!({ let agent_config_json = json!({
"agent": { "agent": {
"project_id": project_id, "project_id": project_id,
"hostname": event.name.to_lowercase().replace(' ', "-"), "hostname": event.name.to_lowercase().replace(' ', "-"),
"master_secret": master_secret,
"health_check_interval_secs": 30 "health_check_interval_secs": 30
}, },
"mqtt": { "mqtt": {
@@ -176,28 +176,98 @@ pub async fn handle_project_created(
"docker": { "docker": {
"container_name": "omnioil-scada", "container_name": "omnioil-scada",
"data_volume_path": "C:/ProgramData/OmniOil/data" "data_volume_path": "C:/ProgramData/OmniOil/data"
} },
// "server" field omitido: todo el flujo del agente va por MQTT
}); });
// 🔍 SEGUIMIENTO DE INYECCIÓN DE DATOS // Convertir a TOML y guardar en la base de datos.
/* // La config NUNCA se embebe en el binario — se sirve cifrada durante el provisioning.
tracing::info!("================================================================"); let agent_config_toml = config_json_to_toml(&agent_config_json)
tracing::info!("💉 INYECTANDO CONFIGURACIÓN EN EL AGENTE (MSI BUILD)"); .map_err(|e| format!("Error convirtiendo config a TOML: {}", e))?;
tracing::info!("Variables de Entorno Detectadas:");
tracing::info!(" - MQTT_PUBLIC_HOST: {:?}", std::env::var("MQTT_PUBLIC_HOST").ok()); sqlx::query(
tracing::info!(" - MQTT_PUBLIC_PORT: {:?}", std::env::var("MQTT_PUBLIC_PORT").ok()); "UPDATE edge_projects SET agent_config_toml = $1 WHERE id = $2",
tracing::info!(" - MQTT_USE_WS: {:?}", std::env::var("MQTT_USE_WS").ok()); )
tracing::info!(" - INSTALLER_INCOMING: {:?}", std::env::var("INSTALLER_INCOMING").ok()); .bind(&agent_config_toml)
tracing::info!(" - INSTALLER_OUTPUT: {:?}", std::env::var("INSTALLER_OUTPUT").ok()); .bind(project_id)
tracing::info!("----------------------------------------------------------------"); .execute(&pool)
tracing::info!("JSON de Configuración Inyectado:"); .await?;
if let Ok(pretty_json) = serde_json::to_string_pretty(&agent_config) {
for line in pretty_json.lines() { // Generar token de provisioning de un solo uso (válido 30 días).
tracing::info!(" {}", line); // Este token es lo único que viaja en el binario, junto con la URL del servidor.
let provisioning_token = Uuid::new_v4().to_string();
let token_expires_at = Utc::now() + chrono::Duration::days(30);
sqlx::query(
r#"INSERT INTO agent_provisioning_tokens (project_id, token, expires_at)
VALUES ($1, $2, $3)"#,
)
.bind(project_id)
.bind(&provisioning_token)
.bind(token_expires_at)
.execute(&pool)
.await?;
tracing::info!(
"🔐 Token de provisioning generado para proyecto {} (expira: {})",
project_id,
token_expires_at
);
// Crear usuario MQTT temporal para el provisioning.
// El agente se conecta con username=`provision:{project_id}` y password=`{token}`.
// El broker valida las credenciales vía la tabla mqtt_users (bcrypt).
let prov_mqtt_user = format!("provision:{}", project_id);
sqlx::query(
"INSERT INTO mqtt_users (username, password_hash)
VALUES ($1, crypt($2, gen_salt('bf', 10)))
ON CONFLICT (username) DO UPDATE SET password_hash = EXCLUDED.password_hash",
)
.bind(&prov_mqtt_user)
.bind(&provisioning_token)
.execute(&pool)
.await?;
// ACL mínimo: solo los topics de provisioning para este proyecto.
// rw=2 para publish (request), rw=4 para subscribe (response).
// go-auth con mosquitto v2 usa MOSQ_ACL_SUBSCRIBE=4 — rw=1 fallaría subscribe.
let prov_acls = vec![
(format!("provision/request/{}", project_id), 2i32), // publish only
(format!("provision/response/{}", project_id), 4i32), // subscribe + read
];
for (topic, rw) in prov_acls {
sqlx::query(
"INSERT INTO mqtt_acls (username, topic, rw) VALUES ($1, $2, $3)
ON CONFLICT (username, topic) DO UPDATE SET rw = $3",
)
.bind(&prov_mqtt_user)
.bind(&topic)
.bind(rw)
.execute(&pool)
.await?;
} }
}
tracing::info!("================================================================"); // Stub de provisioning: lo único que va en el binario.
*/ // Apunta al broker MQTT público — el backend nunca es accesible desde el exterior.
let mqtt_public_host = std::env::var("MQTT_PUBLIC_HOST")
.expect("MQTT_PUBLIC_HOST must be set");
let mqtt_public_port: u16 = std::env::var("MQTT_PUBLIC_PORT")
.expect("MQTT_PUBLIC_PORT must be set")
.parse()
.expect("MQTT_PUBLIC_PORT must be a valid number");
let mqtt_use_ws: bool = std::env::var("MQTT_USE_WS")
.unwrap_or_else(|_| "false".to_string())
.parse()
.unwrap_or(false);
let provisioning_stub = ProvisioningStub {
mqtt_host: mqtt_public_host,
mqtt_port: mqtt_public_port,
use_ws: mqtt_use_ws,
project_id: project_id.to_string(),
provisioning_token,
};
// 2. Extraer dinámicamente la versión del edge-agent desde su Cargo.toml // 2. Extraer dinámicamente la versión del edge-agent desde su Cargo.toml
let edge_agent_toml_path = std::env::var("EDGE_AGENT_CARGO_TOML") let edge_agent_toml_path = std::env::var("EDGE_AGENT_CARGO_TOML")
.unwrap_or_else(|_| "services/edge-agent/Cargo.toml".to_string()); .unwrap_or_else(|_| "services/edge-agent/Cargo.toml".to_string());
@@ -254,17 +324,22 @@ pub async fn handle_project_created(
tracing::info!("♻️ Borrando EXE personalizado antiguo: {:?}", exe_name); tracing::info!("♻️ Borrando EXE personalizado antiguo: {:?}", exe_name);
} }
// 🛠️ COMPILACIÓN AUTOMÁTICA DEL AGENTE (FORZAR REFRESH) // 🛠️ COMPILACIÓN AUTOMÁTICA DEL AGENTE
tracing::info!("🛠️ Compilando binario base del agente (cargo build)..."); // Nota: std::process::Command es bloqueante — se ejecuta en spawn_blocking
let build_status = std::process::Command::new("cargo") // para no bloquear el runtime de tokio durante los minutos que tarda el build.
tracing::info!("🛠️ Compilando binario base del agente (cargo build --release)...");
let build_result = tokio::task::spawn_blocking(|| {
std::process::Command::new("cargo")
.args(["build", "-p", "edge-agent", "--release"]) .args(["build", "-p", "edge-agent", "--release"])
.status(); .status()
})
.await;
match build_status { match build_result {
Ok(s) if s.success() => tracing::info!("✅ Compilación del agente completada con éxito."), Ok(Ok(s)) if s.success() => tracing::info!("✅ Compilación del agente completada con éxito."),
_ => { Ok(Ok(s)) => tracing::warn!("⚠️ cargo build salió con código {:?}. Intentando usar binario existente...", s.code()),
tracing::warn!("⚠️ Falló cargo build o no se pudo ejecutar. Intentando usar binario existente..."); Ok(Err(e)) => tracing::warn!("⚠️ No se pudo ejecutar cargo build: {}. Intentando usar binario existente...", e),
} Err(e) => tracing::warn!("⚠️ spawn_blocking falló: {}. Intentando usar binario existente...", e),
} }
// Intentar encontrar el binario recién compilado (ahora preferimos release) // Intentar encontrar el binario recién compilado (ahora preferimos release)
@@ -285,8 +360,8 @@ pub async fn handle_project_created(
tracing::info!("📂 Usando plantilla de agente fresca desde: {:?}", template_path_found); tracing::info!("📂 Usando plantilla de agente fresca desde: {:?}", template_path_found);
if let Err(e) = generate_custom_binary(template_path_found, &exe_path, &agent_config) { if let Err(e) = generate_provisioning_binary(template_path_found, &exe_path, &provisioning_stub) {
tracing::error!("❌ Error FATAL en inyección nativa: {}", e); tracing::error!("❌ Error FATAL en inyección de stub de provisioning: {}", e);
let _ = update_deployment_status( let _ = update_deployment_status(
&pool, &pool,
project_id, project_id,

View File

@@ -30,13 +30,33 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
tracing::info!("🏗️ Build Orchestrator starting (NATIVE MODE)..."); tracing::info!("🏗️ Build Orchestrator starting (NATIVE MODE)...");
// Conectar a la base de datos // Conectar a la base de datos con retry — necesario si el orquestador
// arranca antes de que Postgres esté listo (race condition en docker compose).
let database_url = env::var("DATABASE_URL").expect("DATABASE_URL must be set"); let database_url = env::var("DATABASE_URL").expect("DATABASE_URL must be set");
let pool = PgPoolOptions::new() let pool = {
let mut last_err = None;
let mut attempt = 0u32;
loop {
attempt += 1;
match PgPoolOptions::new()
.max_connections(5) .max_connections(5)
.acquire_timeout(std::time::Duration::from_secs(10))
.connect(&database_url) .connect(&database_url)
.await?; .await
tracing::info!("Connected to database"); {
Ok(p) => break p,
Err(e) => {
last_err = Some(e);
if attempt >= 10 {
return Err(last_err.unwrap().into());
}
tracing::warn!("⏳ DB no disponible (intento {}/10), reintentando en 3s...", attempt);
tokio::time::sleep(std::time::Duration::from_secs(3)).await;
}
}
}
};
tracing::info!("✅ Connected to database");
// Conectar al MQTT broker // Conectar al MQTT broker
let mqtt_host = env::var("MQTT_HOST").expect("MQTT_HOST must be set"); let mqtt_host = env::var("MQTT_HOST").expect("MQTT_HOST must be set");

View File

@@ -25,11 +25,6 @@ futures-util = "0.3.32"
libc = "0.2.183" libc = "0.2.183"
self-replace = "1.5.0" self-replace = "1.5.0"
hostname = "0.4.2" hostname = "0.4.2"
windows-service = "0.8.0"
tray-icon = "0.22.0"
winit = "0.30.13"
winres = "0.1.12"
image = "0.25.10"
rusqlite = { version = "0.32.1", features = ["bundled"] } rusqlite = { version = "0.32.1", features = ["bundled"] }
aes-gcm = { workspace = true } aes-gcm = { workspace = true }
pbkdf2 = { workspace = true } pbkdf2 = { workspace = true }
@@ -41,3 +36,16 @@ async-trait = "0.1.86"
[build-dependencies] [build-dependencies]
winres = "0.1.12" winres = "0.1.12"
# ─── Dependencias exclusivas de Windows ──────────────────────────────────────
# tray-icon → glib-sys → falla cross-compile a musl si está en [dependencies]
# windows-service, winit, image: sólo se usan en Windows / edge-tray
[target.'cfg(windows)'.dependencies]
windows-service = "0.8.0"
tray-icon = "0.22.0"
winit = "0.30.13"
image = "0.25.10"
windows-sys = { version = "0.59", features = [
"Win32_Security_Cryptography",
"Win32_Foundation",
] }

View File

@@ -43,6 +43,8 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
RUN rustup target add x86_64-pc-windows-gnu RUN rustup target add x86_64-pc-windows-gnu
RUN rustup target add x86_64-unknown-linux-musl
RUN apt-get update && apt-get install -y --no-install-recommends musl-tools && rm -rf /var/lib/apt/lists/*
# Variables de entorno para cross-compilation # Variables de entorno para cross-compilation
ENV CC_x86_64_pc_windows_gnu=x86_64-w64-mingw32-gcc ENV CC_x86_64_pc_windows_gnu=x86_64-w64-mingw32-gcc
@@ -66,8 +68,8 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
RUN rustup target add x86_64-pc-windows-gnu RUN rustup target add x86_64-pc-windows-gnu
RUN rustup target add x86_64-unknown-linux-musl
# Copiar el código fuente y la caché de dependencias RUN apt-get update && apt-get install -y --no-install-recommends musl-tools && rm -rf /var/lib/apt/lists/*
COPY . . COPY . .
COPY --from=cacher /app/target target COPY --from=cacher /app/target target
COPY --from=cacher /usr/local/cargo /usr/local/cargo COPY --from=cacher /usr/local/cargo /usr/local/cargo
@@ -77,8 +79,9 @@ ENV CC_x86_64_pc_windows_gnu=x86_64-w64-mingw32-gcc
ENV CXX_x86_64_pc_windows_gnu=x86_64-w64-mingw32-g++ ENV CXX_x86_64_pc_windows_gnu=x86_64-w64-mingw32-g++
ENV CARGO_TARGET_X86_64_PC_WINDOWS_GNU_LINKER=x86_64-w64-mingw32-gcc ENV CARGO_TARGET_X86_64_PC_WINDOWS_GNU_LINKER=x86_64-w64-mingw32-gcc
# Compilar binarios de producción para Windows GNU (Estático total) # Compilar binarios de producción para Windows GNU (Estático total) y Linux musl
RUN RUSTFLAGS="-C target-feature=+crt-static -C link-args=-static" cargo build --release --bin edge-agent --bin edge-tray --target x86_64-pc-windows-gnu RUN RUSTFLAGS="-C target-feature=+crt-static -C link-args=-static" cargo build --release --bin edge-agent --bin edge-tray --target x86_64-pc-windows-gnu && \
cargo build --release --bin edge-agent --target x86_64-unknown-linux-musl
# --- ETAPA 4: EMPAQUETADOR (MSI) --- # --- ETAPA 4: EMPAQUETADOR (MSI) ---
FROM debian:bookworm-slim FROM debian:bookworm-slim
@@ -92,9 +95,11 @@ WORKDIR /app
# Colocar los binarios en la ruta que espera el worker # Colocar los binarios en la ruta que espera el worker
RUN mkdir -p /app/services/edge-agent/target/x86_64-pc-windows-msvc/release/ RUN mkdir -p /app/services/edge-agent/target/x86_64-pc-windows-msvc/release/
RUN mkdir -p /app/services/edge-agent/target/x86_64-unknown-linux-musl/release/
COPY --from=builder /app/target/x86_64-pc-windows-gnu/release/edge-agent.exe /app/services/edge-agent/target/x86_64-pc-windows-msvc/release/ COPY --from=builder /app/target/x86_64-pc-windows-gnu/release/edge-agent.exe /app/services/edge-agent/target/x86_64-pc-windows-msvc/release/
COPY --from=builder /app/target/x86_64-pc-windows-gnu/release/edge-tray.exe /app/services/edge-agent/target/x86_64-pc-windows-msvc/release/ COPY --from=builder /app/target/x86_64-pc-windows-gnu/release/edge-tray.exe /app/services/edge-agent/target/x86_64-pc-windows-msvc/release/
COPY --from=builder /app/target/x86_64-unknown-linux-musl/release/edge-agent /app/services/edge-agent/target/x86_64-unknown-linux-musl/release/
COPY ./services/edge-agent/Cargo.toml /app/services/edge-agent/Cargo.toml COPY ./services/edge-agent/Cargo.toml /app/services/edge-agent/Cargo.toml
COPY ./services/edge-agent/wix /app/services/edge-agent/wix COPY ./services/edge-agent/wix /app/services/edge-agent/wix

View File

@@ -70,11 +70,12 @@ while true; do
# 2. Ejecutar WiXl para generar el MSI # 2. Ejecutar WiXl para generar el MSI
wixl -v -o "$OUTPUT_DIR/$MSI_NAME" "$TEMP_WXS" wixl -v -o "$OUTPUT_DIR/$MSI_NAME" "$TEMP_WXS"
WIXL_EXIT=$?
# Limpiar temporal # Limpiar temporal (siempre, sin afectar $?)
rm "$TEMP_WXS" rm -f "$TEMP_WXS"
if [ $? -eq 0 ]; then if [ $WIXL_EXIT -eq 0 ]; then
# 3. Parchar el Summary Information Stream para que Windows lo acepte # 3. Parchar el Summary Information Stream para que Windows lo acepte
# msibuild -s [Title] [Author] [Template] [UUID] # msibuild -s [Title] [Author] [Template] [UUID]
# PID_TEMPLATE (Property 7) = x64;1033 # PID_TEMPLATE (Property 7) = x64;1033

View File

@@ -1,53 +1,321 @@
//! Lógica de carga de configuración desde múltiples fuentes. //! Lógica de carga de configuración desde múltiples fuentes.
//! Soporta archivos locales (TOML), configuración embebida (Baked) //!
//! y inyección dinámica (Overlay) mediante lectura de footer de archivo. //! Soporta tres modos, en orden de prioridad:
//!
//! 1. **Provisioning asimétrico** (producción): El binario contiene un `ProvisioningStub`
//! (mqtt_host + mqtt_port + project_id + token de un solo uso). En el primer arranque,
//! el agente genera un keypair P256, se conecta al broker MQTT con credenciales
//! temporales, publica su clave pública y recibe la config cifrada con ECIES.
//! El backend NUNCA es accesible desde el exterior — todo va por MQTT.
//!
//! 2. **Baked config** (solo desarrollo): Config XOR-ofuscada embebida en compilación.
//! **DEPRECATED** — No usar en producción.
//!
//! 3. **Archivo externo** (desarrollo): Fallback a un archivo TOML local.
use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use shared_lib::overlay::ProvisioningStub;
use std::path::Path;
use anyhow::{Result, Context};
use super::models::AgentConfig; use super::models::AgentConfig;
// El bloque embebido se mantiene como un fallback robusto. // El bloque embebido de desarrollo (XOR 0x5A) — solo para builds de dev.
include!(concat!(env!("OUT_DIR"), "/baked_config.rs")); include!(concat!(env!("OUT_DIR"), "/baked_config.rs"));
// ─── Rutas de almacenamiento de claves y config aprovisionada ─────────────────
fn agent_data_dir() -> PathBuf {
#[cfg(windows)]
{ PathBuf::from(r"C:\ProgramData\OmniOil\agent") }
#[cfg(not(windows))]
{ PathBuf::from("/var/lib/omnioil/agent") }
}
fn private_key_path() -> PathBuf {
agent_data_dir().join("agent_private_key.pem")
}
fn public_key_path() -> PathBuf {
agent_data_dir().join("agent_public_key.pem")
}
fn provisioned_config_path() -> PathBuf {
agent_data_dir().join("provisioned_config.toml")
}
// ─── Mensajes MQTT de provisioning ───────────────────────────────────────────
#[derive(Debug, Serialize)]
struct MqttProvisioningRequest<'a> {
public_key_pem: &'a str,
}
#[derive(Debug, Deserialize)]
struct MqttProvisioningResponse {
encrypted_config: String,
}
// ─── Implementación de carga ──────────────────────────────────────────────────
impl AgentConfig { impl AgentConfig {
/// Carga la configuración desde un archivo TOML físico. /// Carga la configuración desde un archivo TOML físico.
pub fn load(path: &Path) -> Result<Self> { pub fn load(path: &Path) -> Result<Self> {
let content = std::fs::read_to_string(path) let content = std::fs::read_to_string(path)
.with_context(|| format!("No se pudo leer el archivo de configuración: {:?}", path))?; .with_context(|| format!("No se pudo leer el archivo de config: {:?}", path))?;
let config: AgentConfig = toml::from_str(&content) let config: AgentConfig = toml::from_str(&content)
.context("Error de sintaxis en el archivo TOML de configuración")?; .context("Error de sintaxis en el archivo TOML de configuración")?;
Ok(config) Ok(config)
} }
/// Implementación de la carga por prioridad (Hierarchical loading): /// Carga la configuración usando el orden de prioridad definido.
/// 1. File Overlay (Producción dinámica) ///
/// 2. Baked Config (Producción estática) /// Es **async** porque el provisioning requiere una llamada HTTP al servidor.
/// 3. Local File (Desarrollo) pub async fn load_default(external_path: &Path) -> Result<Self> {
pub fn load_default(external_path: &Path) -> Result<Self> { // ── Modo 1: Config ya aprovisionada (arranques posteriores al primero) ──
// --- Modo 1: Overlay (Footer de binario Encriptado AES-256) --- let provisioned = provisioned_config_path();
if let Some(content) = shared_lib::overlay::find_and_decrypt_overlay() { if provisioned.exists() {
if let Ok(config) = toml::from_str::<AgentConfig>(&content) { match crate::credential_store::load_secret(&provisioned)
tracing::info!("🚀 [Overlay] Configuración dinámica AES-256 inyectada detectada."); .and_then(|bytes| {
let content = String::from_utf8(bytes).map_err(|e| anyhow::anyhow!("UTF-8 error: {}", e))?;
toml::from_str::<AgentConfig>(&content).map_err(|e| anyhow::anyhow!("TOML error: {}", e))
})
{
Ok(config) => {
tracing::info!("✅ [Provisioned] Config cargada desde disco.");
return Ok(config); return Ok(config);
} }
Err(e) => {
tracing::warn!(
"⚠️ Config aprovisionada corrupta ({}) — intentando re-provisioning.",
e
);
// Eliminar para forzar re-provisioning
let _ = std::fs::remove_file(&provisioned);
}
}
} }
// --- Modo 2: Baked (Compilación estática) --- // ── Modo 2b: Stub en archivo (instalación Linux por curl | bash) ──
// TODO: Migrar Baked a AES también en Fase 3 // El script de instalación Linux escribe provisioning_stub.json en el data dir.
// Después de provisioning exitoso, se elimina el archivo.
let file_stub_path = agent_data_dir().join("provisioning_stub.json");
if file_stub_path.exists() {
match std::fs::read_to_string(&file_stub_path) {
Ok(json) => {
match serde_json::from_str::<shared_lib::overlay::ProvisioningStub>(&json) {
Ok(stub) => {
tracing::info!(
"📄 [Provisioning] Stub de archivo detectado (proyecto: {}).",
stub.project_id
);
match Self::provision_from_server(&stub).await {
Ok(config) => {
// Eliminar el stub después de aprovisionamiento exitoso
let _ = std::fs::remove_file(&file_stub_path);
tracing::info!("✅ [Provisioning] Agente aprovisionado. Stub eliminado.");
return Ok(config);
}
Err(e) => tracing::warn!("⚠️ Provisioning desde archivo falló: {:#}", e),
}
}
Err(e) => tracing::warn!("⚠️ provisioning_stub.json inválido: {}", e),
}
}
Err(e) => tracing::warn!("⚠️ Error leyendo provisioning_stub.json: {}", e),
}
}
// ── Modo 2: Provisioning asimétrico (primer arranque con stub en binario) ──
if let Some(stub) = shared_lib::overlay::find_provisioning_stub() {
tracing::info!(
"🔑 [Provisioning] Stub detectado. Conectando al broker {}:{}…",
stub.mqtt_host,
stub.mqtt_port
);
match Self::provision_from_server(&stub).await {
Ok(config) => {
tracing::info!("✅ [Provisioning] Agente aprovisionado exitosamente.");
return Ok(config);
}
Err(e) => tracing::warn!("⚠️ Provisioning falló: {:#}", e),
}
}
// ── Modo 3: Baked config (SOLO DESARROLLO — XOR 0x5A) ──
#[allow(deprecated)]
if !BAKED_CONFIG_XOR.is_empty() { if !BAKED_CONFIG_XOR.is_empty() {
let deobfuscated: Vec<u8> = BAKED_CONFIG_XOR.iter().map(|b| b ^ 0x5A).collect(); let deobfuscated: Vec<u8> = BAKED_CONFIG_XOR.iter().map(|b| b ^ 0x5A).collect();
if let Ok(content) = String::from_utf8(deobfuscated) { if let Ok(content) = String::from_utf8(deobfuscated) {
if content.trim().len() > 10 { if content.trim().len() > 10 {
if let Ok(config) = toml::from_str::<AgentConfig>(&content) { if let Ok(config) = toml::from_str::<AgentConfig>(&content) {
tracing::info!("🔒 [Baked] Usando configuración embebida en el binario."); tracing::warn!(
"⚠️ [Baked] Usando config embebida en compilación. \
NO USAR EN PRODUCCIÓN."
);
return Ok(config); return Ok(config);
} }
} }
} }
} }
// --- Modo 3: Fallback a archivo externo --- // ── Modo 4: Fallback a archivo externo (desarrollo local) ──
tracing::info!("📂 [Local] Buscando archivo de configuración externo: {:?}", external_path); tracing::info!("📂 [Local] Cargando config desde: {:?}", external_path);
Self::load(external_path) Self::load(external_path)
} }
/// Ejecuta el flujo de provisioning asimétrico.
///
/// 1. Genera (o carga) keypair P256 del agente.
/// 2. Se conecta al broker MQTT con credenciales temporales de provisioning.
/// 3. Publica la clave pública en `provision/request/{project_id}`.
/// 4. Espera la respuesta cifrada en `provision/response/{project_id}`.
/// 5. Descifra con ECIES y guarda la config en disco.
///
/// El backend NUNCA es accesible desde el exterior — todo va por MQTT.
async fn provision_from_server(stub: &ProvisioningStub) -> Result<Self> {
let data_dir = agent_data_dir();
std::fs::create_dir_all(&data_dir)
.with_context(|| format!("No se pudo crear directorio de datos: {:?}", data_dir))?;
crate::credential_store::secure_dir(&data_dir)
.with_context(|| format!("No se pudo securizar directorio de datos: {:?}", data_dir))?;
// 1. Generar o cargar keypair P256
let (private_key_pem, public_key_pem) = load_or_generate_keypair()?;
// 2. Conectar al broker MQTT con credenciales temporales de provisioning
let client_id = format!("provision-{}", uuid::Uuid::new_v4());
let prov_username = format!("provision:{}", stub.project_id);
// rumqttc con WS transport requiere la URL completa como host (ws://host:port/mqtt).
// TCP plain usa solo el hostname directamente.
let mqtt_host_arg = if stub.use_ws {
format!("ws://{}:{}/mqtt", stub.mqtt_host, stub.mqtt_port)
} else {
stub.mqtt_host.clone()
};
let mut mqtt_opts =
rumqttc::MqttOptions::new(client_id, mqtt_host_arg, stub.mqtt_port);
mqtt_opts.set_credentials(&prov_username, &stub.provisioning_token);
mqtt_opts.set_keep_alive(std::time::Duration::from_secs(15));
mqtt_opts.set_clean_session(true);
// Aplicar el transporte correcto según el stub.
// Puerto 9883 es WebSocket en Mosquitto — TCP plain fallaría el handshake.
if stub.use_ws {
tracing::info!(
"🌐 [Provisioning] Conectando vía WebSocket (puerto {})",
stub.mqtt_port
);
mqtt_opts.set_transport(rumqttc::Transport::ws());
} else {
tracing::info!(
"🔌 [Provisioning] Conectando vía TCP plain (puerto {})",
stub.mqtt_port
);
}
let (mqtt_client, mut eventloop) = rumqttc::AsyncClient::new(mqtt_opts, 10);
let response_topic = format!("provision/response/{}", stub.project_id);
let request_topic = format!("provision/request/{}", stub.project_id);
let request_payload =
serde_json::to_vec(&MqttProvisioningRequest { public_key_pem: &public_key_pem })
.context("Error serializando request de provisioning")?;
// 3. Event loop: conectar → suscribir → publicar pubkey → esperar respuesta
let encrypted_config = tokio::time::timeout(
std::time::Duration::from_secs(45),
async {
loop {
match eventloop.poll().await {
Ok(rumqttc::Event::Incoming(rumqttc::Packet::ConnAck(_))) => {
mqtt_client
.subscribe(&response_topic, rumqttc::QoS::AtLeastOnce)
.await
.context("Error suscribiéndose al topic de respuesta")?;
mqtt_client
.publish(
&request_topic,
rumqttc::QoS::AtLeastOnce,
false,
request_payload.clone(),
)
.await
.context("Error publicando clave pública al broker")?;
tracing::info!(
"🔑 [Provisioning] Clave pública enviada al broker, esperando config…"
);
}
Ok(rumqttc::Event::Incoming(rumqttc::Packet::Publish(msg))) => {
if msg.topic == response_topic {
return Ok::<Vec<u8>, anyhow::Error>(msg.payload.to_vec());
}
}
Ok(_) => {}
Err(e) => {
anyhow::bail!("Error en conexión MQTT de provisioning: {}", e);
}
}
}
},
)
.await
.context("Timeout esperando respuesta de provisioning (45 s)")?
.context("Error durante el provisioning MQTT")?;
let prov_response: MqttProvisioningResponse = serde_json::from_slice(&encrypted_config)
.context("Respuesta de provisioning no es JSON válido")?;
// 4. Descifrar con ECIES
let config_bytes =
shared_lib::provisioning::ecies_decrypt(&private_key_pem, &prov_response.encrypted_config)
.map_err(|e| anyhow::anyhow!("Error ECIES al descifrar config: {}", e))?;
let config_toml =
String::from_utf8(config_bytes).context("Config descifrada no es UTF-8 válida")?;
// 5. Parsear para validar antes de guardar
let config: AgentConfig = toml::from_str(&config_toml)
.context("Config descifrada no es TOML válido")?;
// 6. Guardar en disco para arranques posteriores
crate::credential_store::save_secret(&provisioned_config_path(), config_toml.as_bytes())
.context("No se pudo guardar config aprovisionada de forma segura")?;
Ok(config)
}
}
// ─── Gestión del keypair P256 ─────────────────────────────────────────────────
fn load_or_generate_keypair() -> Result<(String, String)> {
let priv_path = private_key_path();
let pub_path = public_key_path();
if priv_path.exists() && pub_path.exists() {
let private_key_bytes = crate::credential_store::load_secret(&priv_path)
.context("Error leyendo clave privada del agente")?;
let private_key_pem = String::from_utf8(private_key_bytes)
.context("Clave privada no es UTF-8 válida")?;
let public_key_pem = std::fs::read_to_string(&pub_path) // public key stays plain
.context("Error leyendo clave pública del agente")?;
tracing::info!("🔐 Keypair P256 cargado desde disco.");
return Ok((private_key_pem, public_key_pem));
}
tracing::info!("🔑 Generando nuevo keypair P256 para este agente…");
let (private_key_pem, public_key_pem) = shared_lib::provisioning::generate_keypair()
.map_err(|e| anyhow::anyhow!("Error generando keypair: {}", e))?;
crate::credential_store::save_secret(&priv_path, private_key_pem.as_bytes())
.context("Error guardando clave privada del agente")?;
std::fs::write(&pub_path, &public_key_pem)
.context("Error guardando clave pública del agente")?;
tracing::info!("✅ Keypair P256 generado y guardado en {:?}", priv_path.parent());
Ok((private_key_pem, public_key_pem))
} }

View File

@@ -0,0 +1,153 @@
//! Almacenamiento seguro de secretos en disco.
//!
//! - Windows: DPAPI con `CryptProtectData(CRYPTPROTECT_LOCAL_MACHINE)`.
//! Los datos se cifran con claves del LSA de Windows — si el disco se copia
//! a otro equipo, no se pueden descifrar.
//!
//! - Linux: archivo plano con permisos chmod 600, directorio chmod 700.
//! La seguridad viene de los permisos del sistema de archivos. El servicio
//! corre como usuario dedicado `omnioil-agent` que es el único con acceso.
use std::path::Path;
use anyhow::{Context, Result};
/// Guarda datos secretos en disco con protección según la plataforma.
/// Windows: cifra con DPAPI (machine-scope) antes de escribir.
/// Linux: escribe en texto plano con chmod 600.
pub fn save_secret(path: &Path, data: &[u8]) -> Result<()> {
#[cfg(windows)]
{
let encrypted = dpapi::protect(data).context("Error cifrando secreto con DPAPI")?;
std::fs::write(path, &encrypted).with_context(|| format!("Error guardando secreto en {:?}", path))?;
}
#[cfg(not(windows))]
{
std::fs::write(path, data).with_context(|| format!("Error guardando secreto en {:?}", path))?;
set_secret_permissions(path)?;
}
Ok(())
}
/// Carga datos secretos desde disco y los descifra si es necesario.
pub fn load_secret(path: &Path) -> Result<Vec<u8>> {
let raw = std::fs::read(path).with_context(|| format!("Error leyendo secreto desde {:?}", path))?;
#[cfg(windows)]
{
dpapi::unprotect(&raw).context("Error descifrando secreto con DPAPI")
}
#[cfg(not(windows))]
{
Ok(raw)
}
}
/// Aplica permisos seguros al directorio de datos del agente.
/// Windows: no hace nada extra (el directorio es accesible por SYSTEM/Admins).
/// Linux: chmod 700 para que solo el usuario del servicio pueda entrar.
pub fn secure_dir(_path: &Path) -> Result<()> {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
if _path.exists() {
let mut perms = std::fs::metadata(_path)?.permissions();
perms.set_mode(0o700);
std::fs::set_permissions(_path, perms)
.with_context(|| format!("Error aplicando chmod 700 a {:?}", _path))?;
}
}
Ok(())
}
#[cfg(not(windows))]
fn set_secret_permissions(path: &Path) -> Result<()> {
use std::os::unix::fs::PermissionsExt;
let mut perms = std::fs::metadata(path)?.permissions();
perms.set_mode(0o600);
std::fs::set_permissions(path, perms)
.with_context(|| format!("Error aplicando chmod 600 a {:?}", path))
}
// ─── DPAPI (Windows only) ────────────────────────────────────────────────────
#[cfg(windows)]
mod dpapi {
use anyhow::{Result, anyhow};
use windows_sys::Win32::Security::Cryptography::{
CryptProtectData, CryptUnprotectData, CRYPTPROTECT_LOCAL_MACHINE,
};
// LocalFree is a legacy Win32 function — declare it directly to avoid feature-flag issues.
unsafe extern "system" {
fn LocalFree(hmem: *mut std::ffi::c_void) -> *mut std::ffi::c_void;
}
#[repr(C)]
struct DataBlob {
cb_data: u32,
pb_data: *mut u8,
}
pub fn protect(data: &[u8]) -> Result<Vec<u8>> {
let mut input = DataBlob {
cb_data: data.len() as u32,
pb_data: data.as_ptr() as *mut u8,
};
let mut output = DataBlob { cb_data: 0, pb_data: std::ptr::null_mut() };
let ok = unsafe {
CryptProtectData(
&input as *const DataBlob as *const _,
std::ptr::null(),
std::ptr::null(),
std::ptr::null_mut(),
std::ptr::null(),
CRYPTPROTECT_LOCAL_MACHINE,
&mut output as *mut DataBlob as *mut _,
)
};
if ok == 0 {
return Err(anyhow!("CryptProtectData falló (código: {})", unsafe {
windows_sys::Win32::Foundation::GetLastError()
}));
}
let result = unsafe {
std::slice::from_raw_parts(output.pb_data, output.cb_data as usize).to_vec()
};
unsafe { LocalFree(output.pb_data as *mut std::ffi::c_void) };
Ok(result)
}
pub fn unprotect(data: &[u8]) -> Result<Vec<u8>> {
let mut input = DataBlob {
cb_data: data.len() as u32,
pb_data: data.as_ptr() as *mut u8,
};
let mut output = DataBlob { cb_data: 0, pb_data: std::ptr::null_mut() };
let ok = unsafe {
CryptUnprotectData(
&input as *const DataBlob as *const _,
std::ptr::null_mut(),
std::ptr::null(),
std::ptr::null_mut(),
std::ptr::null(),
0,
&mut output as *mut DataBlob as *mut _,
)
};
if ok == 0 {
return Err(anyhow!("CryptUnprotectData falló (código: {})", unsafe {
windows_sys::Win32::Foundation::GetLastError()
}));
}
let result = unsafe {
std::slice::from_raw_parts(output.pb_data, output.cb_data as usize).to_vec()
};
unsafe { LocalFree(output.pb_data as *mut std::ffi::c_void) };
Ok(result)
}
}

View File

@@ -0,0 +1,71 @@
//! Log Cleaner — Elimina archivos de log locales más antiguos que el umbral configurado.
//!
//! `tracing_appender::rolling::daily` crea un archivo por día pero nunca borra los viejos.
//! En un dispositivo de campo que corre 24/7 esto llena el disco. Este módulo lanza
//! una tarea de fondo que hace limpieza al arrancar y luego cada 24 h.
//!
//! Patrón de archivos gestionados: `edge-agent.log.YYYY-MM-DD`
use chrono::{Duration, NaiveDate, Utc};
use std::path::{Path, PathBuf};
/// Inicia la tarea de limpieza de logs en segundo plano.
///
/// - Hace una pasada de limpieza inmediata al arrancar.
/// - Repite cada 24 horas mientras el agente esté activo.
/// - Borra archivos `edge-agent.log.YYYY-MM-DD` con más de `max_days` días.
pub async fn start_log_cleaner(log_dir: PathBuf, max_days: i64) {
// Limpieza inmediata al arrancar
cleanup_old_log_files(&log_dir, max_days);
let mut interval = tokio::time::interval(std::time::Duration::from_secs(24 * 3600));
interval.tick().await; // descarta el primer tick instantáneo
loop {
interval.tick().await;
cleanup_old_log_files(&log_dir, max_days);
}
}
/// Escanea `log_dir` y elimina los archivos de log más antiguos que `max_days` días.
fn cleanup_old_log_files(log_dir: &Path, max_days: i64) {
let cutoff = Utc::now().date_naive() - Duration::days(max_days);
let entries = match std::fs::read_dir(log_dir) {
Ok(e) => e,
Err(e) => {
tracing::warn!("🧹 Log cleaner: no se puede leer {:?}: {}", log_dir, e);
return;
}
};
let mut deleted: usize = 0;
let mut errors: usize = 0;
for entry in entries.flatten() {
let fname = entry.file_name();
let fname_str = fname.to_string_lossy();
// Patrón generado por tracing_appender::rolling::daily: edge-agent.log.YYYY-MM-DD
if let Some(date_str) = fname_str.strip_prefix("edge-agent.log.") {
if let Ok(date) = NaiveDate::parse_from_str(date_str, "%Y-%m-%d") {
if date < cutoff {
match std::fs::remove_file(entry.path()) {
Ok(_) => deleted += 1,
Err(e) => {
tracing::warn!("🧹 Log cleaner: no se pudo borrar {:?}: {}", entry.path(), e);
errors += 1;
}
}
}
}
}
}
if deleted > 0 || errors > 0 {
tracing::info!(
"🧹 Log cleaner: {} archivo(s) eliminado(s), {} error(es) (retención: {} días)",
deleted, errors, max_days
);
}
}

View File

@@ -3,10 +3,12 @@
//! Binario ligero que corre en dispositivos de borde (Edge Devices) de forma nativa. //! Binario ligero que corre en dispositivos de borde (Edge Devices) de forma nativa.
mod config; mod config;
mod credential_store;
mod data_gateway; mod data_gateway;
mod health_checker; mod health_checker;
mod ipc_protocol; mod ipc_protocol;
mod ipc_server; mod ipc_server;
mod log_cleaner;
mod log_reporter; mod log_reporter;
mod mqtt_listener; mod mqtt_listener;
mod persistence_manager; mod persistence_manager;
@@ -86,6 +88,24 @@ fn main() -> Result<()> {
return Ok(()); return Ok(());
} }
// Llamado por el MSI durante la desinstalación (CustomAction "CleanAgentData").
// Elimina C:\ProgramData\OmniOil\agent (claves, credenciales MQTT, DB, logs).
// Return::ignore en WiX — si falla, la desinstalación continúa igualmente.
if args.contains(&"--uninstall-cleanup".to_string()) {
#[cfg(windows)]
{
let agent_dir = std::path::PathBuf::from(r"C:\ProgramData\OmniOil");
if agent_dir.exists() {
if let Err(e) = std::fs::remove_dir_all(&agent_dir) {
eprintln!("⚠️ No se pudo limpiar datos del agente: {}", e);
} else {
println!("✅ Datos del agente eliminados correctamente.");
}
}
}
return Ok(());
}
#[cfg(windows)] #[cfg(windows)]
if args.len() == 1 { if args.len() == 1 {
if service_manager::run_service().is_ok() { if service_manager::run_service().is_ok() {
@@ -98,18 +118,24 @@ fn main() -> Result<()> {
.cloned() .cloned()
.unwrap_or_else(|| DEFAULT_CONFIG_PATH.to_string()); .unwrap_or_else(|| DEFAULT_CONFIG_PATH.to_string());
let config = AgentConfig::load_default(std::path::Path::new(&config_path)) // El provisioning asimétrico requiere async (llamada HTTP al servidor).
// Usamos un runtime ligero de hilo único solo para la carga de config,
// y luego el runtime multi-hilo principal para el agente.
let config = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()?
.block_on(AgentConfig::load_default(std::path::Path::new(&config_path)))
.with_context(|| "Failed to load agent configuration")?; .with_context(|| "Failed to load agent configuration")?;
let rt = tokio::runtime::Runtime::new()?; let rt = tokio::runtime::Runtime::new()?;
if let Err(e) = rt.block_on(run_agent(config)) { if let Err(e) = rt.block_on(run_agent(config, log_dir)) {
tracing::error!("Fatal error: {}", e); tracing::error!("Fatal error: {}", e);
return Err(e); return Err(e);
} }
Ok(()) Ok(())
} }
pub async fn run_agent(config: AgentConfig) -> Result<()> { pub async fn run_agent(config: AgentConfig, log_dir: std::path::PathBuf) -> Result<()> {
let project_id = Uuid::parse_str(&config.agent.project_id) let project_id = Uuid::parse_str(&config.agent.project_id)
.with_context(|| format!("Invalid project_id: {}", config.agent.project_id))?; .with_context(|| format!("Invalid project_id: {}", config.agent.project_id))?;
@@ -193,6 +219,27 @@ pub async fn run_agent(config: AgentConfig) -> Result<()> {
let _ = ipc_server::start_ipc_server(ipc_rx).await; let _ = ipc_server::start_ipc_server(ipc_rx).await;
}); });
// ─── Limpieza de logs locales ───────────────────────────────────────────────
// Retención: 30 días de archivos de log en disco (el dispositivo de campo
// tiene almacenamiento limitado y el agente corre 24/7).
tokio::spawn(log_cleaner::start_log_cleaner(log_dir, 30));
// ─── Limpieza de cola SQLite ────────────────────────────────────────────────
// Telemetría no enviada con más de 7 días es descartada para evitar
// acumulación ilimitada durante outages prolongados de MQTT.
let queue_storage = storage.clone();
tokio::spawn(async move {
// Limpieza inmediata al arrancar
let _ = queue_storage.cleanup_old_queue_entries(7);
let mut interval = tokio::time::interval(std::time::Duration::from_secs(24 * 3600));
interval.tick().await; // descarta el primer tick instantáneo
loop {
interval.tick().await;
let _ = queue_storage.cleanup_old_queue_entries(7);
}
});
// Iniciar Health Checker Nativo // Iniciar Health Checker Nativo
let health_mqtt = mqtt_client.clone(); let health_mqtt = mqtt_client.clone();
let health_config = config.clone(); let health_config = config.clone();

View File

@@ -1,6 +1,6 @@
//! MQTT Listener — Escucha señales de despliegue desde el servidor central. //! MQTT Listener — Escucha señales de despliegue desde el servidor central.
use rumqttc::{AsyncClient, MqttOptions, EventLoop, Event, Packet, QoS}; use rumqttc::{AsyncClient, MqttOptions, EventLoop, Event, Packet, QoS, LastWill};
use shared_lib::mqtt_messages::DeploySignal; use shared_lib::mqtt_messages::DeploySignal;
use tokio::sync::mpsc; use tokio::sync::mpsc;
use std::time::Duration; use std::time::Duration;
@@ -56,6 +56,23 @@ pub async fn setup_mqtt_client(
mqtt_options.set_keep_alive(Duration::from_secs(30)); mqtt_options.set_keep_alive(Duration::from_secs(30));
mqtt_options.set_clean_session(true); mqtt_options.set_clean_session(true);
// Last Will Testament: si el agente se desconecta inesperadamente,
// el broker publica automáticamente el estado "offline" de forma inmediata.
let lwt_topic = format!("omnioil/status/{}", project_id);
let lwt_payload = serde_json::json!({
"project_id": project_id,
"status": "offline",
"timestamp": chrono::Utc::now().to_rfc3339()
});
if let Ok(lwt_bytes) = serde_json::to_vec(&lwt_payload) {
mqtt_options.set_last_will(LastWill::new(
&lwt_topic,
lwt_bytes,
QoS::AtLeastOnce,
false, // retain=false
));
}
if let (Some(u), Some(p)) = (&config.mqtt.username, &config.mqtt.password) { if let (Some(u), Some(p)) = (&config.mqtt.username, &config.mqtt.password) {
tracing::info!("🔑 MQTT Auth: User='{}'", u); tracing::info!("🔑 MQTT Auth: User='{}'", u);
mqtt_options.set_credentials(u, p); mqtt_options.set_credentials(u, p);
@@ -76,7 +93,8 @@ pub fn start_mqtt_event_loop(
) -> tokio::task::JoinHandle<()> { ) -> tokio::task::JoinHandle<()> {
let project_id = &config.agent.project_id; let project_id = &config.agent.project_id;
let deploy_topic = format!("omnioil/deploy/{}", project_id); let deploy_topic = format!("omnioil/deploy/{}", project_id);
tokio::spawn(mqtt_event_loop(client, eventloop, tx, deploy_topic, log_reporter)) let status_topic = format!("omnioil/status/{}", project_id);
tokio::spawn(mqtt_event_loop(client, eventloop, tx, deploy_topic, status_topic, project_id.clone(), log_reporter))
} }
async fn mqtt_event_loop( async fn mqtt_event_loop(
@@ -84,6 +102,8 @@ async fn mqtt_event_loop(
mut eventloop: EventLoop, mut eventloop: EventLoop,
tx: mpsc::Sender<AgentCommand>, tx: mpsc::Sender<AgentCommand>,
deploy_topic: String, deploy_topic: String,
status_topic: String,
project_id: String,
log_reporter: Arc<LogReporter>, log_reporter: Arc<LogReporter>,
) { ) {
loop { loop {
@@ -111,13 +131,23 @@ async fn mqtt_event_loop(
Ok(Event::Incoming(Packet::ConnAck(_))) => { Ok(Event::Incoming(Packet::ConnAck(_))) => {
log_reporter.info("MQTT", &format!("Subscribing to: {}", deploy_topic)).await; log_reporter.info("MQTT", &format!("Subscribing to: {}", deploy_topic)).await;
// Suscribirse explícitamente en cada conexión ya que usamos clean_session = true // Suscribirse explícitamente en cada conexión (clean_session = true)
if let Err(e) = client.subscribe(&deploy_topic, QoS::AtLeastOnce).await { if let Err(e) = client.subscribe(&deploy_topic, QoS::AtLeastOnce).await {
log_reporter.error("MQTT", &format!("Failed to re-subscribe to {}: {}", deploy_topic, e)).await; log_reporter.error("MQTT", &format!("Failed to re-subscribe to {}: {}", deploy_topic, e)).await;
} else { } else {
log_reporter.info("MQTT", &format!("Subscribed to MQTT topic: {}", deploy_topic)).await; log_reporter.info("MQTT", &format!("Subscribed to MQTT topic: {}", deploy_topic)).await;
} }
// Notificar al servidor que el agente está online (para actualización inmediata de UI)
let online_payload = serde_json::json!({
"project_id": project_id,
"status": "online",
"timestamp": chrono::Utc::now().to_rfc3339()
});
if let Ok(bytes) = serde_json::to_vec(&online_payload) {
let _ = client.publish(&status_topic, QoS::AtLeastOnce, false, bytes).await;
}
let _ = tx.send(AgentCommand::Reconnected).await; let _ = tx.send(AgentCommand::Reconnected).await;
} }
Ok(_) => {} Ok(_) => {}

View File

@@ -150,4 +150,24 @@ impl StoreAndForward {
Ok(sent_count) Ok(sent_count)
} }
/// Elimina entradas de la cola con más de `max_days` días de antigüedad.
///
/// Útil para evitar acumulación ilimitada durante outages prolongados de MQTT.
/// La telemetría tan antigua ya no tiene utilidad operativa.
pub fn cleanup_old_queue_entries(&self, max_days: i64) -> Result<usize> {
let cutoff = (Utc::now() - chrono::Duration::days(max_days)).to_rfc3339();
let conn = self.conn.lock().map_err(|e| anyhow::anyhow!("Lock poisoned: {}", e))?;
let deleted = conn.execute(
"DELETE FROM telemetry_queue WHERE created_at < ?1",
params![cutoff],
)?;
if deleted > 0 {
tracing::info!(
"🧹 Queue cleaner: {} entrada(s) de telemetría antigua(s) eliminada(s) (>{} días)",
deleted, max_days
);
}
Ok(deleted)
}
} }

View File

@@ -66,12 +66,14 @@ fn service_handler() -> Result<()> {
let rt = tokio::runtime::Runtime::new().context("Failed to create tokio runtime")?; let rt = tokio::runtime::Runtime::new().context("Failed to create tokio runtime")?;
rt.block_on(async { rt.block_on(async {
let exe_path = std::env::current_exe().unwrap(); let exe_path = std::env::current_exe().unwrap();
let log_dir = exe_path.parent().unwrap_or(std::path::Path::new(".")).join("logs");
let _ = std::fs::create_dir_all(&log_dir);
let config_path = exe_path.with_file_name("agent.toml"); let config_path = exe_path.with_file_name("agent.toml");
match crate::config::AgentConfig::load_default(&config_path) { match crate::config::AgentConfig::load_default(&config_path).await {
Ok(config) => { Ok(config) => {
tokio::select! { tokio::select! {
res = crate::run_agent(config) => { res = crate::run_agent(config, log_dir) => {
if let Err(e) = res { if let Err(e) = res {
tracing::error!("Agent exited with error: {}", e); tracing::error!("Agent exited with error: {}", e);
} }

View File

@@ -58,11 +58,25 @@
</Directory> </Directory>
</Directory> </Directory>
<!-- Feature principal -->
<Feature Id='MainFeature' Title='Main Feature' Level='1'> <Feature Id='MainFeature' Title='Main Feature' Level='1'>
<ComponentRef Id='MainExecutable' /> <ComponentRef Id='MainExecutable' />
<ComponentRef Id='TrayExecutable' /> <ComponentRef Id='TrayExecutable' />
<ComponentRef Id='IconFile' /> <ComponentRef Id='IconFile' />
</Feature> </Feature>
<!--
NOTA sobre limpieza al desinstalar:
wixl (compilador Linux) NO soporta correctamente CustomAction con
FileKey+ExeCommand - genera error 2613 en Windows Installer.
Los datos del agente en C:\ProgramData\OmniOil se pueden limpiar
manualmente ejecutando: edge-agent.exe con el flag uninstall-cleanup
Ese flag esta implementado en el binario para uso desde scripts
de despliegue o administracion remota.
-->
</Product> </Product>
</Wix> </Wix>

View File

@@ -141,6 +141,7 @@ export default function DashboardPage() {
const [visibleVars, setVisibleVars] = useState<Record<string, boolean>>({}) const [visibleVars, setVisibleVars] = useState<Record<string, boolean>>({})
const activeProjectId = useAuthStore(s => s.activeProjectId) const activeProjectId = useAuthStore(s => s.activeProjectId)
const token = useAuthStore(s => s.token)
// Referencia para evitar cierres de ciclo y efectos innecesarios // Referencia para evitar cierres de ciclo y efectos innecesarios
const assetsRef = useRef<AssetData[]>([]) const assetsRef = useRef<AssetData[]>([])
@@ -148,7 +149,7 @@ export default function DashboardPage() {
// WebSocket Telemetry Relay // WebSocket Telemetry Relay
useEffect(() => { useEffect(() => {
if (!activeProjectId) return; if (!activeProjectId || !token) return;
const apiBase = import.meta.env.VITE_API_URL || ''; const apiBase = import.meta.env.VITE_API_URL || '';
// Construcción inteligente del protocolo WebSocket // Construcción inteligente del protocolo WebSocket
@@ -159,7 +160,7 @@ export default function DashboardPage() {
: window.location.host; : window.location.host;
const wsBase = `${isSecure ? 'wss' : 'ws'}://${wsHost}`; const wsBase = `${isSecure ? 'wss' : 'ws'}://${wsHost}`;
const wsUrl = `${wsBase}/api/ws/telemetry?project_id=${activeProjectId}`; const wsUrl = `${wsBase}/api/ws/telemetry?project_id=${activeProjectId}&token=${token}`;
let socket: WebSocket | null = null; let socket: WebSocket | null = null;
let reconnectTimer: NodeJS.Timeout; let reconnectTimer: NodeJS.Timeout;
let mounted = true; let mounted = true;
@@ -171,8 +172,18 @@ export default function DashboardPage() {
socket.onmessage = (event) => { socket.onmessage = (event) => {
try { try {
const payload = JSON.parse(event.data); const payload = JSON.parse(event.data);
const assetName = payload.asset || payload.device || payload.project || 'UNKNOWN';
const assetId = payload.asset_id || payload.asset || assetName; // Mapeo inteligente de nombres y IDs
const assetName = payload.asset || payload.device || payload.device_name || payload.project || 'UNKNOWN';
// Intentar obtener el ID (el agente ahora envía asset_id)
let assetId = payload.asset_id || payload.id;
if (!assetId || assetId === 'UNKNOWN') {
// Intentar buscar en la lista actual si conocemos este nombre
const existing = assetsRef.current.find(a => a.asset === assetName);
assetId = existing ? existing.asset_id : assetName;
}
// Actualización atómica de assets // Actualización atómica de assets
setAssets(prev => { setAssets(prev => {
@@ -218,7 +229,7 @@ export default function DashboardPage() {
socket.close(1000, 'Component unmounted'); socket.close(1000, 'Component unmounted');
} }
}; };
}, [activeProjectId]); }, [activeProjectId, token]);
// Initial Load (One time) // Initial Load (One time)
useEffect(() => { useEffect(() => {
@@ -241,7 +252,7 @@ export default function DashboardPage() {
// Buscamos el ID en la referencia estable para no depender de la mutación de 'assets' // Buscamos el ID en la referencia estable para no depender de la mutación de 'assets'
const selectedObj = assetsRef.current.find(a => a.asset === selectedAsset); const selectedObj = assetsRef.current.find(a => a.asset === selectedAsset);
if (selectedObj?.asset_id) { if (selectedObj?.asset_id && selectedObj.asset_id !== 'UNKNOWN') {
console.log('📡 Cargando Historial para:', selectedAsset); console.log('📡 Cargando Historial para:', selectedAsset);
fetchAssetTelemetry(selectedObj.asset_id, 100) fetchAssetTelemetry(selectedObj.asset_id, 100)
.then(res => { .then(res => {

View File

@@ -92,3 +92,13 @@ export async function rebuildInstaller(projectId: string) {
token, token,
}) })
} }
export async function getLinuxInstallScript(projectId: string): Promise<string> {
const token = useAuthStore.getState().token
const apiBase = (window as any).__VITE_API_BASE__ || import.meta.env.VITE_API_BASE || '/api';
const response = await fetch(`${apiBase}/edge/projects/${projectId}/linux-install`, {
headers: { 'Authorization': `Bearer ${token}` }
})
if (!response.ok) throw new Error('Error generando script de instalación Linux')
return response.text()
}

View File

@@ -3,11 +3,11 @@ import { useAuthStore } from '@/features/auth/stores/auth-store'
import { toast } from 'sonner' import { toast } from 'sonner'
import { useParams, Link } from 'react-router-dom' import { useParams, Link } from 'react-router-dom'
import { listAssets, createAsset, EdgeAsset } from '../api/wells-api' import { listAssets, createAsset, EdgeAsset } from '../api/wells-api'
import { getProject, EdgeProject, deploySystem, triggerBuild, listDeployments, EdgeDeployment, rebuildInstaller } from '../api/projects-api' import { getProject, EdgeProject, deploySystem, triggerBuild, listDeployments, EdgeDeployment, rebuildInstaller, getLinuxInstallScript } from '../api/projects-api'
import { getAssetTelemetry, TelemetryRecord } from '../api/telemetry-api' import { getAssetTelemetry, TelemetryRecord } from '../api/telemetry-api'
import { import {
Plus, Loader2, ArrowLeft, Droplets, MapPin, HardDrive, Cpu, Download, Plus, Loader2, ArrowLeft, Droplets, MapPin, HardDrive, Cpu, Download,
Activity, RefreshCcw, CheckCircle2, AlertCircle Activity, RefreshCcw, CheckCircle2, AlertCircle, Terminal, Copy, CheckCheck
} from 'lucide-react' } from 'lucide-react'
import { Card, CardContent } from '@/components/ui/card' import { Card, CardContent } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
@@ -18,6 +18,7 @@ import AgentLogViewer from '../components/AgentLogViewer'
export default function ProjectDetailsPage() { export default function ProjectDetailsPage() {
const { id } = useParams<{ id: string }>() const { id } = useParams<{ id: string }>()
const token = useAuthStore(s => s.token)
const [project, setProject] = useState<EdgeProject | null>(null) const [project, setProject] = useState<EdgeProject | null>(null)
const [assets, setAssets] = useState<EdgeAsset[]>([]) const [assets, setAssets] = useState<EdgeAsset[]>([])
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
@@ -27,6 +28,10 @@ export default function ProjectDetailsPage() {
const [lastDeployment, setLastDeployment] = useState<EdgeDeployment | null>(null) const [lastDeployment, setLastDeployment] = useState<EdgeDeployment | null>(null)
const [selectedAsset, setSelectedAsset] = useState<EdgeAsset | null>(null) const [selectedAsset, setSelectedAsset] = useState<EdgeAsset | null>(null)
const [isOnline, setIsOnline] = useState(false) const [isOnline, setIsOnline] = useState(false)
const [showInstallModal, setShowInstallModal] = useState(false)
const [linuxScript, setLinuxScript] = useState<string | null>(null)
const [loadingScript, setLoadingScript] = useState(false)
const [copied, setCopied] = useState(false)
// Usamos un Ref para el estado previo para evitar stale closures en el polling // Usamos un Ref para el estado previo para evitar stale closures en el polling
const prevStatusRef = useRef<string | null>(null) const prevStatusRef = useRef<string | null>(null)
@@ -56,7 +61,7 @@ export default function ProjectDetailsPage() {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const host = window.location.host; const host = window.location.host;
const wsUrl = `${protocol}//${host}/api/ws/telemetry?project_id=${id}`; const wsUrl = `${protocol}//${host}/api/ws/telemetry?project_id=${id}&token=${token}`;
let socket: WebSocket | null = null; let socket: WebSocket | null = null;
let reconnectTimeout: NodeJS.Timeout; let reconnectTimeout: NodeJS.Timeout;
@@ -115,7 +120,7 @@ export default function ProjectDetailsPage() {
socket.close(1000, 'Component unmounted'); socket.close(1000, 'Component unmounted');
} }
}; };
}, [id]); }, [id, token]);
// Polling de seguridad (se mantiene como fallback) // Polling de seguridad (se mantiene como fallback)
useEffect(() => { useEffect(() => {
@@ -210,6 +215,42 @@ export default function ProjectDetailsPage() {
} }
} }
const handleGetLinuxInstall = async () => {
if (!project?.id) return
setLoadingScript(true)
setShowInstallModal(true)
setLinuxScript(null)
try {
const script = await getLinuxInstallScript(project.id)
setLinuxScript(script)
} catch (e: any) {
toast.error('Error generando instalador Linux', { description: e.message })
setShowInstallModal(false)
} finally {
setLoadingScript(false)
}
}
const handleCopyScript = () => {
if (!linuxScript) return
const apiBase = import.meta.env.VITE_API_BASE || '/api'
const curlCmd = `curl -sSL ${window.location.origin}${apiBase}/edge/projects/${project?.id}/linux-install | sudo bash`
navigator.clipboard.writeText(curlCmd)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}
const handleDownloadScript = () => {
if (!linuxScript || !project) return
const blob = new Blob([linuxScript], { type: 'text/x-shellscript' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `install-omnioil-${project.id.split('-')[0]}.sh`
a.click()
URL.revokeObjectURL(url)
}
const handleCreateWell = async (e: React.FormEvent) => { const handleCreateWell = async (e: React.FormEvent) => {
e.preventDefault() e.preventDefault()
if (!id) return if (!id) return
@@ -279,6 +320,7 @@ export default function ProjectDetailsPage() {
<div className="flex flex-col sm:flex-row items-stretch sm:items-center gap-2 ml-0 md:ml-auto w-full md:w-auto"> <div className="flex flex-col sm:flex-row items-stretch sm:items-center gap-2 ml-0 md:ml-auto w-full md:w-auto">
{project && ( {project && (
<div className="flex flex-col sm:flex-row items-stretch gap-2"> <div className="flex flex-col sm:flex-row items-stretch gap-2">
{/* Windows MSI */}
<Button <Button
size="sm" size="sm"
disabled={!isInstallerReady} disabled={!isInstallerReady}
@@ -286,7 +328,17 @@ export default function ProjectDetailsPage() {
className={cn('text-xs h-9 px-4 w-full sm:w-auto', isInstallerReady ? 'bg-success hover:bg-success/90' : 'bg-secondary')} className={cn('text-xs h-9 px-4 w-full sm:w-auto', isInstallerReady ? 'bg-success hover:bg-success/90' : 'bg-secondary')}
> >
<Download className="h-3.5 w-3.5 mr-2" /> <Download className="h-3.5 w-3.5 mr-2" />
{isInstallerReady ? "Descargar Instalador" : isInstallerBuilding ? "Generando..." : "Error"} {isInstallerReady ? "Windows (.msi)" : isInstallerBuilding ? "Generando..." : "Error"}
</Button>
{/* Linux */}
<Button
size="sm"
variant="outline"
onClick={handleGetLinuxInstall}
className="text-xs h-9 px-4 w-full sm:w-auto border-primary/40 text-primary hover:bg-primary/10"
>
<Terminal className="h-3.5 w-3.5 mr-2" />
Linux (SSH)
</Button> </Button>
<Button <Button
size="sm" size="sm"
@@ -381,6 +433,60 @@ export default function ProjectDetailsPage() {
onClose={() => setSelectedAsset(null)} onClose={() => setSelectedAsset(null)}
/> />
)} )}
{/* Modal: Instalar en Linux */}
{showInstallModal && (
<div className="fixed inset-0 z-50 bg-black/60 flex items-center justify-center p-4">
<div className="bg-card border border-border rounded-xl shadow-xl w-full max-w-2xl">
<div className="flex items-center justify-between p-4 border-b border-border">
<div className="flex items-center gap-2">
<Terminal className="h-4 w-4 text-primary" />
<h2 className="font-semibold text-sm">Instalar en Linux (via SSH)</h2>
</div>
<button onClick={() => setShowInstallModal(false)} className="text-muted-foreground hover:text-foreground text-lg leading-none"></button>
</div>
<div className="p-4 space-y-4">
<p className="text-xs text-muted-foreground">
Ejecuta este comando en el dispositivo de campo como <code className="bg-muted px-1 rounded">root</code>. El token expira en 7 días.
</p>
{loadingScript ? (
<div className="flex items-center gap-2 py-4 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" /> Generando token seguro...
</div>
) : linuxScript ? (
<>
<div className="bg-muted rounded-lg p-3 font-mono text-xs break-all border border-border">
<span className="text-primary">curl</span> -sSL{' '}
<span className="text-yellow-500 break-all">
{window.location.origin}{import.meta.env.VITE_API_BASE || '/api'}/edge/projects/{project?.id}/linux-install
</span>{' '}
| <span className="text-primary">sudo bash</span>
</div>
<div className="flex gap-2">
<Button size="sm" onClick={handleCopyScript} className="flex-1 text-xs">
{copied ? <CheckCheck className="h-3.5 w-3.5 mr-1 text-success" /> : <Copy className="h-3.5 w-3.5 mr-1" />}
{copied ? '¡Copiado!' : 'Copiar comando'}
</Button>
<Button size="sm" variant="outline" onClick={handleDownloadScript} className="flex-1 text-xs">
<Download className="h-3.5 w-3.5 mr-1" />
Descargar .sh
</Button>
</div>
<div className="text-[10px] text-muted-foreground bg-muted/50 rounded p-2 border border-dashed">
<strong>Arquitecturas soportadas:</strong> x86_64, aarch64 (ARM64)<br/>
<strong>Requisitos:</strong> systemd, curl o wget<br/>
<strong>Crea:</strong> usuario <code>omnioil-agent</code>, servicio <code>omnioil-edge-agent</code>, datos en <code>/var/lib/omnioil/agent</code>
</div>
</>
) : null}
</div>
</div>
</div>
)}
</div> </div>
) )
} }

View File

@@ -98,11 +98,13 @@ export const useAuthStore = create<AuthState>()(
}), }),
{ {
name: 'omnioil-admin-auth', name: 'omnioil-admin-auth',
version: 2, version: 3,
// Solo persistir datos de UI (email, rol, proyectos, proyecto activo).
// Los tokens NO se persisten en localStorage (HIGH-8):
// - accessToken: en memoria de Zustand (se pierde al recargar página)
// - refreshToken: en cookie HttpOnly del servidor (no accesible por JS)
// Al recargar, el SessionCoordinator llama /api/auth/refresh vía cookie.
partialize: (state) => ({ partialize: (state) => ({
accessToken: state.accessToken,
refreshToken: state.refreshToken,
token: state.token,
user: state.user, user: state.user,
projects: state.projects, projects: state.projects,
activeProjectId: state.activeProjectId, activeProjectId: state.activeProjectId,

View File

@@ -17,6 +17,7 @@ interface RefreshResponse {
access_token?: string access_token?: string
token?: string token?: string
role?: string role?: string
email?: string
projects?: Array<{ id: string; name: string }> projects?: Array<{ id: string; name: string }>
} }
@@ -71,19 +72,18 @@ export class SessionCoordinator {
private static async performRefresh(): Promise<string | null> { private static async performRefresh(): Promise<string | null> {
const store = useAuthStore.getState() const store = useAuthStore.getState()
const refreshToken = store.refreshToken
const email = store.user?.email const email = store.user?.email
if (!refreshToken || !email) { // El refresh_token viaja como cookie HttpOnly — no se necesita leerlo del estado.
return null // El browser lo envía automáticamente gracias a `credentials: 'include'`.
}
const response = await fetch(`${API_BASE}/auth/refresh`, { const response = await fetch(`${API_BASE}/auth/refresh`, {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
}, },
body: JSON.stringify({ refresh_token: refreshToken }), // Necesario para que el browser envíe la cookie del mismo dominio
credentials: 'include',
body: JSON.stringify({}),
}) })
if (!response.ok) { if (!response.ok) {
@@ -100,9 +100,9 @@ export class SessionCoordinator {
store.setSession({ store.setSession({
accessToken: nextAccessToken, accessToken: nextAccessToken,
refreshToken, refreshToken: null, // El refreshToken vive en cookie HttpOnly, no en estado
role: nextRole, role: nextRole,
email, email: email ?? data.email ?? '',
projects: data.projects ?? store.projects, projects: data.projects ?? store.projects,
}) })

View File

@@ -111,11 +111,9 @@ export const useAuthStore = create<AuthState>()(
}), }),
{ {
name: 'omnioil-pwa-auth', name: 'omnioil-pwa-auth',
version: 2, version: 3,
// Solo persistir datos de UI, no tokens (HIGH-8)
partialize: (state) => ({ partialize: (state) => ({
accessToken: state.accessToken,
refreshToken: state.refreshToken,
token: state.token,
user: state.user, user: state.user,
projects: state.projects, projects: state.projects,
activeProjectId: state.activeProjectId, activeProjectId: state.activeProjectId,

View File

@@ -17,6 +17,7 @@ interface RefreshResponse {
access_token?: string access_token?: string
token?: string token?: string
role?: string role?: string
email?: string
projects?: Array<{ id: string; name: string }> projects?: Array<{ id: string; name: string }>
} }
@@ -71,19 +72,15 @@ export class SessionCoordinator {
private static async performRefresh(): Promise<string | null> { private static async performRefresh(): Promise<string | null> {
const store = useAuthStore.getState() const store = useAuthStore.getState()
const refreshToken = store.refreshToken
const email = store.user?.email
if (!refreshToken || !email) {
return null
}
// El refresh_token viaja como cookie HttpOnly — no se necesita leerlo del estado.
const response = await fetch(`${API_BASE}/auth/refresh`, { const response = await fetch(`${API_BASE}/auth/refresh`, {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
}, },
body: JSON.stringify({ refresh_token: refreshToken }), credentials: 'include',
body: JSON.stringify({}),
}) })
if (!response.ok) { if (!response.ok) {
@@ -100,9 +97,9 @@ export class SessionCoordinator {
store.setSession({ store.setSession({
accessToken: nextAccessToken, accessToken: nextAccessToken,
refreshToken, refreshToken: null, // vive en cookie HttpOnly
role: nextRole, role: nextRole,
email, email: data.email ?? store.user?.email ?? '',
projects: data.projects ?? store.projects, projects: data.projects ?? store.projects,
lastResolvedDomain: store.lastResolvedDomain ?? window.location.hostname, lastResolvedDomain: store.lastResolvedDomain ?? window.location.hostname,
}) })

View File

@@ -15,6 +15,7 @@ pbkdf2 = { workspace = true }
hmac = "0.12.1" hmac = "0.12.1"
rand = { workspace = true } rand = { workspace = true }
base64 = "0.22.1" base64 = "0.22.1"
p256 = { version = "0.13", features = ["ecdh", "pkcs8", "pem"] }
getrandom = "0.2.15" getrandom = "0.2.15"
sha2 = { workspace = true } sha2 = { workspace = true }
rust_decimal = "1.41.0" rust_decimal = "1.41.0"

View File

@@ -3,5 +3,6 @@ pub mod events;
pub mod models; pub mod models;
pub mod mqtt_messages; pub mod mqtt_messages;
pub mod overlay; pub mod overlay;
pub mod provisioning;
pub mod mqtt_security; pub mod mqtt_security;
pub mod compression; pub mod compression;

View File

@@ -1,50 +1,125 @@
use aes_gcm::{
Aes256Gcm, Nonce,
aead::{Aead, KeyInit},
};
use pbkdf2::pbkdf2_hmac;
use rand::RngCore;
use serde_json::Value; use serde_json::Value;
use sha2::Sha256;
use std::fs; use std::fs;
use std::io::{Read, Write}; use std::io::{Read, Write};
use std::path::Path; use std::path::Path;
const MAGIC: &[u8; 8] = b"OMNIOIL\0"; /// Bytes de firma para el footer del overlay de provisioning (v2).
const SALT: &[u8; 15] = b"omnioil_v4_salt"; // Sal fija para derivación (en prod esto debería ser dinámico) const MAGIC: &[u8; 8] = b"OMNOIL2\0";
/// Deriva una llave de 32 bytes (AES-256) a partir de un Master Secret // ─── Provisioning Stub ────────────────────────────────────────────────────────
fn derive_key(master_secret: &str) -> [u8; 32] {
let mut key = [0u8; 32]; /// Datos mínimos incrustados en cada binario de agente.
pbkdf2_hmac::<Sha256>(master_secret.as_bytes(), SALT, 1000, &mut key); ///
key /// **No contiene secretos.** El token es de un solo uso y solo sirve para
/// autenticar la primera conexión MQTT del agente. Todo el material criptográfico
/// sensible viaja cifrado a través del broker MQTT y nunca toca el binario.
///
/// Todo el flujo de aprovisionamiento va por MQTT — el backend nunca
/// es accesible directamente desde el exterior.
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct ProvisioningStub {
/// Host público del broker MQTT (ej. `mqtt.omnioil.io`).
pub mqtt_host: String,
/// Puerto del broker MQTT (ej. `1883` TCP o `9883` WebSocket).
pub mqtt_port: u16,
/// Si `true`, el agente conecta vía WebSocket en lugar de TCP plain.
/// Necesario cuando el puerto público es el 9883 (WS) de Mosquitto.
#[serde(default)]
pub use_ws: bool,
/// UUID del proyecto al que pertenece este agente.
pub project_id: String,
/// Token de provisioning de un solo uso (UUID v4).
/// Usado como contraseña MQTT del usuario temporal `provision:{project_id}`.
pub provisioning_token: String,
} }
/// Genera un binario personalizado agregando la configuración al final del ejecutable (Overlay) /// Genera un binario de agente con un **stub de provisioning** embebido.
/// con encriptación AES-256-GCM. ///
pub fn generate_custom_binary( /// El binario resultante **NO contiene secretos** — solo los datos mínimos
/// para que el agente sepa dónde registrarse en el primer arranque.
///
/// Formato del overlay:
/// `[stub_json:N][stub_len:4LE][MAGIC:8]`
pub fn generate_provisioning_binary(
template_path: &Path, template_path: &Path,
output_path: &Path, output_path: &Path,
config: &Value, stub: &ProvisioningStub,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> { ) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
// 1. Obtener la clave obligatoria del entorno (solo requerida en el servidor para generar) let stub_json =
let master_secret = std::env::var("OMNIOIL_MASTER_SECRET") serde_json::to_vec(stub).map_err(|e| format!("Error serializando stub: {}", e))?;
.map_err(|_| "OMNIOIL_MASTER_SECRET must be set for binary generation")?; let stub_len = stub_json.len() as u32;
// 2. Convertir JSON config a TOML simple let mut template_content = Vec::new();
let mut config_toml = String::new(); fs::File::open(template_path)?.read_to_end(&mut template_content)?;
if let Some(parent) = output_path.parent() {
fs::create_dir_all(parent)?;
}
let mut out_file = fs::File::create(output_path)?;
out_file.write_all(&template_content)?;
out_file.write_all(&stub_json)?;
out_file.write_all(&stub_len.to_le_bytes())?;
out_file.write_all(MAGIC)?;
Ok(())
}
/// Extrae el `ProvisioningStub` del ejecutable en ejecución.
pub fn find_provisioning_stub() -> Option<ProvisioningStub> {
let current_exe = std::env::current_exe().ok()?;
extract_provisioning_stub_from_file(&current_exe)
}
/// Extrae el `ProvisioningStub` de un archivo binario dado (útil para tests).
pub fn extract_provisioning_stub_from_file(path: &Path) -> Option<ProvisioningStub> {
let mut file = fs::File::open(path).ok()?;
let mut buffer = Vec::new();
file.read_to_end(&mut buffer).ok()?;
// Mínimo: 1 byte JSON + 4 bytes len + 8 bytes magic
if buffer.len() < 13 {
return None;
}
let footer_start = buffer.len() - 8;
if &buffer[footer_start..] != MAGIC {
return None;
}
let len_start = footer_start - 4;
let mut len_bytes = [0u8; 4];
len_bytes.copy_from_slice(&buffer[len_start..footer_start]);
let stub_len = u32::from_le_bytes(len_bytes) as usize;
let stub_start = len_start.checked_sub(stub_len)?;
let stub_json = &buffer[stub_start..len_start];
serde_json::from_slice(stub_json).ok()
}
// ─── Config JSON → TOML ───────────────────────────────────────────────────────
/// Convierte el JSON de configuración del agente al formato TOML requerido.
///
/// Se llama en el build-orchestrator para generar el TOML que se almacena
/// en la base de datos y se devuelve cifrado durante el provisioning.
pub fn config_json_to_toml(
config: &Value,
) -> Result<String, Box<dyn std::error::Error + Send + Sync>> {
let mut toml = String::new();
if let Some(obj) = config.as_object() { if let Some(obj) = config.as_object() {
for (section, values) in obj { for (section, values) in obj {
config_toml.push_str(&format!("[{}]\n", section)); toml.push_str(&format!("[{}]\n", section));
if let Some(vals) = values.as_object() { if let Some(vals) = values.as_object() {
for (k, v) in vals { for (k, v) in vals {
match v { match v {
Value::String(s) => config_toml.push_str(&format!("{} = \"{}\"\n", k, s)), Value::String(s) => toml.push_str(&format!("{} = \"{}\"\n", k, s)),
Value::Bool(b) => config_toml.push_str(&format!("{} = {}\n", k, b)), Value::Bool(b) => toml.push_str(&format!("{} = {}\n", k, b)),
Value::Number(n) => config_toml.push_str(&format!("{} = {}\n", k, n)), Value::Number(n) => toml.push_str(&format!("{} = {}\n", k, n)),
_ => { _ => {
let val_json = serde_json::to_string(v)?; let val_json = serde_json::to_string(v)?;
config_toml.push_str(&format!( toml.push_str(&format!(
"{} = \"{}\"\n", "{} = \"{}\"\n",
k, k,
val_json.replace('"', "\\\"") val_json.replace('"', "\\\"")
@@ -53,119 +128,13 @@ pub fn generate_custom_binary(
} }
} }
} }
config_toml.push_str("\n"); toml.push('\n');
} }
} }
Ok(toml)
// 3. Encriptar con AES-256-GCM usando la clave original
let key_bytes = derive_key(&master_secret);
let cipher = Aes256Gcm::new_from_slice(&key_bytes)
.map_err(|e| format!("Cipher Initialization Error: {}", e))?;
let mut nonce_bytes = [0u8; 12];
rand::thread_rng().fill_bytes(&mut nonce_bytes);
let nonce = Nonce::from_slice(&nonce_bytes);
let ciphertext = cipher
.encrypt(nonce, config_toml.as_bytes())
.map_err(|e| format!("Encryption failed: {}", e))?;
// 4. Ofuscar el Master Secret para viajar en el binario sin estar en texto plano
// Usamos XOR con 0x5A para ofuscación simple (mismo patrón que BAKED_CONFIG)
let obfuscated_secret: Vec<u8> = master_secret.as_bytes().iter().map(|b| b ^ 0x5A).collect();
let secret_len = obfuscated_secret.len();
if secret_len > 255 {
return Err("Master secret is too long".into());
}
// Estructura del Payload: [Nonce (12)] + [SecretLen (1)] + [ObfuscatedSecret (N)] + [Ciphertext (M)]
let mut payload = Vec::new();
payload.extend_from_slice(&nonce_bytes);
payload.push(secret_len as u8);
payload.extend_from_slice(&obfuscated_secret);
payload.extend_from_slice(&ciphertext);
let total_payload_len = payload.len() as u32;
// 5. Leer plantilla y construir binario final
let mut template_content = Vec::new();
fs::File::open(template_path)?.read_to_end(&mut template_content)?;
let length_bytes = total_payload_len.to_le_bytes();
if let Some(parent) = output_path.parent() {
fs::create_dir_all(parent)?;
}
let mut out_file = fs::File::create(output_path)?;
out_file.write_all(&template_content)?;
out_file.write_all(&payload)?; // Todo el bloque (nonce, secret, ciphertext)
out_file.write_all(&length_bytes)?;
out_file.write_all(MAGIC)?;
Ok(())
} }
/// Versión de la función que permite especificar la ruta (útil para tests) // ─── Tests ────────────────────────────────────────────────────────────────────
pub fn extract_overlay_from_file(path: &Path) -> Option<String> {
let mut file = fs::File::open(path).ok()?;
let mut buffer = Vec::new();
file.read_to_end(&mut buffer).ok()?;
if buffer.len() < 12 + 1 + 4 + 8 {
return None;
}
// Comprobar Magic
let footer_start = buffer.len() - 8;
if &buffer[footer_start..] != MAGIC {
return None;
}
// Leer longitud total del payload (4 bytes antes del magic)
let len_start = footer_start - 4;
let mut len_bytes = [0u8; 4];
len_bytes.copy_from_slice(&buffer[len_start..footer_start]);
let total_payload_len = u32::from_le_bytes(len_bytes) as usize;
let payload_start = len_start - total_payload_len;
if payload_start >= len_start {
return None;
}
// --- Extracción del Payload por segmentos ---
// [Nonce (12)] + [SecretLen (1)] + [ObfuscatedSecret (N)] + [Ciphertext (M)]
// Nonce
let nonce_bytes = &buffer[payload_start..payload_start + 12];
// Secret Length & Secret
let secret_len = buffer[payload_start + 12] as usize;
if payload_start + 13 + secret_len > len_start {
return None;
}
let obfuscated_secret = &buffer[payload_start + 13..payload_start + 13 + secret_len];
// Ciphertext
let ciphertext = &buffer[payload_start + 13 + secret_len..len_start];
// 1. Reconstruir Master Secret desde el ofuscado (XOR 0x5A)
let master_secret: String = obfuscated_secret.iter().map(|b| (b ^ 0x5A) as char).collect();
// 2. Derivar llave y desencriptar
let key_bytes = derive_key(&master_secret);
let cipher = Aes256Gcm::new_from_slice(&key_bytes).ok()?;
let nonce = Nonce::from_slice(nonce_bytes);
let plaintext_bytes = cipher.decrypt(nonce, ciphertext).ok()?;
String::from_utf8(plaintext_bytes).ok()
}
/// Intenta encontrar, desencriptar y parsear el overlay de configuración en el ejecutable actual.
pub fn find_and_decrypt_overlay() -> Option<String> {
let current_exe = std::env::current_exe().ok()?;
extract_overlay_from_file(&current_exe)
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
@@ -173,29 +142,39 @@ mod tests {
use tempfile::NamedTempFile; use tempfile::NamedTempFile;
#[test] #[test]
fn test_overlay_encryption_cycle() { fn test_provisioning_stub_roundtrip() {
let config = serde_json::json!({ let stub = ProvisioningStub {
"database": { mqtt_host: "mqtt.omnioil.io".to_string(),
"url": "postgres://localhost/test", mqtt_port: 1883,
"pool_size": 10 project_id: "550e8400-e29b-41d4-a716-446655440000".to_string(),
}, provisioning_token: "test-token-123".to_string(),
"features": { };
"enable_telemetry": true
}
});
// Crear archivos temporales
let template = NamedTempFile::new().unwrap(); let template = NamedTempFile::new().unwrap();
let output = NamedTempFile::new().unwrap(); let output = NamedTempFile::new().unwrap();
// 1. Generar binario con overlay generate_provisioning_binary(template.path(), output.path(), &stub)
generate_custom_binary(template.path(), output.path(), &config).expect("Generation failed"); .expect("generate_provisioning_binary");
// 2. Extraer y verificar let extracted =
let decrypted = extract_overlay_from_file(output.path()).expect("Extraction failed"); extract_provisioning_stub_from_file(output.path()).expect("extract stub");
assert!(decrypted.contains("[database]")); assert_eq!(extracted.mqtt_host, stub.mqtt_host);
assert!(decrypted.contains("url = \"postgres://localhost/test\"")); assert_eq!(extracted.mqtt_port, stub.mqtt_port);
assert!(decrypted.contains("enable_telemetry = true")); assert_eq!(extracted.project_id, stub.project_id);
assert_eq!(extracted.provisioning_token, stub.provisioning_token);
}
#[test]
fn test_config_json_to_toml() {
let config = serde_json::json!({
"agent": {"project_id": "abc", "hostname": "field-01"},
"mqtt": {"host": "mqtt.example.com", "port": 8883, "use_tls": true}
});
let toml = config_json_to_toml(&config).unwrap();
assert!(toml.contains("[agent]"));
assert!(toml.contains("project_id = \"abc\""));
assert!(toml.contains("port = 8883"));
assert!(toml.contains("use_tls = true"));
} }
} }

View File

@@ -0,0 +1,208 @@
//! Criptografía asimétrica para el provisioning seguro de agentes.
//!
//! Implementa ECIES (Elliptic Curve Integrated Encryption Scheme) usando:
//! - P256 ECDH para el acuerdo de clave
//! - HKDF-SHA256 (RFC 5869) para la derivación de clave AES
//! - AES-256-GCM para el cifrado simétrico autenticado
//!
//! El flujo de provisioning es:
//! 1. El agente genera un keypair P256 en el primer arranque.
//! 2. El agente envía su clave pública al servidor con el token de un solo uso.
//! 3. El servidor cifra la config TOML con `ecies_encrypt` y la devuelve.
//! 4. El agente descifra con `ecies_decrypt` usando su clave privada.
use aes_gcm::{
Aes256Gcm, Nonce,
aead::{Aead, KeyInit},
};
use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64};
use hmac::{Hmac, Mac};
use p256::{
PublicKey, SecretKey,
ecdh::EphemeralSecret,
elliptic_curve::sec1::ToEncodedPoint,
pkcs8::{DecodePrivateKey, DecodePublicKey, EncodePrivateKey, EncodePublicKey, LineEnding},
};
use rand::RngCore;
use sha2::Sha256;
type HmacSha256 = Hmac<Sha256>;
const HKDF_INFO: &[u8] = b"OmniOil-Agent-Provisioning-v1";
// ─── Keypair ─────────────────────────────────────────────────────────────────
/// Genera un keypair P256. Devuelve `(private_key_pem, public_key_pem)`.
/// La clave privada debe almacenarse solo en el dispositivo del agente.
pub fn generate_keypair(
) -> Result<(String, String), Box<dyn std::error::Error + Send + Sync>> {
let secret = SecretKey::random(&mut rand::thread_rng());
let public = secret.public_key();
let secret_pem = secret
.to_pkcs8_pem(LineEnding::LF)
.map_err(|e| format!("Error serializando clave privada: {}", e))?;
let public_pem = public
.to_public_key_pem(LineEnding::LF)
.map_err(|e| format!("Error serializando clave pública: {}", e))?;
Ok((secret_pem.to_string(), public_pem))
}
// ─── ECIES Encrypt (server-side) ─────────────────────────────────────────────
/// Cifra `plaintext` con la clave pública P256 del destinatario.
///
/// Formato del payload Base64:
/// `[ephemeral_pubkey:65][nonce:12][ciphertext:N+16]`
pub fn ecies_encrypt(
recipient_public_key_pem: &str,
plaintext: &[u8],
) -> Result<String, Box<dyn std::error::Error + Send + Sync>> {
let recipient_pub = PublicKey::from_public_key_pem(recipient_public_key_pem)
.map_err(|e| format!("Clave pública inválida: {}", e))?;
// Generar keypair efímero para este mensaje
let ephemeral_secret = EphemeralSecret::random(&mut rand::thread_rng());
let ephemeral_pub = PublicKey::from(&ephemeral_secret);
// ECDH → secreto compartido
let shared = ephemeral_secret.diffie_hellman(&recipient_pub);
// HKDF-SHA256: Extract + Expand → clave AES-256
let aes_key = hkdf_sha256(shared.raw_secret_bytes(), HKDF_INFO);
// AES-256-GCM cifrado
let cipher = Aes256Gcm::new_from_slice(&aes_key)
.map_err(|e| format!("Error inicializando cifrado: {}", e))?;
let mut nonce_bytes = [0u8; 12];
rand::thread_rng().fill_bytes(&mut nonce_bytes);
let nonce = Nonce::from_slice(&nonce_bytes);
let ciphertext = cipher
.encrypt(nonce, plaintext)
.map_err(|_| "Error en AES-GCM encrypt")?;
// Empaquetar: clave pública efímera (sin comprimir, 65 bytes) + nonce + ciphertext
let ephemeral_pub_bytes = ephemeral_pub.to_encoded_point(false);
let ephemeral_pub_bytes = ephemeral_pub_bytes.as_bytes();
let mut payload = Vec::with_capacity(65 + 12 + ciphertext.len());
payload.extend_from_slice(ephemeral_pub_bytes);
payload.extend_from_slice(&nonce_bytes);
payload.extend_from_slice(&ciphertext);
Ok(BASE64.encode(&payload))
}
// ─── ECIES Decrypt (agent-side) ──────────────────────────────────────────────
/// Descifra un payload producido por `ecies_encrypt` usando la clave privada del agente.
pub fn ecies_decrypt(
private_key_pem: &str,
encrypted_b64: &str,
) -> Result<Vec<u8>, Box<dyn std::error::Error + Send + Sync>> {
let private_key = SecretKey::from_pkcs8_pem(private_key_pem)
.map_err(|e| format!("Clave privada inválida: {}", e))?;
let payload = BASE64
.decode(encrypted_b64)
.map_err(|e| format!("Error decodificando base64: {}", e))?;
// Mínimo: 65 (pubkey) + 12 (nonce) + 16 (tag GCM) = 93 bytes
if payload.len() < 93 {
return Err("Payload ECIES demasiado corto".into());
}
let ephemeral_pub_bytes = &payload[..65];
let nonce_bytes = &payload[65..77];
let ciphertext = &payload[77..];
// Recuperar clave pública efímera del servidor
let ephemeral_pub = PublicKey::from_sec1_bytes(ephemeral_pub_bytes)
.map_err(|e| format!("Clave efímera inválida: {}", e))?;
// ECDH: private_key × ephemeral_pub → mismo secreto compartido
let shared = p256::ecdh::diffie_hellman(
private_key.to_nonzero_scalar(),
ephemeral_pub.as_affine(),
);
// Derivar misma clave AES
let aes_key = hkdf_sha256(shared.raw_secret_bytes(), HKDF_INFO);
// Descifrar
let cipher = Aes256Gcm::new_from_slice(&aes_key)
.map_err(|e| format!("Error inicializando cifrado: {}", e))?;
let nonce = Nonce::from_slice(nonce_bytes);
let plaintext = cipher
.decrypt(nonce, ciphertext)
.map_err(|_| "Error en AES-GCM decrypt — datos corruptos o clave incorrecta")?;
Ok(plaintext)
}
// ─── HKDF-SHA256 ─────────────────────────────────────────────────────────────
/// HKDF-SHA256 simplificado (RFC 5869): Extract (salt vacía) + Expand (1 bloque).
/// Produce 32 bytes de clave derivada.
fn hkdf_sha256(ikm: &[u8], info: &[u8]) -> [u8; 32] {
// Extract: PRK = HMAC-SHA256(salt="", IKM)
let mut mac = <HmacSha256 as KeyInit>::new_from_slice(b"")
.expect("HMAC acepta cualquier longitud de clave");
mac.update(ikm);
let prk = mac.finalize().into_bytes();
// Expand: T(1) = HMAC-SHA256(PRK, info || 0x01)
let mut mac = <HmacSha256 as KeyInit>::new_from_slice(&prk)
.expect("PRK es siempre 32 bytes");
mac.update(info);
mac.update(&[0x01u8]);
mac.finalize().into_bytes().into()
}
// ─── Tests ───────────────────────────────────────────────────────────────────
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_ecies_roundtrip() {
let (private_pem, public_pem) = generate_keypair().expect("generate_keypair");
let plaintext = b"config TOML de prueba para agente de campo";
let encrypted = ecies_encrypt(&public_pem, plaintext).expect("ecies_encrypt");
let decrypted = ecies_decrypt(&private_pem, &encrypted).expect("ecies_decrypt");
assert_eq!(decrypted, plaintext);
}
#[test]
fn test_ecies_wrong_key_fails() {
let (_, public_pem) = generate_keypair().expect("generate_keypair");
let (wrong_private_pem, _) = generate_keypair().expect("generate_keypair 2");
let plaintext = b"datos secretos";
let encrypted = ecies_encrypt(&public_pem, plaintext).expect("encrypt");
assert!(
ecies_decrypt(&wrong_private_pem, &encrypted).is_err(),
"debe fallar con clave incorrecta"
);
}
#[test]
fn test_ecies_tampered_payload_fails() {
let (private_pem, public_pem) = generate_keypair().expect("generate_keypair");
let encrypted_b64 = ecies_encrypt(&public_pem, b"datos").expect("encrypt");
let mut payload = BASE64.decode(&encrypted_b64).unwrap();
// Corromper el último byte del ciphertext
*payload.last_mut().unwrap() ^= 0xFF;
let tampered = BASE64.encode(&payload);
assert!(
ecies_decrypt(&private_pem, &tampered).is_err(),
"debe detectar manipulación"
);
}
}

View File

@@ -163,19 +163,29 @@ async fn process_message(pool: &sqlx::PgPool, topic: &str, payload: &[u8]) -> an
"telemetry" => { "telemetry" => {
let payload_json: serde_json::Value = serde_json::from_slice(&decompressed_payload)?; let payload_json: serde_json::Value = serde_json::from_slice(&decompressed_payload)?;
// 1. Resolver Asset ID (Prioridad: ID explícito > Búsqueda por Nombre) // 1. Resolver Asset ID (Prioridad: ID explícito > Búsqueda por Código > Búsqueda por Nombre de Dispositivo)
let mut asset_id = payload_json.get("asset_id") let mut asset_id = payload_json.get("asset_id")
.and_then(|id| id.as_str()) .and_then(|id| id.as_str())
.and_then(|id| Uuid::parse_str(id).ok()); .and_then(|id| Uuid::parse_str(id).ok());
if asset_id.is_none() { if asset_id.is_none() {
// Opción A: Buscar por código de activo (asset field)
if let Some(code) = payload_json.get("asset").and_then(|p| p.as_str()) { if let Some(code) = payload_json.get("asset").and_then(|p| p.as_str()) {
let res: Option<(Uuid,)> = sqlx::query_as("SELECT id FROM edge_assets WHERE name = $1 LIMIT 1") let res: Option<(Uuid,)> = sqlx::query_as("SELECT id FROM edge_assets WHERE code = $1 OR name = $1 LIMIT 1")
.bind(code).fetch_optional(pool).await?; .bind(code).fetch_optional(pool).await?;
if let Some((id,)) = res { asset_id = Some(id); } if let Some((id,)) = res { asset_id = Some(id); }
} }
} }
if asset_id.is_none() {
// Opción B: Buscar por nombre de dispositivo (dispositivos están asociados a un activo)
if let Some(device_name) = payload_json.get("device_name").and_then(|p| p.as_str()) {
let res: Option<(Uuid,)> = sqlx::query_as("SELECT asset_id FROM edge_devices WHERE name = $1 LIMIT 1")
.bind(device_name).fetch_optional(pool).await?;
if let Some((id,)) = res { asset_id = Some(id); }
}
}
// 3. Extraer timestamp // 3. Extraer timestamp
let ts = payload_json.get("timestamp") let ts = payload_json.get("timestamp")
.and_then(|t| t.as_str()) .and_then(|t| t.as_str())

View File

@@ -0,0 +1,125 @@
import asyncio
import logging
import random
import snap7
import time
from pymodbus.server import StartAsyncTcpServer
from pymodbus.datastore import (
ModbusSequentialDataBlock,
ModbusDeviceContext,
ModbusServerContext
)
# Configuración de Logging con estética premium
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s | %(levelname)s | %(message)s',
datefmt='%Y-%m-%d %H:%M:%S'
)
log = logging.getLogger("OmniOilSimulator")
# ---------------------------------------------------------
# MODBUS CONFIGURATION
# ---------------------------------------------------------
async def update_modbus_values(context):
"""Actualiza valores Modbus con rangos amplios."""
log.info("📊 [MODBUS] Iniciando generador de datos aleatorios...")
slave_id = 0x00
while True:
await asyncio.sleep(2)
try:
# En pymodbus 3.x, async_setValues requiere: (slave_id, function_code, address, values)
# HR (Holding Registers) - FC 3
new_hr_values = [random.randint(500, 9500) for _ in range(5)]
await context.async_setValues(slave_id, 3, 1, new_hr_values)
# IR (Input Registers) - FC 4
new_ir_values = [random.randint(1000, 15000) for _ in range(3)]
await context.async_setValues(slave_id, 4, 1, new_ir_values)
log.info(f"MODBUS Update: HR={new_hr_values}, IR={new_ir_values}")
except Exception as e:
log.error(f"MODBUS Error: {e}")
async def run_modbus_server():
# Bloques de datos iniciales
hr_block = ModbusSequentialDataBlock(1, [0]*100)
ir_block = ModbusSequentialDataBlock(1, [0]*100)
store = ModbusDeviceContext(hr=hr_block, ir=ir_block)
context = ModbusServerContext(store, single=True)
log.info("🚀 [MODBUS] Escuchando en 0.0.0.0:5020")
# Tarea de actualización
asyncio.create_task(update_modbus_values(context))
await StartAsyncTcpServer(
context,
address=("0.0.0.0", 5020)
)
# ---------------------------------------------------------
# S7 CONFIGURATION
# ---------------------------------------------------------
async def update_s7_values(db1_data):
"""Actualiza valores S7 con rangos amplios."""
log.info("📊 [S7] Iniciando generador de datos aleatorios...")
while True:
await asyncio.sleep(2)
try:
# Generar valores aleatorios amplios
val1 = random.randint(100, 30000)
val2 = random.randint(50, 20000)
# DB1.DBW0 y DB1.DBW2 (Integers de 16 bits en Big Endian)
db1_data[0:2] = val1.to_bytes(2, 'big')
db1_data[2:4] = val2.to_bytes(2, 'big')
log.info(f"S7 Update: DB1.DBW0={val1}, DB1.DBW2={val2}")
except Exception as e:
log.error(f"S7 Error: {e}")
async def run_s7_server():
server = snap7.server.Server()
# DB 1 con 1024 bytes
size = 1024
db1_data = bytearray(size)
# Registrar área DB1
server.register_area(snap7.type.SrvArea.DB, 1, db1_data)
log.info("🚀 [S7] Escuchando en 0.0.0.0:1102 (Rack 0, Slot 1)")
server.start(tcp_port=1102)
# Tarea de actualización
asyncio.create_task(update_s7_values(db1_data))
# Mantener el loop vivo para S7 (snap7 server corre en su propio thread C)
while True:
await asyncio.sleep(10)
# ---------------------------------------------------------
# MAIN ORCHESTRATOR
# ---------------------------------------------------------
async def main():
log.info("✨ Iniciando Simulador Multiprotocolo OmniOil (Modbus + S7) ✨")
# Ejecutar ambos servidores concurrentemente
try:
await asyncio.gather(
run_modbus_server(),
run_s7_server()
)
except Exception as e:
log.error(f"Error en el orquestador: {e}")
if __name__ == "__main__":
try:
asyncio.run(main())
except KeyboardInterrupt:
log.info("👋 Simuladores detenidos por el usuario.")
except Exception as e:
log.error(f"💥 Error fatal: {e}")