Compare commits
3 Commits
2f2b8f3538
...
5067b5730f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5067b5730f | ||
|
|
821d93b840 | ||
|
|
2f5f9af3c8 |
6
.gitignore
vendored
6
.gitignore
vendored
@@ -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
134
Cargo.lock
generated
@@ -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
527
SECURITY_AUDIT_REPORT.md
Normal 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
BIN
SECURITY_AUDIT_REPORT.pdf
Normal file
Binary file not shown.
@@ -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"
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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>(
|
||||||
|
|||||||
@@ -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'")
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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>,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
238
services/backend-api/src/handlers/provisioning.rs
Normal file
238
services/backend-api/src/handlers/provisioning.rs
Normal 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
|
||||||
|
})))
|
||||||
|
}
|
||||||
@@ -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()))?;
|
||||||
|
|||||||
@@ -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: {:?}",
|
||||||
|
|||||||
66
services/backend-api/src/rate_limiter.rs
Normal file
66
services/backend-api/src/rate_limiter.rs
Normal 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."
|
||||||
|
})),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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, ¬ifier).await {
|
if let Err(e) = check_telemetry_silence(&pool, ¬ifier).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, ¬ifier).await {
|
if let Err(e) = check_unhealthy_agents(&pool, ¬ifier).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(())
|
||||||
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
@@ -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",
|
||||||
|
] }
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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))
|
||||||
}
|
}
|
||||||
|
|||||||
153
services/edge-agent/src/credential_store.rs
Normal file
153
services/edge-agent/src/credential_store.rs
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
71
services/edge-agent/src/log_cleaner.rs
Normal file
71
services/edge-agent/src/log_cleaner.rs
Normal 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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
|||||||
@@ -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(_) => {}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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 => {
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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(¤t_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(¤t_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"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
208
services/shared-lib/src/provisioning.rs
Normal file
208
services/shared-lib/src/provisioning.rs
Normal 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"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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())
|
||||||
|
|||||||
125
tests/simulators/multi_protocol_sim.py
Normal file
125
tests/simulators/multi_protocol_sim.py
Normal 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}")
|
||||||
Reference in New Issue
Block a user