104 Commits

Author SHA1 Message Date
henryor
acbf3cc56a chore(repo): ignore local generated artifacts 2026-06-25 15:29:52 -05:00
Wilman Yesid Farfan Diaz
e2c97bce74 feat: implement session tracking to display active users count in dashboard 2026-06-23 09:39:42 -05:00
Wilman Yesid Farfan Diaz
8bb323b2aa refactor: replace individual simulators with a unified multiprotocol simulator and add central documentation. 2026-06-23 09:33:58 -05:00
henryor
9ab9579cad feat(admin): filter agent logs by well 2026-06-22 22:08:37 -05:00
henryor
82777aa132 fix(admin): resolve telemetry wells without active project 2026-06-22 20:11:10 -05:00
henryor
45e945f82b fix(admin): keep telemetry selector above cards 2026-06-22 17:00:10 -05:00
henryor
5d190f00d2 feat(admin): filter dashboard telemetry by well 2026-06-22 10:41:27 -05:00
henryor
64914f98cd fix(admin): normalize well asset aliases 2026-06-22 10:04:10 -05:00
henryor
967e306e44 fix(admin): simplify well card actions 2026-06-22 09:44:15 -05:00
henryor
4b9fc196e5 fix(anh): use Colombia timezone in report JSON 2026-06-22 08:06:22 -05:00
henryor
f679bceca1 feat(admin): improve Edge agent log viewer 2026-06-22 06:25:36 -05:00
henryor
9070b5cacd feat(admin): improve Edge project detail UX 2026-06-22 06:25:36 -05:00
Wilman Yesid Farfan Diaz
a3401cfd95 feat: implement Telegram notifications and add rate-limited system error alerts to watchdog checks 2026-06-22 02:11:07 -05:00
henryor
9cccb8ba0e fix(security): replace telemetry websocket JWT query 2026-06-20 20:19:45 -05:00
henryor
28301e68fa feat(anh): split daily generation and regeneration 2026-06-20 19:47:42 -05:00
henryor
1511f13970 fix(admin): avoid duplicate input focus outline 2026-06-20 19:14:41 -05:00
henryor
139dbaa9a9 feat(admin): clarify Edge project status 2026-06-20 18:58:03 -05:00
henryor
0e66f4a04c feat(admin): show ANH report creation time 2026-06-20 18:46:42 -05:00
henryor
1448f6f530 fix(admin): gate layout alarm fetches 2026-06-20 18:28:06 -05:00
henryor
9bdb804dfd fix(api): limit offline agent noise 2026-06-20 17:56:51 -05:00
henryor
b923e75103 fix(admin): avoid global alert fetches 2026-06-20 17:46:01 -05:00
henryor
a02a10556e fix(api): scope alarms to tenant access 2026-06-20 17:36:32 -05:00
henryor
5c2ddac702 fix(anh): stabilize scheduled report generation 2026-06-20 09:11:13 -05:00
Wilman Yesid Farfan Diaz
55f189f000 chore: update INVITATION_BASE_URL, realign backend ports, and remove redundant VITE_API_URL build arguments 2026-06-19 15:59:11 -05:00
Wilman Yesid Farfan Diaz
5ebd31b049 fix(dev): port mappings to 3030 and 8080 to avoid conflicts 2026-06-19 14:33:48 -05:00
Wilman Yesid Farfan Diaz
bc021bd80f fix(seed): insert telemetry with associated asset_id 2026-06-19 14:10:44 -05:00
Wilman Yesid Farfan Diaz
7f717dd84d feat(telemetry): normalize variables in dashboard and fix ingestor thresholds 2026-06-19 14:01:56 -05:00
Wilman Yesid Farfan Diaz
7cbae7d3cc refactor: optimize Docker build caching and update error logging in credential store 2026-06-19 11:20:04 -05:00
henryor
00d130d776 fix(landing): update operational plan pricing 2026-06-19 11:08:35 -05:00
henryor
60764cce50 fix(admin): route user QR to PWA movements 2026-06-19 11:04:33 -05:00
henryor
0e6ed3b5db fix(admin): restrict customer user roles 2026-06-19 09:14:29 -05:00
henryor
1617c89f8f fix(api): support alarm acknowledgement migration on hypertables 2026-06-19 09:14:21 -05:00
Wilman Yesid Farfan Diaz
875a5a1106 feat: consolidate frontend services into a unified pnpm workspace and build pipeline with optimized Docker configuration 2026-06-19 08:47:21 -05:00
henryor
b077adf0f8 fix(billing): avoid compile-time database checks 2026-06-19 08:16:17 -05:00
henryor
ea1cd59f33 feat(admin): acknowledge grouped alerts 2026-06-19 08:16:07 -05:00
henryor
161d330801 fix(admin): handle nullable alerts 2026-06-19 06:09:22 -05:00
Wilman Yesid Farfan Diaz
9635ec379b refactor: consolidate Docker build stages and optimize cross-compilation pipeline for Windows/Linux targets 2026-06-19 01:38:01 -05:00
Wilman Yesid Farfan Diaz
739ee9d413 chore: add build script and config file to docker context for edge-agent 2026-06-19 00:35:27 -05:00
Wilman Yesid Farfan Diaz
43307fea12 chore: enable locked sharing for Docker cargo registry and git cache mounts 2026-06-19 00:17:48 -05:00
Wilman Yesid Farfan Diaz
3046823bb4 feat: implement automatic Linux binary upload to MinIO in build-orchestrator and optimize Docker build caching and ignore patterns. 2026-06-19 00:14:32 -05:00
Wilman Yesid Farfan Diaz
f6ea571613 perf: add target cache mounts to Docker build stages and optimize dockerignore build context filtering 2026-06-18 23:42:34 -05:00
henryor
11f29f9248 chore(rust): normalize workspace formatting 2026-06-18 23:03:50 -05:00
henryor
b2eb75d824 fix(admin): clean frontend typecheck debt 2026-06-18 23:03:49 -05:00
henryor
bb27ad14aa fix(admin): remove demo audit page 2026-06-18 23:03:49 -05:00
Wilman Yesid Farfan Diaz
88fe56b11e CAmbio de Dockerfiles para mejorar velocidad. 2026-06-18 22:27:06 -05:00
henryor
8f1fd2d098 docs(anh): document correction disposition flow 2026-06-18 21:26:09 -05:00
henryor
192b22b247 feat(admin): add anh correction review flow 2026-06-18 21:26:09 -05:00
henryor
0bae416111 fix(anh): protect scheduled report versions 2026-06-18 21:26:09 -05:00
henryor
42cda1ce70 feat(anh): add report correction workflow 2026-06-18 21:26:08 -05:00
henryor
d709629e66 feat(admin): improve anh report review ui 2026-06-18 06:11:14 -05:00
henryor
b799cb3109 fix(anh): exclude unconfirmed report scopes 2026-06-18 06:11:14 -05:00
0f6305e404 Merge pull request 'OptimizacionesDocker + permisos en local carpeta mapeadas' (#4) from OptimizacionesDocker into main
Reviewed-on: #4
2026-06-17 16:34:53 +00:00
Wilman Yesid Farfan Diaz
1571d2fa69 chore: update entrypoint and build scripts to use environment variables for configuration management 2026-06-17 11:32:36 -05:00
henryor
0179e593e1 fix(admin): hide API docs navigation 2026-06-17 09:13:50 -05:00
Wilman Yesid Farfan Diaz
3013b8ca66 feat: optimize Dockerfile by introducing a runtime base image for all services to reduce redundancy in package installations 2026-06-17 09:00:58 -05:00
henryor
8d2b5ef72a feat(admin): improve ANH reports dashboard 2026-06-17 08:52:34 -05:00
Wilman Yesid Farfan Diaz
09c5451876 chore: optimize Docker build caching by adding per-service Dockerfiles, normalizing build contexts, and documenting optimization strategies. 2026-06-17 08:13:16 -05:00
Wilman Yesid Farfan Diaz
7bb5cd658a feat: implement cargo-chef multi-stage builds for edge-agent and update Docker ignore patterns 2026-06-17 08:05:17 -05:00
Wilman Yesid Farfan Diaz
87a4d0427e feat: add WiX installer configuration and Linux build script for Windows MSI generation 2026-06-16 22:48:47 -05:00
Wilman Yesid Farfan Diaz
99e8dec729 feat: implement json-generator service and edge-agent, and optimize Docker unified build process 2026-06-16 21:41:50 -05:00
Wilman Yesid Farfan Diaz
d3ec5f8645 feat: implement multi-stage Dockerfiles for individual services using cargo-chef and update unified build configuration 2026-06-16 20:57:47 -05:00
henryor
a166e8ea0e feat(anh): auto provision schedules for confirmed contracts 2026-06-16 20:29:29 -05:00
henryor
9953a49eb3 fix(access): restore invitation resend test helpers 2026-06-16 20:29:28 -05:00
Wilman Yesid Farfan Diaz
f2ed516748 feat: add multi-stage Dockerfile for cross-compiling and MSI generation of edge-agent 2026-06-16 20:24:56 -05:00
Wilman Yesid Farfan Diaz
b1d60a5f9a feat: implement multi-stage Docker builder for Windows/Linux and add provisioning configuration loader 2026-06-16 20:22:41 -05:00
Wilman Yesid Farfan Diaz
99ed6a88a1 feat: implement multi-stage Docker builder for cross-compilation of edge-agent binaries and MSI generation 2026-06-16 19:30:34 -05:00
Wilman Yesid Farfan Diaz
257976167a feat: add multi-stage Dockerfile for cross-compiling and building edge-agent MSI installer 2026-06-16 19:04:55 -05:00
Wilman Yesid Farfan Diaz
74e7832aef feat: implement core edge-agent services, backend handlers for protocol imports and reports, and frontend admin modules 2026-06-16 18:35:11 -05:00
Wilman Yesid Farfan Diaz
80b0457396 feat: include json-generator service in the unified Docker build process 2026-06-16 17:24:36 -05:00
Wilman Yesid Farfan Diaz
073d80dc79 feat: implement optimized multi-stage MSI builder for edge-agent and simplify unified build pipeline 2026-06-16 13:45:22 -05:00
Wilman Yesid Farfan Diaz
cc5b308dfb feat: add multi-stage Docker builder for unified workspace services and Windows edge-agent cross-compilation 2026-06-16 13:44:40 -05:00
Wilman Yesid Farfan Diaz
8ce5904cf7 build: configure cross-compilation environment for Windows with static linking and OPC-UA support 2026-06-16 12:50:19 -05:00
henryor
5bce7cb5fc fix(anh): clarify report approval messaging 2026-06-16 10:48:26 -05:00
henryor
22962fdab8 feat(admin): configure anh report schedule 2026-06-16 10:48:25 -05:00
henryor
41c4a2cdfb feat(anh): run scheduled reports from database 2026-06-16 10:48:25 -05:00
henryor
a6fc71bcc9 feat(anh): add report schedule configuration 2026-06-16 10:48:25 -05:00
Wilman Yesid Farfan Diaz
4a7aa94cb9 feat: implement asymmetric provisioning flow for edge agent and add installer worker for orchestration 2026-06-16 10:11:38 -05:00
henryor
f95575a832 fix(admin): download anh reports with auth 2026-06-16 08:17:19 -05:00
henryor
12adbc2e8c test(anh): harden sealed download coverage 2026-06-16 07:37:20 -05:00
henryor
b3fe40637d feat(anh): seal generated report downloads 2026-06-16 07:22:54 -05:00
henryor
fceb8fe1a5 feat(anh): add sealed report metadata foundation 2026-06-16 05:39:12 -05:00
henryor
e1c10f58f8 fix(admin): repair settings profile security 2026-06-15 21:22:12 -05:00
henryor
1d1d32c4d4 fix(admin): remove inactive header search 2026-06-15 20:48:27 -05:00
henryor
a4627c2b4c fix(admin): scope user management by project 2026-06-15 20:32:39 -05:00
henryor
a92b731110 fix(console): show trial and invitation expiry 2026-06-15 20:32:39 -05:00
Wilman Yesid Farfan Diaz
4801101d4f feat: add agent download token management and implement edge project CRUD handlers 2026-06-15 20:07:40 -05:00
henryor
1b739e2b14 feat(landing): add trial pricing option 2026-06-15 08:51:20 -05:00
henryor
95b9d76f3f feat(auth): brand trial activation email 2026-06-15 08:22:51 -05:00
henryor
7ca59a43cc fix(auth): dedupe access requests before unique index 2026-06-15 07:00:45 -05:00
henryor
0093839b5e feat(auth): automate trial access approval 2026-06-14 22:19:19 -05:00
Wilman Yesid Farfan Diaz
be7f0a8da4 feat: implement modular multi-protocol data collector agent with support for Modbus, S7, OPC-UA, MQTT Sparkplug B, and SQL drivers. 2026-06-14 19:37:42 -05:00
henryor
44acb87203 feat(landing): mejora privacidad SEO y accesibilidad
Alinea OmniOil como servicio operado por Kyrbot Innovation S.A.S., agrega la política de privacidad y refuerza la landing con SEO, accesibilidad, reduced motion y pruebas runtime.
2026-06-14 15:03:50 -05:00
henryor
e9e8d85092 fix(landing): ajusta textos técnicos de arquitectura 2026-06-13 20:24:53 -05:00
henryor
930461e7ce feat(landing): simplifica precios por tags 2026-06-13 20:19:55 -05:00
henryor
de661f42e5 fix(landing): ajusta texto de alertas por correo 2026-06-13 20:09:03 -05:00
henryor
983c800583 fix(landing): retira botón de agendar demo del hero 2026-06-13 19:54:24 -05:00
Wilman Yesid Farfan Diaz
92ec74dd92 feat: add terms and conditions page with multi-language support 2026-06-13 19:15:52 -05:00
Wilman Yesid Farfan Diaz
abb95e1501 feat: implement ANH report management handlers and include json-generator in Docker build 2026-06-13 16:49:24 -05:00
Wilman Yesid Farfan Diaz
aa7cbbce7d Generaci[on del json mejorado 2026-06-13 13:35:51 -05:00
henryor
910c2ab964 refactor(admin): migrate user form modal to UI kit 2026-05-15 21:44:35 -05:00
henryor
0beebb04c0 refactor(admin): migrate users page to UI kit 2026-05-15 20:11:59 -05:00
henryor
44d3b2cf26 feat(admin): add admin UI compositions 2026-05-15 20:11:59 -05:00
henryor
21bb2bbea8 feat(admin): add reusable UI primitives 2026-05-15 20:11:58 -05:00
henryor
bec2580e2f retiro imagenes antiguas 2026-05-15 10:03:24 -05:00
252 changed files with 31785 additions and 37344 deletions

View File

@@ -1,30 +1,55 @@
# Rust build artifacts # === Ignorar casi todo por defecto ===
target/ **
services/**/target/
# Node.js # === Archivos y carpetas que SÍ queremos incluir ===
node_modules/ !Dockerfile*
**/node_modules/ !docker-compose.yml
# Tests & Data
tests/
.docker/
infrastructure/postgres/data/
# Git
.git/
.gitignore
# Environment
.env
.env.*
!.env.example !.env.example
!Cargo.toml
!Cargo.lock
!package.json
!pnpm-lock.yaml
!pnpm-workspace.yaml
!.sqlx/
# Servicios Rust esenciales
!services/shared-lib/
!services/backend-api/
!services/data-collector/
!services/json-generator/
!services/events-relay/
!services/build-orchestrator/
!services/telemetry-ingestor/
!services/edge-agent/Cargo.toml
!services/edge-agent/src/
!services/edge-agent/Cargo.lock
!services/edge-agent/build.rs
!services/edge-agent/embedded_config.toml
!services/edge-agent/Dockerfile.builder
!services/edge-agent/wix/
!services/edge-agent/*.ico
!services/edge-agent/*.sh
!services/edge-agent/msi_worker.sh
# IDE !infrastructure/
.vscode/
.idea/
# Docs & Meta # Excepciones dentro de frontend (Node.js)
docs/ !services/landing/
LICENSE !services/frontend-pwa/
!README.md !services/frontend-admin/
!services/frontend-console/
# Ignorar dentro de las carpetas permitidas
**/target
**/node_modules
**/.git
**/.github
**/.vscode
**/.idea
**/*.log
**/.DS_Store
.env*
**/__pycache__
**/dist
**/build
*.tar
*.tar.gz

View File

@@ -2,6 +2,7 @@
# OMNIOIL SCADA - CONFIGURACIÓN PARA AMBIENTE LOCAL (DOCUMENTADA) # OMNIOIL SCADA - CONFIGURACIÓN PARA AMBIENTE LOCAL (DOCUMENTADA)
# ============================================================================= # =============================================================================
AGENT_LATEST_VERSION=0.3.1
# --- ORQUESTACIÓN DE COMPOSE --- # --- ORQUESTACIÓN DE COMPOSE ---
COMPOSE_PATH_SEPARATOR=; # Separador de archivos compose para Windows COMPOSE_PATH_SEPARATOR=; # Separador de archivos compose para Windows
COMPOSE_FILE=docker-compose.yml;docker-compose.dev.yml # Archivos compose activos por defecto COMPOSE_FILE=docker-compose.yml;docker-compose.dev.yml # Archivos compose activos por defecto

8
.gitignore vendored
View File

@@ -47,3 +47,11 @@ CLAUDE.md
__pycache__ __pycache__
build_test/ build_test/
venv venv
# Local generated artifacts
.venv/
**/.venv/
/services/edge-agent/logs/
/services/edge-agent/edge-agent-revision-local*.exe
/tests/*.log
/tests/simulators/variables_import.csv

1389
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -20,6 +20,7 @@ tokio = { version = "1.50.0", features = ["full"] }
serde = { version = "1.0.228", features = ["derive"] } serde = { version = "1.0.228", features = ["derive"] }
serde_json = "1.0.149" serde_json = "1.0.149"
chrono = { version = "0.4.44", features = ["serde"] } chrono = { version = "0.4.44", features = ["serde"] }
chrono-tz = "0.10.4"
uuid = { version = "1.23.0", features = ["v4", "serde"] } uuid = { version = "1.23.0", features = ["v4", "serde"] }
anyhow = "1.0.102" anyhow = "1.0.102"
tracing = "0.1.44" tracing = "0.1.44"
@@ -63,3 +64,14 @@ lto = "thin"
codegen-units = 1 codegen-units = 1
# Abort on panic — removes unwinding tables (~5-10% smaller binary). # Abort on panic — removes unwinding tables (~5-10% smaller binary).
panic = "abort" panic = "abort"
# ── CI/Docker profile: fast builds, still production-grade ─────────────────────
# Use with `cargo build --profile ci`. Output goes to target/ci/.
# Skips LTO (~3-5 min saved) and uses parallel codegen units.
# Binaries are ~5-10% larger but functionally identical.
[profile.ci]
inherits = "release"
lto = false
strip = true
codegen-units = 8

111
Dockerfile.frontend Normal file
View File

@@ -0,0 +1,111 @@
# syntax=docker/dockerfile:1
# =============================================================================
# OmniOil Workspace - Unified Frontend Build Pipeline
# =============================================================================
# --- Stage 1: Install workspace dependencies ---
FROM node:22-alpine AS deps
RUN corepack enable && corepack prepare pnpm@9.0.0 --activate
WORKDIR /app
# Copy workspace configurations and manifests
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
COPY services/landing/package.json ./services/landing/
COPY services/frontend-admin/package.json ./services/frontend-admin/
COPY services/frontend-console/package.json ./services/frontend-console/
COPY services/frontend-pwa/package.json ./services/frontend-pwa/
# Run installation utilizing pnpm cache mount
RUN --mount=type=cache,id=pnpm,target=/pnpm/store \
pnpm install --frozen-lockfile --store-dir /pnpm/store
# --- Stage 2: Base builder ---
FROM deps AS builder
# Copy the rest of the source code
COPY . .
# --- Stage 3: Landing build ---
FROM builder AS builder-landing
ARG NEXT_PUBLIC_TURNSTILE_SITE_KEY
ENV NEXT_TELEMETRY_DISABLED=1
ENV NEXT_PUBLIC_TURNSTILE_SITE_KEY=$NEXT_PUBLIC_TURNSTILE_SITE_KEY
RUN pnpm --filter landing build
# --- Stage 4: Admin build ---
FROM builder AS builder-admin
ARG VITE_API_BASE
ARG VITE_ADMIN_APP_ORIGIN
ARG VITE_MOBILE_APP_ORIGIN
ARG VITE_API_URL
ARG VITE_TURNSTILE_SITE_KEY
ARG VITE_LANDING_APP_ORIGIN
ENV VITE_API_BASE=$VITE_API_BASE
ENV VITE_ADMIN_APP_ORIGIN=$VITE_ADMIN_APP_ORIGIN
ENV VITE_MOBILE_APP_ORIGIN=$VITE_MOBILE_APP_ORIGIN
ENV VITE_API_URL=$VITE_API_URL
ENV VITE_TURNSTILE_SITE_KEY=$VITE_TURNSTILE_SITE_KEY
ENV VITE_LANDING_APP_ORIGIN=$VITE_LANDING_APP_ORIGIN
RUN pnpm --filter frontend-admin build
# --- Stage 5: Console build ---
FROM builder AS builder-console
ARG VITE_API_BASE
ARG VITE_CONSOLE_APP_ORIGIN
ARG VITE_ACCESS_REQUEST_REVIEWER_EMAILS
ENV VITE_API_BASE=$VITE_API_BASE
ENV VITE_CONSOLE_APP_ORIGIN=$VITE_CONSOLE_APP_ORIGIN
ENV VITE_ACCESS_REQUEST_REVIEWER_EMAILS=$VITE_ACCESS_REQUEST_REVIEWER_EMAILS
RUN pnpm --filter frontend-console build
# --- Stage 6: PWA build ---
FROM builder AS builder-pwa
ARG VITE_API_BASE
ARG VITE_ADMIN_APP_ORIGIN
ARG VITE_MOBILE_APP_ORIGIN
ARG VITE_API_URL
ENV VITE_API_BASE=$VITE_API_BASE
ENV VITE_ADMIN_APP_ORIGIN=$VITE_ADMIN_APP_ORIGIN
ENV VITE_MOBILE_APP_ORIGIN=$VITE_MOBILE_APP_ORIGIN
ENV VITE_API_URL=$VITE_API_URL
RUN pnpm --filter frontend-pwa build
# =============================================================================
# RUNTIME IMAGES
# =============================================================================
# --- LANDING RUNTIME (Next.js Standalone Mode in Monorepo) ---
FROM node:22-alpine AS landing
WORKDIR /app
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
ENV HOSTNAME=0.0.0.0
ENV PORT=80
ENV LANDING_API_PROXY_TARGET=http://backend-api:8000
COPY --from=builder-landing /app/services/landing/public ./services/landing/public
COPY --from=builder-landing /app/services/landing/.next/standalone ./
COPY --from=builder-landing /app/services/landing/.next/static ./services/landing/.next/static
EXPOSE 80
CMD ["node", "services/landing/server.js"]
# --- ADMIN RUNTIME (Nginx SPA) ---
FROM nginx:alpine AS frontend-admin
COPY --from=builder-admin /app/services/frontend-admin/dist /usr/share/nginx/html
COPY services/frontend-admin/nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
# --- CONSOLE RUNTIME (Nginx SPA) ---
FROM nginx:alpine AS frontend-console
COPY --from=builder-console /app/services/frontend-console/dist /usr/share/nginx/html
COPY services/frontend-console/nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
# --- PWA RUNTIME (Nginx SPA) ---
FROM nginx:alpine AS frontend-pwa
COPY --from=builder-pwa /app/services/frontend-pwa/dist /usr/share/nginx/html
COPY services/frontend-pwa/nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

View File

@@ -7,94 +7,112 @@ FROM lukemathwalker/cargo-chef:latest-rust-1.93-slim-bookworm AS base
WORKDIR /app WORKDIR /app
RUN apt-get update && apt-get install -y \ RUN apt-get update && apt-get install -y \
cmake nasm pkg-config libssl-dev libglib2.0-dev libatk1.0-dev libgtk-3-dev build-essential ca-certificates lld \ pkg-config libssl-dev build-essential ca-certificates lld \
gcc-mingw-w64-x86-64 musl-tools \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
RUN rustup target add x86_64-pc-windows-gnu && \
rustup target add x86_64-unknown-linux-musl
# Variables de entorno para cross-compilation
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 CARGO_TARGET_X86_64_PC_WINDOWS_GNU_LINKER=x86_64-w64-mingw32-gcc
ENV OPENSSL_NO_ASM=1
ENV RUSTFLAGS="-C link-arg=-fuse-ld=lld" ENV RUSTFLAGS="-C link-arg=-fuse-ld=lld"
ENV CARGO_INCREMENTAL=0 ENV CARGO_INCREMENTAL=0
# 2. PLANNER: Solo analiza dependencias (cambia poco) # 2. PLANNER: Solo analiza dependencias (cambia poco)
# Copiamos solo los Cargo.toml y Cargo.lock para máxima reutilización de caché
FROM base AS planner FROM base AS planner
COPY Cargo.toml Cargo.lock ./ COPY Cargo.toml Cargo.lock ./
COPY services/ services/ COPY services/shared-lib/Cargo.toml services/shared-lib/Cargo.toml
COPY services/backend-api/Cargo.toml services/backend-api/Cargo.toml
COPY services/data-collector/Cargo.toml services/data-collector/Cargo.toml
COPY services/json-generator/Cargo.toml services/json-generator/Cargo.toml
COPY services/events-relay/Cargo.toml services/events-relay/Cargo.toml
COPY services/build-orchestrator/Cargo.toml services/build-orchestrator/Cargo.toml
COPY services/edge-agent/Cargo.toml services/edge-agent/Cargo.toml
COPY services/telemetry-ingestor/Cargo.toml services/telemetry-ingestor/Cargo.toml
# Creamos archivos stub para cada crate (evita "file not found" en cargo metadata)
RUN mkdir -p services/shared-lib/src && echo "// stub" > services/shared-lib/src/lib.rs && \
mkdir -p services/backend-api/src && echo "// stub" > services/backend-api/src/lib.rs && echo "fn main() {}" > services/backend-api/src/main.rs && \
mkdir -p services/data-collector/src && echo "fn main() {}" > services/data-collector/src/main.rs && \
mkdir -p services/json-generator/src && echo "// stub" > services/json-generator/src/lib.rs && echo "fn main() {}" > services/json-generator/src/main.rs && \
mkdir -p services/events-relay/src && echo "fn main() {}" > services/events-relay/src/main.rs && \
mkdir -p services/build-orchestrator/src && echo "fn main() {}" > services/build-orchestrator/src/main.rs && \
mkdir -p services/edge-agent/src/bin && echo "fn main() {}" > services/edge-agent/src/bin/edge-tray.rs && echo "fn main() {}" > services/edge-agent/src/main.rs && \
mkdir -p services/telemetry-ingestor/src && echo "fn main() {}" > services/telemetry-ingestor/src/main.rs
# Generamos recipe.json para TODAS las dependencias del workspace
# Esto permite que todas las dependencias comunes se cacheen juntas
RUN cargo chef prepare --recipe-path recipe.json RUN cargo chef prepare --recipe-path recipe.json
# 3. CACHER: Compila TODAS las dependencias del workspace UNA vez # 3. CACHER: Compila TODAS las dependencias del workspace UNA vez
FROM base AS cacher FROM base AS cacher
COPY --from=planner /app/recipe.json recipe.json COPY --from=planner /app/recipe.json recipe.json
RUN --mount=type=cache,target=/usr/local/cargo/registry \
--mount=type=cache,target=/usr/local/cargo/git \ # 3a. Cook default workspace dependencies (Linux GNU)
--mount=type=cache,target=/app/target \ RUN --mount=type=cache,sharing=locked,target=/usr/local/cargo/registry \
--mount=type=cache,sharing=locked,target=/usr/local/cargo/git \
cargo chef cook --release --recipe-path recipe.json cargo chef cook --release --recipe-path recipe.json
# 4. BUILDER-LINUX: Base para servicios Linux (rápida) # 3b. Cook edge-agent dependencies for Windows GNU (con RUSTFLAGS idénticos)
RUN --mount=type=cache,sharing=locked,target=/usr/local/cargo/registry \
--mount=type=cache,sharing=locked,target=/usr/local/cargo/git \
RUSTFLAGS="-C target-feature=+crt-static -C link-args=-static" \
cargo chef cook --release --target x86_64-pc-windows-gnu --features edge-agent/opcua-support --recipe-path recipe.json
# 3c. Cook edge-agent dependencies for Linux musl
RUN --mount=type=cache,sharing=locked,target=/usr/local/cargo/registry \
--mount=type=cache,sharing=locked,target=/usr/local/cargo/git \
cargo chef cook --release --target x86_64-unknown-linux-musl --features edge-agent/opcua-support --recipe-path recipe.json
# 4. BUILDER-LINUX: Base para servicios Linux (reutiliza caché de deps)
FROM base AS builder-linux FROM base AS builder-linux
COPY Cargo.toml Cargo.lock ./ COPY --from=cacher /app /app
# Crear estructura de carpetas y archivos dummy COPY --from=cacher /usr/local/cargo /usr/local/cargo
RUN mkdir -p services/backend-api/src && echo "fn main() {}" > services/backend-api/src/main.rs && \ # Ahora copiar el código real de shared-lib (debe estar DESPUÉS de usar cacher)
mkdir -p services/data-collector/src && echo "fn main() {}" > services/data-collector/src/main.rs && \
mkdir -p services/json-generator/src && echo "fn main() {}" > services/json-generator/src/main.rs && \
mkdir -p services/shared-lib/src && touch services/shared-lib/src/lib.rs && \
mkdir -p services/build-orchestrator/src && echo "fn main() {}" > services/build-orchestrator/src/main.rs && \
mkdir -p services/edge-agent/src && echo "fn main() {}" > services/edge-agent/src/main.rs && \
mkdir -p services/events-relay/src && echo "fn main() {}" > services/events-relay/src/main.rs && \
mkdir -p services/telemetry-ingestor/src && echo "fn main() {}" > services/telemetry-ingestor/src/main.rs
COPY services/backend-api/Cargo.toml services/backend-api/Cargo.toml
COPY services/data-collector/Cargo.toml services/data-collector/Cargo.toml
COPY services/json-generator/Cargo.toml services/json-generator/Cargo.toml
COPY services/shared-lib/Cargo.toml services/shared-lib/Cargo.toml
COPY services/build-orchestrator/Cargo.toml services/build-orchestrator/Cargo.toml
COPY services/edge-agent/Cargo.toml services/edge-agent/Cargo.toml
COPY services/events-relay/Cargo.toml services/events-relay/Cargo.toml
COPY services/telemetry-ingestor/Cargo.toml services/telemetry-ingestor/Cargo.toml
# Pre-descargar todas las dependencias de forma secuencial para evitar colisiones
RUN --mount=type=cache,sharing=locked,target=/usr/local/cargo/registry \
--mount=type=cache,sharing=locked,target=/usr/local/cargo/git \
cargo fetch
# Compilar shared-lib primero
COPY services/shared-lib services/shared-lib COPY services/shared-lib services/shared-lib
# Compilar shared-lib (será rápido debido al caché de deps)
RUN --mount=type=cache,sharing=locked,target=/usr/local/cargo/registry \ RUN --mount=type=cache,sharing=locked,target=/usr/local/cargo/registry \
--mount=type=cache,sharing=locked,target=/usr/local/cargo/git \ --mount=type=cache,sharing=locked,target=/usr/local/cargo/git \
--mount=type=cache,sharing=locked,target=/app/target \ --mount=type=cache,target=/app/target \
cargo build --release -p shared-lib cargo build --release -p shared-lib
# 5. BUILDER-WINDOWS: Especializada en el agente # 5. BUILDER-WINDOWS: Especializada en el agente
FROM base AS builder-windows FROM base AS builder-windows
RUN apt-get update && apt-get install -y gcc-mingw-w64-x86-64 && rm -rf /var/lib/apt/lists/* COPY --from=cacher /app /app
RUN rustup target add x86_64-pc-windows-gnu COPY --from=cacher /usr/local/cargo /usr/local/cargo
COPY Cargo.toml Cargo.lock ./
RUN mkdir -p services/backend-api/src && echo "fn main() {}" > services/backend-api/src/main.rs && \
mkdir -p services/data-collector/src && echo "fn main() {}" > services/data-collector/src/main.rs && \
mkdir -p services/json-generator/src && echo "fn main() {}" > services/json-generator/src/main.rs && \
mkdir -p services/shared-lib/src && touch services/shared-lib/src/lib.rs && \
mkdir -p services/build-orchestrator/src && echo "fn main() {}" > services/build-orchestrator/src/main.rs && \
mkdir -p services/edge-agent/src && echo "fn main() {}" > services/edge-agent/src/main.rs && \
mkdir -p services/events-relay/src && echo "fn main() {}" > services/events-relay/src/main.rs && \
mkdir -p services/telemetry-ingestor/src && echo "fn main() {}" > services/telemetry-ingestor/src/main.rs
COPY services/backend-api/Cargo.toml services/backend-api/Cargo.toml
COPY services/data-collector/Cargo.toml services/data-collector/Cargo.toml
COPY services/json-generator/Cargo.toml services/json-generator/Cargo.toml
COPY services/shared-lib/Cargo.toml services/shared-lib/Cargo.toml
COPY services/build-orchestrator/Cargo.toml services/build-orchestrator/Cargo.toml
COPY services/edge-agent/Cargo.toml services/edge-agent/Cargo.toml
COPY services/events-relay/Cargo.toml services/events-relay/Cargo.toml
COPY services/telemetry-ingestor/Cargo.toml services/telemetry-ingestor/Cargo.toml
RUN --mount=type=cache,sharing=locked,target=/usr/local/cargo/registry \
--mount=type=cache,sharing=locked,target=/usr/local/cargo/git \
cargo fetch
COPY services/shared-lib services/shared-lib COPY services/shared-lib services/shared-lib
COPY services/edge-agent services/edge-agent COPY services/edge-agent services/edge-agent
RUN --mount=type=cache,sharing=locked,target=/usr/local/cargo/registry \
# Parchear la versión del agente en Cargo.toml antes de compilar
ARG BUILD_VERSION=0.3.0
RUN CURRENT_VERSION=$(grep -m 1 "^version =" services/edge-agent/Cargo.toml | cut -d '"' -f 2) && \
if [ "$CURRENT_VERSION" != "$BUILD_VERSION" ]; then \
sed -i "0,/^version = /s/^version = \"[^\"]*\"/version = \"${BUILD_VERSION}\"/" services/edge-agent/Cargo.toml; \
fi
RUN --mount=type=cache,target=/app/target \
--mount=type=cache,sharing=locked,target=/usr/local/cargo/registry \
--mount=type=cache,sharing=locked,target=/usr/local/cargo/git \ --mount=type=cache,sharing=locked,target=/usr/local/cargo/git \
--mount=type=cache,sharing=locked,target=/app/target \ RUSTFLAGS="-C target-feature=+crt-static -C link-args=-static" \
cargo build --release -p edge-agent --target x86_64-pc-windows-gnu && \ cargo build --release -p edge-agent --target x86_64-pc-windows-gnu && \
mkdir -p /app/bin/windows && cp target/x86_64-pc-windows-gnu/release/edge-agent.exe /app/bin/windows/ cargo build --release -p edge-agent --bin edge-agent --target x86_64-unknown-linux-musl && \
mkdir -p /app/bin/windows && cp target/x86_64-pc-windows-gnu/release/edge-agent.exe /app/bin/windows/ && \
mkdir -p /app/bin/linux && cp target/x86_64-unknown-linux-musl/release/edge-agent /app/bin/linux/
# =============================================================================
# RUNTIME BASE: Imagen base optimizada para todos los servicios
# =============================================================================
# Un ÚNICO apt-get update para todos los paquetes de runtime
FROM debian:bookworm-slim AS runtime-base
RUN apt-get update && apt-get install -y \
libssl3 ca-certificates curl mosquitto-clients \
&& rm -rf /var/lib/apt/lists/*
# ============================================================================= # =============================================================================
# INDIVIDUAL SERVICE TARGETS # INDIVIDUAL SERVICE TARGETS
@@ -103,18 +121,17 @@ RUN --mount=type=cache,sharing=locked,target=/usr/local/cargo/registry \
# --- BACKEND-API --- # --- BACKEND-API ---
FROM builder-linux AS builder-backend-api FROM builder-linux AS builder-backend-api
COPY services/backend-api services/backend-api COPY services/backend-api services/backend-api
COPY services/shared-lib services/shared-lib COPY services/json-generator services/json-generator
COPY .sqlx .sqlx COPY .sqlx .sqlx
ENV SQLX_OFFLINE=true ENV SQLX_OFFLINE=true
RUN --mount=type=cache,sharing=locked,target=/usr/local/cargo/registry \ RUN --mount=type=cache,sharing=locked,target=/usr/local/cargo/registry \
--mount=type=cache,sharing=locked,target=/usr/local/cargo/git \ --mount=type=cache,sharing=locked,target=/usr/local/cargo/git \
--mount=type=cache,sharing=locked,target=/app/target \ --mount=type=cache,target=/app/target \
cargo build --release -p backend-api && \ cargo build --release -p backend-api && \
cp target/release/backend-api /app/backend-api cp target/release/backend-api /app/backend-api
FROM debian:bookworm-slim AS backend-api FROM runtime-base AS backend-api
WORKDIR /app WORKDIR /app
RUN apt-get update && apt-get install -y libssl3 ca-certificates curl && rm -rf /var/lib/apt/lists/*
COPY --from=builder-backend-api /app/backend-api . COPY --from=builder-backend-api /app/backend-api .
ENV RUST_LOG=info ENV RUST_LOG=info
EXPOSE 8000 EXPOSE 8000
@@ -125,13 +142,12 @@ FROM builder-linux AS builder-data-collector
COPY services/data-collector services/data-collector COPY services/data-collector services/data-collector
RUN --mount=type=cache,sharing=locked,target=/usr/local/cargo/registry \ RUN --mount=type=cache,sharing=locked,target=/usr/local/cargo/registry \
--mount=type=cache,sharing=locked,target=/usr/local/cargo/git \ --mount=type=cache,sharing=locked,target=/usr/local/cargo/git \
--mount=type=cache,sharing=locked,target=/app/target \ --mount=type=cache,target=/app/target \
cargo build --release -p data-collector && \ cargo build --release -p data-collector && \
cp target/release/data-collector /app/data-collector cp target/release/data-collector /app/data-collector
FROM debian:bookworm-slim AS data-collector FROM runtime-base AS data-collector
WORKDIR /app WORKDIR /app
RUN apt-get update && apt-get install -y libssl3 ca-certificates && rm -rf /var/lib/apt/lists/*
COPY --from=builder-data-collector /app/data-collector . COPY --from=builder-data-collector /app/data-collector .
ENV RUST_LOG=info ENV RUST_LOG=info
CMD ["./data-collector"] CMD ["./data-collector"]
@@ -141,13 +157,12 @@ FROM builder-linux AS builder-json-generator
COPY services/json-generator services/json-generator COPY services/json-generator services/json-generator
RUN --mount=type=cache,sharing=locked,target=/usr/local/cargo/registry \ RUN --mount=type=cache,sharing=locked,target=/usr/local/cargo/registry \
--mount=type=cache,sharing=locked,target=/usr/local/cargo/git \ --mount=type=cache,sharing=locked,target=/usr/local/cargo/git \
--mount=type=cache,sharing=locked,target=/app/target \ --mount=type=cache,target=/app/target \
cargo build --release -p json-generator && \ cargo build --release -p json-generator && \
cp target/release/json-generator /app/json-generator cp target/release/json-generator /app/json-generator
FROM debian:bookworm-slim AS json-generator FROM runtime-base AS json-generator
WORKDIR /app WORKDIR /app
RUN apt-get update && apt-get install -y libssl3 ca-certificates && rm -rf /var/lib/apt/lists/*
COPY --from=builder-json-generator /app/json-generator . COPY --from=builder-json-generator /app/json-generator .
ENV RUST_LOG=info ENV RUST_LOG=info
CMD ["./json-generator"] CMD ["./json-generator"]
@@ -157,13 +172,12 @@ FROM builder-linux AS builder-telemetry-ingestor
COPY services/telemetry-ingestor services/telemetry-ingestor COPY services/telemetry-ingestor services/telemetry-ingestor
RUN --mount=type=cache,sharing=locked,target=/usr/local/cargo/registry \ RUN --mount=type=cache,sharing=locked,target=/usr/local/cargo/registry \
--mount=type=cache,sharing=locked,target=/usr/local/cargo/git \ --mount=type=cache,sharing=locked,target=/usr/local/cargo/git \
--mount=type=cache,sharing=locked,target=/app/target \ --mount=type=cache,target=/app/target \
cargo build --release -p telemetry-ingestor && \ cargo build --release -p telemetry-ingestor && \
cp target/release/telemetry-ingestor /app/telemetry-ingestor cp target/release/telemetry-ingestor /app/telemetry-ingestor
FROM debian:bookworm-slim AS telemetry-ingestor FROM runtime-base AS telemetry-ingestor
WORKDIR /app WORKDIR /app
RUN apt-get update && apt-get install -y libssl3 ca-certificates && rm -rf /var/lib/apt/lists/*
COPY --from=builder-telemetry-ingestor /app/telemetry-ingestor . COPY --from=builder-telemetry-ingestor /app/telemetry-ingestor .
ENV RUST_LOG=info ENV RUST_LOG=info
CMD ["./telemetry-ingestor"] CMD ["./telemetry-ingestor"]
@@ -173,13 +187,12 @@ FROM builder-linux AS builder-events-relay
COPY services/events-relay services/events-relay COPY services/events-relay services/events-relay
RUN --mount=type=cache,sharing=locked,target=/usr/local/cargo/registry \ RUN --mount=type=cache,sharing=locked,target=/usr/local/cargo/registry \
--mount=type=cache,sharing=locked,target=/usr/local/cargo/git \ --mount=type=cache,sharing=locked,target=/usr/local/cargo/git \
--mount=type=cache,sharing=locked,target=/app/target \ --mount=type=cache,target=/app/target \
cargo build --release -p events-relay && \ cargo build --release -p events-relay && \
cp target/release/events-relay /app/events-relay cp target/release/events-relay /app/events-relay
FROM debian:bookworm-slim AS events-relay FROM runtime-base AS events-relay
WORKDIR /app WORKDIR /app
RUN apt-get update && apt-get install -y libssl3 ca-certificates && rm -rf /var/lib/apt/lists/*
COPY --from=builder-events-relay /app/events-relay . COPY --from=builder-events-relay /app/events-relay .
ENV RUST_LOG=info ENV RUST_LOG=info
CMD ["./events-relay"] CMD ["./events-relay"]
@@ -189,21 +202,21 @@ FROM builder-linux AS builder-build-orchestrator
COPY services/build-orchestrator services/build-orchestrator COPY services/build-orchestrator services/build-orchestrator
RUN --mount=type=cache,sharing=locked,target=/usr/local/cargo/registry \ RUN --mount=type=cache,sharing=locked,target=/usr/local/cargo/registry \
--mount=type=cache,sharing=locked,target=/usr/local/cargo/git \ --mount=type=cache,sharing=locked,target=/usr/local/cargo/git \
--mount=type=cache,sharing=locked,target=/app/target \ --mount=type=cache,target=/app/target \
cargo build --release -p build-orchestrator && \ cargo build --release -p build-orchestrator && \
cp target/release/build-orchestrator /app/build-orchestrator cp target/release/build-orchestrator /app/build-orchestrator
FROM debian:bookworm-slim AS build-orchestrator FROM runtime-base AS build-orchestrator
WORKDIR /app WORKDIR /app
RUN apt-get update && apt-get install -y libssl3 ca-certificates mosquitto-clients && rm -rf /var/lib/apt/lists/*
# Binario del orquestador
COPY --from=builder-build-orchestrator /app/build-orchestrator . COPY --from=builder-build-orchestrator /app/build-orchestrator .
# Template del agente (Desde la etapa builder-windows) # Template del agente (Desde la etapa builder-windows)
RUN mkdir -p target/x86_64-pc-windows-gnu/release RUN mkdir -p target/x86_64-pc-windows-gnu/release
COPY --from=builder-windows /app/bin/windows/edge-agent.exe target/x86_64-pc-windows-gnu/release/ COPY --from=builder-windows /app/bin/windows/edge-agent.exe target/x86_64-pc-windows-gnu/release/
RUN mkdir -p target/x86_64-unknown-linux-musl/release
COPY --from=builder-windows /app/bin/linux/edge-agent target/x86_64-unknown-linux-musl/release/
# Cargo.toml del agente # Cargo.toml del agente
RUN mkdir -p services/edge-agent RUN mkdir -p services/edge-agent
COPY services/edge-agent/Cargo.toml services/edge-agent/Cargo.toml COPY services/edge-agent/Cargo.toml services/edge-agent/Cargo.toml

View File

@@ -26,7 +26,7 @@ services:
- SMTP_TLS_MODE=none - SMTP_TLS_MODE=none
- SMTP_USER= - SMTP_USER=
- SMTP_PASS= - SMTP_PASS=
- SMTP_FROM=notificaciones-local@omnioil.app - SMTP_FROM=OmniOil <soporte@omnioil.app>
- INVITATION_BASE_URL=http://localhost:3000 - INVITATION_BASE_URL=http://localhost:3000
- PLATFORM_CONSOLE_URL=http://localhost:3002/login - PLATFORM_CONSOLE_URL=http://localhost:3002/login
depends_on: depends_on:

View File

@@ -84,6 +84,7 @@ services:
- MQTT_PUBLIC_PORT=${MQTT_PUBLIC_PORT:-1883} - MQTT_PUBLIC_PORT=${MQTT_PUBLIC_PORT:-1883}
- MQTT_USE_WS=${MQTT_USE_WS:-false} - MQTT_USE_WS=${MQTT_USE_WS:-false}
- APP_PUBLIC_URL=${APP_PUBLIC_URL} - APP_PUBLIC_URL=${APP_PUBLIC_URL}
- AGENT_LATEST_VERSION=${AGENT_LATEST_VERSION:-0.3.0}
expose: expose:
- "8000" - "8000"
depends_on: depends_on:
@@ -150,9 +151,12 @@ services:
target: json-generator target: json-generator
environment: environment:
- DATABASE_URL=postgres://${DB_USER}:${DB_PASSWORD}@timescaledb:5432/${DB_NAME} - DATABASE_URL=postgres://${DB_USER}:${DB_PASSWORD}@timescaledb:5432/${DB_NAME}
- MINIO_ENDPOINT=${MINIO_ENDPOINT} - MINIO_ENDPOINT_URL=http://minio:9000
- MINIO_ENDPOINT=http://minio:9000
- MINIO_BUCKET=${MINIO_BUCKET:-anh-reports}
- AWS_ACCESS_KEY_ID=${MINIO_USER} - AWS_ACCESS_KEY_ID=${MINIO_USER}
- AWS_SECRET_ACCESS_KEY=${MINIO_PASSWORD} - AWS_SECRET_ACCESS_KEY=${MINIO_PASSWORD}
- AWS_REGION=${AWS_REGION:-us-east-1}
depends_on: depends_on:
backend-api: backend-api:
condition: service_healthy condition: service_healthy
@@ -165,7 +169,8 @@ services:
container_name: anh_landing container_name: anh_landing
build: build:
context: . context: .
dockerfile: services/landing/Dockerfile dockerfile: Dockerfile.frontend
target: landing
args: args:
- NEXT_PUBLIC_TURNSTILE_SITE_KEY=${NEXT_PUBLIC_TURNSTILE_SITE_KEY:-${VITE_TURNSTILE_SITE_KEY}} - NEXT_PUBLIC_TURNSTILE_SITE_KEY=${NEXT_PUBLIC_TURNSTILE_SITE_KEY:-${VITE_TURNSTILE_SITE_KEY}}
environment: environment:
@@ -180,7 +185,8 @@ services:
restart: always restart: always
build: build:
context: . context: .
dockerfile: services/frontend-pwa/Dockerfile dockerfile: Dockerfile.frontend
target: frontend-pwa
args: args:
- VITE_API_BASE=/api - VITE_API_BASE=/api
- VITE_ADMIN_APP_ORIGIN=${VITE_ADMIN_APP_ORIGIN} - VITE_ADMIN_APP_ORIGIN=${VITE_ADMIN_APP_ORIGIN}
@@ -195,8 +201,9 @@ services:
container_name: anh_frontend_admin container_name: anh_frontend_admin
restart: always restart: always
build: build:
context: ./services/frontend-admin context: .
dockerfile: Dockerfile dockerfile: Dockerfile.frontend
target: frontend-admin
args: args:
- VITE_API_BASE=/api - VITE_API_BASE=/api
- VITE_ADMIN_APP_ORIGIN=${VITE_ADMIN_APP_ORIGIN} - VITE_ADMIN_APP_ORIGIN=${VITE_ADMIN_APP_ORIGIN}
@@ -213,11 +220,13 @@ services:
container_name: anh_frontend_console container_name: anh_frontend_console
restart: always restart: always
build: build:
context: ./services/frontend-console context: .
dockerfile: Dockerfile dockerfile: Dockerfile.frontend
target: frontend-console
args: args:
- VITE_API_BASE=/api - VITE_API_BASE=/api
- VITE_CONSOLE_APP_ORIGIN=${VITE_CONSOLE_APP_ORIGIN:-https://console.omnioil.app} - VITE_CONSOLE_APP_ORIGIN=${VITE_CONSOLE_APP_ORIGIN:-https://console.omnioil.app}
- VITE_ACCESS_REQUEST_REVIEWER_EMAILS=${ACCESS_REQUEST_REVIEWER_EMAILS:-w.farfan@omnioil.app,h.ortegon@omnioil.app}
expose: expose:
- "80" - "80"
depends_on: depends_on:
@@ -252,6 +261,8 @@ services:
context: . context: .
dockerfile: Dockerfile.unified dockerfile: Dockerfile.unified
target: build-orchestrator target: build-orchestrator
args:
- BUILD_VERSION=${AGENT_LATEST_VERSION:-0.3.0}
environment: environment:
- DATABASE_URL=postgres://${DB_USER}:${DB_PASSWORD}@timescaledb:5432/${DB_NAME} - DATABASE_URL=postgres://${DB_USER}:${DB_PASSWORD}@timescaledb:5432/${DB_NAME}
- MQTT_HOST=mosquitto - MQTT_HOST=mosquitto
@@ -269,6 +280,7 @@ services:
- MQTT_USE_WS=${MQTT_USE_WS:-true} - MQTT_USE_WS=${MQTT_USE_WS:-true}
- OMNIOIL_MASTER_SECRET=${OMNIOIL_MASTER_SECRET} - OMNIOIL_MASTER_SECRET=${OMNIOIL_MASTER_SECRET}
- S3_INSTALLERS_BUCKET=${S3_INSTALLERS_BUCKET:-omnioil-releases} - S3_INSTALLERS_BUCKET=${S3_INSTALLERS_BUCKET:-omnioil-releases}
- AGENT_LATEST_VERSION=${AGENT_LATEST_VERSION:-0.3.0}
volumes: volumes:
- ./infrastructure/mosquitto:/app/infrastructure/mosquitto - ./infrastructure/mosquitto:/app/infrastructure/mosquitto
- ${INSTALLER_INCOMING:-installer_incoming}:/app/services/edge-agent/incoming - ${INSTALLER_INCOMING:-installer_incoming}:/app/services/edge-agent/incoming
@@ -286,6 +298,8 @@ services:
build: build:
context: . context: .
dockerfile: services/edge-agent/Dockerfile.builder dockerfile: services/edge-agent/Dockerfile.builder
args:
- BUILD_VERSION=${AGENT_LATEST_VERSION:-0.3.0}
volumes: volumes:
- ${INSTALLER_INCOMING:-installer_incoming}:/app/services/edge-agent/incoming - ${INSTALLER_INCOMING:-installer_incoming}:/app/services/edge-agent/incoming
- ${INSTALLER_OUTPUT:-installer_output}:/app/services/edge-agent/output - ${INSTALLER_OUTPUT:-installer_output}:/app/services/edge-agent/output

View File

@@ -12,7 +12,7 @@ La **Resolución 0651 de 2025** de la Agencia Nacional de Hidrocarburos (ANH) es
- Instalación de sistemas de telemetría certificados en todos los campos de producción activos - Instalación de sistemas de telemetría certificados en todos los campos de producción activos
- Reporte diario automático en formato JSON con identificador único de recurso - Reporte diario automático en formato JSON con identificador único de recurso
- Almacenamiento de datos con frecuencia mínima de 5 minutos - Almacenamiento de datos con frecuencia mínima de 5 minutos
- Mecanismos de integridad de datos (hash SHA-256) en cada reporte - Mecanismos de integridad, inalterabilidad y trazabilidad del dato reportado
- Trazabilidad completa de ingresos y movimientos de crudo - Trazabilidad completa de ingresos y movimientos de crudo
- Registro de paradas planificadas y no planificadas con justificación - Registro de paradas planificadas y no planificadas con justificación
@@ -32,7 +32,7 @@ El incumplimiento de la Resolución puede resultar en:
| **Art. 7** | Sistema de medición continua (mínimo cada 5 min) | Telemetría cada 10 segundos vía MQTT, almacenada en TimescaleDB | ✅ | | **Art. 7** | Sistema de medición continua (mínimo cada 5 min) | Telemetría cada 10 segundos vía MQTT, almacenada en TimescaleDB | ✅ |
| **Art. 17** | Almacenamiento periódico mínimo 5 min | Hypertable con registros cada ≤5 minutos. Resolución real: 10 segundos | ✅ | | **Art. 17** | Almacenamiento periódico mínimo 5 min | Hypertable con registros cada ≤5 minutos. Resolución real: 10 segundos | ✅ |
| **Art. 22** | Redundancia ante fallos de comunicación | Store & Forward en Edge Agent (SQLite AES-256 local) | ✅ | | **Art. 22** | Redundancia ante fallos de comunicación | Store & Forward en Edge Agent (SQLite AES-256 local) | ✅ |
| **Art. 26** | Integridad e inalterabilidad de datos | TimescaleDB append-only + hash SHA-256 en cada reporte | ✅ | | **Art. 26** | Integridad e inalterabilidad de datos | TimescaleDB append-only + artefactos JSON sellados en MinIO; cada corrección crea una nueva versión inmutable con hash SHA-384 propio | ✅ |
| **Art. 27** | Timestamps UTC obligatorios | `timestamptz` en todas las tablas, NTP sincronizado | ✅ | | **Art. 27** | Timestamps UTC obligatorios | `timestamptz` en todas las tablas, NTP sincronizado | ✅ |
| **Art. 34** | Nomenclatura del archivo de reporte | `OPERADOR_CONTRATO_DD-MMM-YYYY.json` implementado | ✅ | | **Art. 34** | Nomenclatura del archivo de reporte | `OPERADOR_CONTRATO_DD-MMM-YYYY.json` implementado | ✅ |
| **Art. 35** | ID de recurso coincidente con Forma 4CR | Campo `id_recurso` mapeado al código ANH del pozo | ✅ | | **Art. 35** | ID de recurso coincidente con Forma 4CR | Campo `id_recurso` mapeado al código ANH del pozo | ✅ |
@@ -41,27 +41,19 @@ El incumplimiento de la Resolución puede resultar en:
| **Art. 52** | Registro de paradas y excepciones | Sección `exceptions` en el reporte JSON con alarmas detectadas | ✅ | | **Art. 52** | Registro de paradas y excepciones | Sección `exceptions` en el reporte JSON con alarmas detectadas | ✅ |
| **Art. 55** | Auditoría de datos ingresados manualmente | `audit_log` con usuario, IP, timestamp, valor anterior/nuevo | ✅ | | **Art. 55** | Auditoría de datos ingresados manualmente | `audit_log` con usuario, IP, timestamp, valor anterior/nuevo | ✅ |
| **Art. 60** | Metrología y calibración | Campos de calibración en activos: última fecha, próxima fecha, certificado | ✅ | | **Art. 60** | Metrología y calibración | Campos de calibración en activos: última fecha, próxima fecha, certificado | ✅ |
| **Art. 65** | Acceso del ente regulador | Rol `anh_reader` con acceso de solo lectura a reportes y datos históricos | ✅ | | **Art. 65** | Acceso del ente regulador | Rol `anh_reader` con acceso de solo lectura a reportes, versiones corregidas y datos históricos dispuestos para consulta ANH | ✅ |
--- ---
## 3. Integridad de Datos ## 3. Integridad de Datos
### 3.1 Hash SHA-256 ### 3.1 Artefacto JSON sellado e integridad interna
Cada reporte diario generado incluye un hash de integridad en el encabezado: El JSON entregado a la ANH mantiene únicamente la estructura normativa del Anexo 4. OmniOil no agrega campos propios como `Hash Sello`, `INTEGRITY_HASH` o un `hash_sha256` en el cuerpo del JSON entregable.
```json Para auditoría interna, cada reporte se sella como bytes finales en MinIO y el sistema calcula `hash_sha384` sobre esos bytes exactos. Ese hash se conserva como metadato de evidencia, separado del contenido normativo del archivo.
{
"header": {
"hash_sha256": "a3f5c8d9e2b1f4a7c6d8e9f0a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0",
"algoritmo": "SHA-256",
"fecha_generacion": "2026-05-04T06:30:00Z"
}
}
```
Cualquier alteración del contenido del archivo después de su generación invalidará el hash, permitiendo detectar manipulaciones. Cualquier alteración del objeto sellado cambia el SHA-384 esperado y permite detectar manipulaciones sin modificar la forma del JSON que recibe la ANH.
### 3.2 Timestamps UTC ### 3.2 Timestamps UTC
@@ -208,18 +200,44 @@ CREATE TABLE audit_log (
### 8.1 Proceso de Generación ### 8.1 Proceso de Generación
El microservicio `json-generator` ejecuta diariamente a las **06:30 AM UTC**: El microservicio `json-generator` genera reportes ANH con una programación administrable desde el panel Admin. La configuración por defecto es **06:00** en zona horaria **America/Bogota** y puede activarse/desactivarse por unidad de generación ANH.
1. Consulta todos los proyectos activos con contrato ANH configurado 1. Consulta todos los proyectos activos con contrato ANH configurado
2. Para cada proyecto, agrega: 2. Para cada proyecto, agrega:
- Telemetría de las últimas 24h (con promedio, mínimo y máximo por variable) - Telemetría de las últimas 24h (con promedio, mínimo y máximo por variable)
- Movimientos de producción del período - Movimientos de producción del período
- Alarmas y excepciones del período - Alarmas y excepciones del período
3. Calcula el hash SHA-256 del contenido 3. Serializa el JSON normativo final, sin campos de hash agregados por OmniOil
4. Guarda el archivo en MinIO (bucket `anh-reports`) 4. Guarda los bytes finales en MinIO (bucket `anh-reports`) y registra `hash_sha384` como metadato interno de auditoría
5. Envía notificación de reporte generado a los supervisores 5. Deja el reporte en estado generado/disponible para revisión; no lo aprueba automáticamente
### 8.2 Generación Manual ### 8.2 Estados del ciclo de vida
- `GENERATED`: reporte sellado y disponible para revisión manual.
- `APPROVED`: reporte aprobado manualmente por un administrador.
- `SENT` / `DISPOSED`: estados históricos legibles por compatibilidad; en la interfaz se interpretan como **disponible para ANH** o **dispuesto para consulta ANH**.
- `REJECTED`: ANH registró rechazo o inconsistencia sobre una versión disponible.
- `CORRECTION_DRAFT`: nueva versión de corrección en preparación; no modifica el artefacto sellado anterior.
- `CORRECTED_APPROVED`: versión corregida aprobada y sellada con hash propio.
- `AVAILABLE_FOR_ANH`: artefacto sellado disponible para consulta ANH.
- `FAILED`: generación fallida o error operativo.
### 8.3 Correcciones y re-disposición para consulta ANH
OmniOil **no envía reportes a la ANH** ni representa una retransmisión saliente por API. El sistema conserva artefactos sellados y los deja **disponibles para ANH** / **dispuestos para consulta ANH** cuando el regulador decida accederlos.
Flujo de corrección:
1. Un administrador autorizado registra el rechazo o inconsistencia con motivo obligatorio y fecha/hora del rechazo.
2. Desde esa fecha/hora corre un SLA de **1 hora** para preparar la corrección.
3. La corrección se crea como una nueva versión inmutable (`correction_version`) enlazada a la versión previa y a la raíz del reporte.
4. El artefacto anterior permanece sellado: no se sobrescriben `object_key`, `hash_sha384` ni `sealed_at`.
5. La versión corregida se aprueba y se sella con su propio `object_key`, `hash_sha384` y `sealed_at`.
6. El administrador marca la versión corregida como **disponible para ANH**; esto solo registra la disposición para consulta y no dispara integración saliente.
Cada evento queda en auditoría con actor, motivo, timestamp, versión previa, versión raíz y metadatos del artefacto sellado. La historia permite reconstruir la cadena completa: original → rechazo/inconsistencia → versión corregida → artefacto corregido sellado → disponible para ANH.
### 8.4 Generación Manual
Para generar el reporte de un día específico: Para generar el reporte de un día específico:
``` ```
@@ -244,7 +262,11 @@ Antes de la certificación ante la ANH, verificar:
- [ ] Reglas de alerta configuradas para variables críticas - [ ] Reglas de alerta configuradas para variables críticas
- [ ] Fechas de calibración actualizadas en todos los activos - [ ] Fechas de calibración actualizadas en todos los activos
- [ ] Primer reporte ANH generado y verificado manualmente - [ ] Primer reporte ANH generado y verificado manualmente
- [ ] Hash SHA-256 verificado correctamente - [ ] Hash interno SHA-384 verificado contra los bytes sellados descargados desde MinIO
- [ ] Correcciones ANH verificadas como versiones inmutables, sin sobrescribir artefactos sellados previos
- [ ] Rechazos/inconsistencias registran motivo obligatorio y SLA de 1 hora desde el timestamp de rechazo
- [ ] Versiones corregidas aprobadas quedan disponibles para ANH / dispuestas para consulta ANH, sin flujo de envío saliente
- [ ] Audit trail muestra actor, motivo, timestamp, versión previa, versión raíz, hash y objeto sellado
- [ ] Nomenclatura del archivo cumple con el Art. 34 - [ ] Nomenclatura del archivo cumple con el Art. 34
- [ ] Rol `anh_reader` creado para el auditor de la ANH - [ ] Rol `anh_reader` creado para el auditor de la ANH
- [ ] MinIO accesible para descarga de reportes - [ ] MinIO accesible para descarga de reportes

View File

@@ -102,14 +102,14 @@ COMPOSE_PATH_SEPARATOR=:
INVITATION_BASE_URL=https://app.omnioil.app INVITATION_BASE_URL=https://app.omnioil.app
SMTP_HOST=smtp.tu-proveedor.com SMTP_HOST=smtp.tu-proveedor.com
SMTP_PORT=587 SMTP_PORT=587
SMTP_USER=notificaciones@omnioil.app SMTP_USER=soporte@omnioil.app
SMTP_PASS=<gestionar-en-secrets-manager> SMTP_PASS=<gestionar-en-secrets-manager>
SMTP_FROM=notificaciones@omnioil.app SMTP_FROM=OmniOil <soporte@omnioil.app>
``` ```
`INVITATION_BASE_URL` debe apuntar al Admin App público porque los clientes activan su cuenta en `https://app.omnioil.app/activate?token=...`, no en la Console interna. Las variables `SMTP_*` son requeridas para los correos de aprobación/activación y reutilizan la configuración SMTP del backend; `SMTP_PASS` debe cargarse desde el gestor de secretos de Dokploy o equivalente, nunca desde el repositorio. `INVITATION_BASE_URL` debe apuntar al Admin App público porque los clientes activan su cuenta en `https://app.omnioil.app/activate?token=...`, no en la Console interna. Las variables `SMTP_*` son requeridas para acuses de recibo, rechazos, aprobación/activación y reutilizan la configuración SMTP del backend; `SMTP_PASS` debe cargarse desde el gestor de secretos de Dokploy o equivalente, nunca desde el repositorio. Los únicos correos autorizados para aprobar, rechazar, marcar en revisión o reenviar invitaciones son `w.farfan@omnioil.app` y `h.ortegon@omnioil.app`; esta allowlist se aplica en backend.
Cuando se aprueba una solicitud, el backend crea el proyecto, el usuario administrador cliente, el acceso al proyecto y una suscripción inicial `trial` de 7 días antes de intentar enviar el correo. Si SMTP falla, la aprobación NO se revierte: el operador debe revisar la solicitud en Console y usar `Reenviar invitación` después de corregir la configuración o incidente SMTP. Cuando se aprueba una solicitud, el backend crea el proyecto, el usuario administrador cliente, el acceso al proyecto y una suscripción inicial `trial` de 30 días antes de intentar enviar el correo. Si SMTP falla, la aprobación NO se revierte: el operador debe revisar la solicitud en Console y usar `Reenviar invitación` después de corregir la configuración o incidente SMTP.
### 2.3 Configuración MQTT sobre WSS ### 2.3 Configuración MQTT sobre WSS
@@ -136,11 +136,13 @@ wss://mqtt.omnioil.app:443
- [ ] `TURNSTILE_SECRET_KEY` configurado en backend para validar solicitudes públicas de acceso - [ ] `TURNSTILE_SECRET_KEY` configurado en backend para validar solicitudes públicas de acceso
- [ ] Puertos 5432 (TimescaleDB) y 6379 (Redis) NO expuestos públicamente - [ ] Puertos 5432 (TimescaleDB) y 6379 (Redis) NO expuestos públicamente
- [ ] `VITE_API_BASE` apunta al subdominio correcto de la API - [ ] `VITE_API_BASE` apunta al subdominio correcto de la API
- [ ] `VITE_ADMIN_APP_ORIGIN=https://app.omnioil.app` y `VITE_MOBILE_APP_ORIGIN=https://mobile.omnioil.app` configurados antes de rebuildar frontends
- [ ] `VITE_CONSOLE_APP_ORIGIN` apunta a `https://console.omnioil.app` - [ ] `VITE_CONSOLE_APP_ORIGIN` apunta a `https://console.omnioil.app`
- [ ] `ALLOWED_ORIGINS` incluye `https://app.omnioil.app`, `https://mobile.omnioil.app` y `https://console.omnioil.app` - [ ] `ALLOWED_ORIGINS` incluye `https://app.omnioil.app`, `https://mobile.omnioil.app` y `https://console.omnioil.app`
- [ ] `VITE_TURNSTILE_SITE_KEY` configurado en el frontend admin para el formulario público de registro - [ ] `VITE_TURNSTILE_SITE_KEY` configurado en el frontend admin para el formulario público de registro
- [ ] `INVITATION_BASE_URL=https://app.omnioil.app` configurado para links de activación de solicitudes aprobadas - [ ] `INVITATION_BASE_URL=https://app.omnioil.app` configurado para links de activación de solicitudes aprobadas
- [ ] `SMTP_HOST`, `SMTP_PORT`, `SMTP_USER`, `SMTP_PASS` y `SMTP_FROM` configurados para alertas ANH y correos de aprobación/activación - [ ] `SMTP_HOST`, `SMTP_PORT`, `SMTP_USER=soporte@omnioil.app`, `SMTP_PASS` y `SMTP_FROM=OmniOil <soporte@omnioil.app>` configurados para alertas ANH y correos de acceso
- [ ] Console recibe `VITE_ACCESS_REQUEST_REVIEWER_EMAILS=w.farfan@omnioil.app,h.ortegon@omnioil.app` como ayuda visual; la autorización real se valida en backend
- [ ] Operadores informados: un fallo SMTP no revierte la aprobación; corregir SMTP y usar `Reenviar invitación` desde Console - [ ] Operadores informados: un fallo SMTP no revierte la aprobación; corregir SMTP y usar `Reenviar invitación` desde Console
- [ ] `TELEGRAM_*` configurado para alertas ANH cuando aplique - [ ] `TELEGRAM_*` configurado para alertas ANH cuando aplique
- [ ] Backup inicial de la base de datos configurado - [ ] Backup inicial de la base de datos configurado

View File

@@ -0,0 +1,200 @@
# Docker Build Cache Optimization Guide
## Changes Made
### 1. **Dockerfile.unified** (Rust Services)
#### Problem Solved
- Previously, `planner` and `cacher` stages only generated/compiled dependencies for `backend-api`
- This caused other services to recompile shared dependencies independently
- Each service rebuild invalidated previous builds' caches
#### Improvements
- **Planner stage**: Now copies only `Cargo.toml` files and creates stub source files
- Generates recipe for **ALL workspace dependencies**, not just backend-api
- Maximizes cache reuse across all services
- **Cacher stage**: Now compiles **all dependencies** for the entire workspace
- Single cache layer for all Rust services
- Dependencies are compiled once and reused by all builders
- **Builder stages**: Each builder (linux/windows) now:
- Copies from cacher (pre-compiled deps)
- Uses `--mount=type=cache` for incremental builds
- Only rebuilds when source code changes
#### Expected Improvement
- **First build**: ~1-2 hours (initializes dependencies)
- **Code changes only**: 30-60 seconds (skips dependency compilation)
- **Dependency changes**: ~5-10 minutes (only affected services rebuild)
### 2. **Frontend Dockerfiles** (Node.js SPAs)
#### Problem Solved
- Previously, copied `package.json` and `pnpm-lock.yaml` together
- Any source code change invalidated the entire deps cache
- Rebuild would reinstall `node_modules` even if dependencies unchanged
#### Improvements
**Standard pattern for all frontends** (admin, console, pwa, landing):
```dockerfile
# Stage 1: Dependencies (cached separately)
FROM node:22-alpine AS deps
COPY package.json pnpm-lock.yaml ./
RUN pnpm install (with --mount=type=cache)
# Stage 2: Build (reuses cached node_modules)
FROM node:22-alpine AS builder
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN pnpm build
# Stage 3: Runtime (Nginx)
FROM nginx:alpine
COPY --from=builder /app/dist /usr/share/nginx/html
```
#### Expected Improvement
- **Dependency changes only**: 2-5 minutes (installs new deps)
- **Source code changes**: 10-30 seconds (build only)
- **Rebuild with no changes**: <5 seconds (pulls from cache)
### 3. **docker-compose.yml** (Context Normalization)
#### Problem Solved
- Inconsistent `context` values across services
- Some used `context: .` with full dockerfile paths
- Others used `context: ./services/...` with relative paths
- This made builds unpredictable and paths ambiguous
#### Improvements
- **All services now use consistent context**:
- Rust services: `context: .` with `dockerfile: Dockerfile.unified` (workspace build)
- Frontend services: `context: ./services/frontend-xxx` with `dockerfile: Dockerfile`
- Infrastructure: `context: ./infrastructure/mosquitto` (local builds)
### 4. **.dockerignore** (Build Context Optimization)
Already configured to exclude:
- Build artifacts (`target/`, `dist/`, `.next/`, `node_modules/`)
- Git and IDE files
- Documentation (not needed in images)
- Test files and CI configs
## Docker Build Commands
### Full Stack Build (with caching)
```bash
# Build all services (leverages existing cache)
docker compose -f docker-compose.yml build
# Build specific service
docker compose -f docker-compose.yml build backend-api
docker compose -f docker-compose.yml build frontend-admin
```
### Development Stack
```bash
# Start with local cache
docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d
# Rebuild after code changes (fast with cache)
docker compose -f docker-compose.yml build --no-cache # Clear all cache
docker compose -f docker-compose.yml build # Use cache
```
### CI/CD Recommendations
For **GitHub Actions** or **GitLab CI**:
```yaml
# Use --cache-from and --cache-to for BuildKit cache export
docker buildx build --cache-from=type=gha \
--cache-to=type=gha,mode=max \
-t myimage:latest .
```
For **Local Development**:
```bash
# Enable BuildKit (faster, better caching)
export DOCKER_BUILDKIT=1
# Build with progress output
docker compose build --progress plain
```
## Cache Invalidation Behavior
| Change Type | Cache Invalidated | Rebuild Time |
|-------------|------------------|--------------|
| No changes | No | <5s (pull from cache) |
| Source code only | Deps cache remains | 10-60s |
| `package.json` / `pnpm-lock.yaml` only | Node_modules reused if hash matches | 2-5m |
| `Cargo.toml` / `Cargo.lock` only | Deps recompiled, binaries reused | 5-15m |
| All of above changed | Full rebuild | 1-2h |
## Best Practices
### 1. **Use BuildKit for faster builds**
```bash
export DOCKER_BUILDKIT=1
export COMPOSE_DOCKER_CLI_BUILD=1
export BUILDKIT_PROGRESS=plain
```
### 2. **Keep Dockerfiles stable**
- Don't modify Dockerfile frequently (invalidates base layer cache)
- Update `.dockerignore` to exclude unnecessary files
### 3. **Dependency management**
- Update `Cargo.toml` / `pnpm-lock.yaml` separately when possible
- Commit lock files to version control
- Don't run `cargo update` or `pnpm update` in builds
### 4. **Multi-stage builds**
- Each stage is independently cacheable
- Earlier stages (deps) change less frequently
- Later stages (source) are rebuilt more often
### 5. **Monitor cache efficiency**
```bash
# View cache layers
docker buildx du
# Clear unused cache
docker buildx prune
```
## Troubleshooting
### Cache not working
```bash
# Check if BuildKit is enabled
docker buildx version
# Force rebuild without cache
docker compose build --no-cache
# Clear all local cache
docker buildx prune -a
```
### Slow builds after "fix"
- Verify `.dockerignore` isn't excluding needed files
- Check if `Cargo.lock` and `pnpm-lock.yaml` are stable
- Ensure CI systems use persistent cache volumes
### Container size optimization
```bash
# Check layer sizes
docker history myimage:tag
# Use slim base images (alpine, distroless)
# Already configured in this project
```
## References
- [Docker BuildKit Documentation](https://docs.docker.com/develop/build/build_context/)
- [Dockerfile Best Practices](https://docs.docker.com/develop/dev-best-practices/)
- [cargo-chef for Rust](https://github.com/LukeMathWalker/cargo-chef)

View File

@@ -523,9 +523,9 @@ DOCKER_REGISTRY_URL=docker-registry:5000
# --- NOTIFICACIONES --- # --- NOTIFICACIONES ---
SMTP_HOST=smtp.gmail.com SMTP_HOST=smtp.gmail.com
SMTP_PORT=465 SMTP_PORT=465
SMTP_USER=alertas@omnioil.app SMTP_USER=soporte@omnioil.app
SMTP_PASS=clave_de_aplicacion_smtp SMTP_PASS=clave_de_aplicacion_smtp
SMTP_FROM=alertas@omnioil.app SMTP_FROM=OmniOil <soporte@omnioil.app>
TELEGRAM_BOT_TOKEN=bot_token_telegram TELEGRAM_BOT_TOKEN=bot_token_telegram
TELEGRAM_CHAT_ID=-100123456789 TELEGRAM_CHAT_ID=-100123456789
@@ -588,10 +588,9 @@ El backend expone métricas en `GET /api/metrics` en formato Prometheus:
| Ruta | Descripción | | Ruta | Descripción |
|------|-------------| |------|-------------|
| `/login` | Login de operador | | `/login` | Login de operador |
| `/projects` | Lista de proyectos asignados | | `/app/field` | Inicio de la aplicación de campo |
| `/projects/:id/assets` | Activos del proyecto | | `/app/field/measurements` | Registrar medición manual |
| `/assets/:id/readings` | Registrar medición manual | | `/app/field/movements` | Registrar movimiento de producción |
| `/assets/:id/movements` | Registrar movimiento de producción |
| `/offline` | Estado offline / sincronización pendiente | | `/offline` | Estado offline / sincronización pendiente |
--- ---

132
docs/SIMULADORES.md Normal file
View File

@@ -0,0 +1,132 @@
# 📊 Simulador Multiprotocolo Unificado (Modbus TCP & Siemens S7)
Este documento detalla el funcionamiento, configuración y uso del **Simulador Multiprotocolo Unificado** (`tests/simulators/unified_simulator.py`) desarrollado para facilitar las pruebas locales y el desarrollo dentro del ecosistema **OmniOil**.
El simulador unifica los flujos de simulación industrial en un único script concurrente, evitando tener que levantar múltiples procesos por separado. Además, incorpora un **servidor HTTP con dashboard web interactivo** para visualizar las señales de telemetría en tiempo real.
---
## 🔌 Puertos de Exposición y Servicios
El simulador expone tres servicios en puertos distintos sobre la interfaz de red local (`localhost` / `0.0.0.0`):
| Servicio | Protocolo | Puerto | Descripción |
| :--- | :--- | :--- | :--- |
| **Modbus TCP Server** | Modbus TCP | **`5020`** | Servidor de registros Modbus (Holding/Input Registers) para adquisición de datos. |
| **Siemens S7 Server** | S7 (RFC1006) | **`1102`** | Servidor PLC simulado con área de datos DB1 activa para lectura de bloques. |
| **Dashboard & API** | HTTP (Web) | **`8085`** | Panel visual interactivo y API JSON para desarrollo rápido. |
---
## 🛠️ Requisitos e Instalación
El simulador requiere **Python 3.8+** y un conjunto de dependencias de comunicación industrial.
### 1. Entorno Virtual de Python (Recomendado)
Desde la raíz del proyecto, activa tu entorno virtual:
```bash
python -m venv venv
source venv/bin/activate # En Linux/WSL/macOS
# o: venv\Scripts\activate en Windows
```
### 2. Dependencias de Python
Instala las dependencias necesarias:
```bash
pip install pymodbus python-snap7
```
### 3. Requisito del Sistema para Siemens S7 (`snap7`)
La librería `python-snap7` es un wrapper de la librería nativa en C **Snap7**. Por lo tanto, el sistema operativo necesita el binario de Snap7 para poder instanciar el servidor S7:
#### En Linux (Ubuntu / WSL / Debian):
```bash
sudo add-apt-repository ppa:gijzelaar/snap7
sudo apt-get update
sudo apt-get install libsnap7-dev libsnap7-1
```
*Nota: Si estás ejecutando en Linux, el puerto S7 estándar es el 102. Como ese puerto es menor que 1024, requiere privilegios de superusuario (`sudo`). Para facilitar el desarrollo y evitar ejecutar como root, este simulador expone S7 en el puerto alternativo **`1102`**.*
#### En Windows o macOS:
Descarga el binario compilado de `snap7.dll` o `libsnap7.dylib` desde el [repositorio oficial de SourceForge de Snap7](https://sourceforge.net/projects/snap7/) y colócalo en el directorio del sistema (por ejemplo, `C:\Windows\System32`) o agrégalo a tus variables de entorno (`PATH` o `LD_LIBRARY_PATH`).
> [!TIP]
> **Resiliencia:** Si no tienes configurado `snap7` en tu sistema, el script unificado capturará el error de forma segura, informará de la falta del binario por consola, y **levantará exitosamente** los servidores Modbus TCP (5020) y HTTP Dashboard (8085) sin colapsar.
---
## 🚀 Ejecución del Simulador
Para iniciar todos los simuladores concurrentemente, ejecuta el script:
```bash
python tests/simulators/unified_simulator.py
```
### Comportamiento al iniciar:
1. Imprime el estado de carga de cada protocolo por consola.
2. Inicia el servidor HTTP en el puerto `8085`.
3. Cada **5 segundos** genera nuevos valores aleatorios realistas y actualiza la memoria interna de los servidores Modbus y S7.
4. Imprime los valores simulados en la terminal con un formato limpio.
---
## 📊 Mapeo y Estructura de Datos Simulados
Los valores cambian dinámicamente cada 5 segundos simulando condiciones industriales en tiempo real:
### 1. Modbus TCP (Puerto `5020`, ID de Esclavo por defecto: `0x00`)
* **Holding Registers (FC 3):**
* `HR1` [Registro `40001`]: Temperatura simulada. Rango: `500` a `9500`.
* `HR2` [Registro `40002`]: Presión simulada. Rango: `500` a `9500`.
* `HR3` [Registro `40003`]: Flujo simulado. Rango: `500` a `9500`.
* `HR4` [Registro `40004`]: Nivel simulado. Rango: `500` a `9500`.
* `HR5` [Registro `40005`]: Estado simulado. Rango: `500` a `9500`.
* **Input Registers (FC 4):**
* `IR1` [Registro `30001`]: Voltaje simulado. Rango: `1000` a `15000`.
* `IR2` [Registro `30002`]: Corriente simulada. Rango: `1000` a `15000`.
* `IR3` [Registro `30003`]: Frecuencia simulada. Rango: `1000` a `15000`.
### 2. Siemens S7 (Puerto `1102`, Rack 0, Slot 1)
* **Data Block 1 (DB1):**
* `DB1.DBW0` (Offset 0): Integer de 16 bits (Big Endian). Rango: `100` a `30000`.
* `DB1.DBW2` (Offset 2): Integer de 16 bits (Big Endian). Rango: `50` a `20000`.
### 3. API JSON HTTP (Puerto `8085`)
Puedes realizar una petición HTTP tipo `GET` a `http://localhost:8085/api/data` para recuperar el estado instantáneo de la simulación en formato JSON:
```json
{
"timestamp": "2026-06-23 09:30:15",
"modbus": {
"running": true,
"holding_registers": [4832, 1289, 7891, 5421, 3312],
"input_registers": [12015, 8431, 14201]
},
"s7": {
"running": true,
"db1_dbw0": 18451,
"db1_dbw2": 11342
},
"http": {
"running": true
}
}
```
---
## 🎨 Visualización en Navegador (Dashboard Web)
Abre tu navegador web favorito y dirígete a:
👉 **`http://localhost:8085`** o **`http://localhost:8085/dashboard`**
### Características del Dashboard:
- **Diseño Premium:** Estética oscura moderna con efectos de traslucidez (glassmorphism) y paleta de colores curada para el sector energético.
- **Gráficas en Vivo:** Tendencias dinámicas e interactivas representadas mediante *Chart.js* con actualizaciones continuas cada 5 segundos.
- **Micro-animaciones:** Los valores parpadean con un sutil destello de color cian/púrpura en el momento exacto en el que cambia la telemetría, y los indicadores de estado pulsan en verde cuando los servidores están activos.
- **Selectores de Señales:** Permite alternar la gráfica en tiempo real entre los Holding Registers, Input Registers o las variables S7 con un solo clic.

0
infrastructure/mosquitto/entrypoint.sh Normal file → Executable file
View File

0
infrastructure/nginx/entrypoint.sh Normal file → Executable file
View File

View File

@@ -4,6 +4,14 @@ 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';"; default "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self' wss:; frame-ancestors 'none';";
} }
# Access logs must not include query strings: URLs can carry short-lived tickets,
# invitation tokens, or other sensitive one-time values. Use $uri instead of
# $request to avoid persisting secrets in Nginx/Docker logs.
log_format main_no_query '$remote_addr - $remote_user [$time_local] '
'"$request_method $uri $server_protocol" $status $body_bytes_sent '
'"$http_referer" "$http_user_agent" "$http_x_forwarded_for"';
access_log /var/log/nginx/access.log main_no_query;
server { server {
listen 80; listen 80;
server_name omnioil.app www.omnioil.app localhost; server_name omnioil.app www.omnioil.app localhost;

View File

@@ -128,9 +128,9 @@ async function main() {
} }
await client.query( await client.query(
`INSERT INTO telemetry_raw (time, project_id, data) `INSERT INTO telemetry_raw (time, project_id, asset_id, data)
SELECT $1::timestamptz, id, $2::jsonb FROM edge_projects LIMIT 1`, VALUES ($1::timestamptz, $2::uuid, $3::uuid, $4::jsonb)`,
[time.toISOString(), JSON.stringify(data)] [time.toISOString(), projectId, assetId, JSON.stringify(data)]
) )
insertCount++ insertCount++
} }

10537
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

5
pnpm-workspace.yaml Normal file
View File

@@ -0,0 +1,5 @@
packages:
- "services/landing"
- "services/frontend-admin"
- "services/frontend-console"
- "services/frontend-pwa"

View File

@@ -5,6 +5,7 @@ edition = "2024"
[dependencies] [dependencies]
shared-lib = { workspace = true } shared-lib = { workspace = true }
json-generator = { path = "../json-generator" }
axum = { workspace = true, features = ["macros", "ws"] } axum = { workspace = true, features = ["macros", "ws"] }
tokio = { workspace = true } tokio = { workspace = true }
sqlx = { workspace = true } sqlx = { workspace = true }
@@ -18,6 +19,7 @@ dotenvy = { workspace = true }
jsonwebtoken = { version = "10.3.0", features = ["aws_lc_rs"] } jsonwebtoken = { version = "10.3.0", features = ["aws_lc_rs"] }
argon2 = { version = "0.5.3", features = ["password-hash", "rand"] } argon2 = { version = "0.5.3", features = ["password-hash", "rand"] }
chrono = { workspace = true } chrono = { workspace = true }
chrono-tz = { workspace = true }
axum-extra = { version = "0.12.5", features = ["typed-header"] } axum-extra = { version = "0.12.5", features = ["typed-header"] }
uuid = { workspace = true } uuid = { workspace = true }
anyhow = { workspace = true } anyhow = { workspace = true }
@@ -41,3 +43,4 @@ reqwest = { workspace = true, features = ["json", "rustls"] }
semver = { workspace = true } semver = { workspace = true }
axum-prometheus = "0.8" axum-prometheus = "0.8"
metrics = "0.24" metrics = "0.24"
csv = "1.4.0"

View File

@@ -6,7 +6,7 @@ WORKDIR /app
# Instala dependencias del sistema necesarias para compilar crates de C y linkeo rápido # Instala dependencias del sistema necesarias para compilar crates de C y linkeo rápido
# Nota: libatk1.0-dev y libgtk-3-dev se añaden para satisfacer dependencias transitivas de GUI si el workspace las tiene # Nota: libatk1.0-dev y libgtk-3-dev se añaden para satisfacer dependencias transitivas de GUI si el workspace las tiene
RUN apt-get update && apt-get install -y \ RUN apt-get update && apt-get install -y \
cmake nasm pkg-config libssl-dev libglib2.0-dev libatk1.0-dev libgtk-3-dev build-essential ca-certificates lld \ pkg-config libssl-dev build-essential ca-certificates lld \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
# Linker LLD para reducir el tiempo de linkeado significativamente # Linker LLD para reducir el tiempo de linkeado significativamente
ENV RUSTFLAGS="-C link-arg=-fuse-ld=lld" ENV RUSTFLAGS="-C link-arg=-fuse-ld=lld"

View File

@@ -0,0 +1,35 @@
-- Enforce one active access request per email, case-insensitively.
-- Rejected requests remain historical and allow a future retry.
-- Production may already contain duplicate active requests created before this
-- invariant existed. Keep the most advanced/latest request and close the rest
-- before creating the partial unique index, otherwise the deploy migration fails.
WITH ranked_active_requests AS (
SELECT
id,
ROW_NUMBER() OVER (
PARTITION BY lower(email)
ORDER BY
CASE status
WHEN 'approved' THEN 1
WHEN 'in_review' THEN 2
WHEN 'pending' THEN 3
ELSE 4
END,
created_at DESC,
id DESC
) AS duplicate_rank
FROM access_requests
WHERE status IN ('pending', 'in_review', 'approved')
)
UPDATE access_requests ar
SET status = 'rejected',
rejection_reason = 'duplicate_active_request_before_unique_index',
updated_at = NOW()
FROM ranked_active_requests ranked
WHERE ar.id = ranked.id
AND ranked.duplicate_rank > 1;
CREATE UNIQUE INDEX IF NOT EXISTS idx_access_requests_active_email_unique
ON access_requests (lower(email))
WHERE status IN ('pending', 'in_review', 'approved');

View File

@@ -0,0 +1,4 @@
-- Alter edge_devices check constraint to include new SCADA protocols
ALTER TABLE edge_devices DROP CONSTRAINT IF EXISTS edge_devices_protocol_check;
ALTER TABLE edge_devices ADD CONSTRAINT edge_devices_protocol_check
CHECK (protocol IN ('MODBUS_TCP', 'MODBUS_RTU', 'OPC_UA', 'MQTT', 'ETHERNET_IP', 'PROFINET', 'S7', 'MQTT_SPARKPLUG_B', 'SQL_DB'));

View File

@@ -0,0 +1,12 @@
-- =============================================================================
-- Migration: Add agent_download_tokens table for one-time script downloads
-- =============================================================================
CREATE TABLE IF NOT EXISTS agent_download_tokens (
token VARCHAR(100) PRIMARY KEY,
project_id UUID NOT NULL REFERENCES edge_projects(id) ON DELETE CASCADE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
expires_at TIMESTAMPTZ NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_agent_download_tokens_project ON agent_download_tokens(project_id);

View File

@@ -0,0 +1,8 @@
ALTER TABLE anh_reports
ADD COLUMN IF NOT EXISTS object_key TEXT,
ADD COLUMN IF NOT EXISTS sealed_at TIMESTAMPTZ,
ADD COLUMN IF NOT EXISTS content_type TEXT;
CREATE UNIQUE INDEX IF NOT EXISTS idx_anh_reports_object_key_unique
ON anh_reports (object_key)
WHERE object_key IS NOT NULL;

View File

@@ -0,0 +1,61 @@
CREATE TABLE IF NOT EXISTS anh_report_schedules (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
operator_name TEXT NOT NULL,
contract_number TEXT NOT NULL,
local_time TIME NOT NULL DEFAULT '06:00',
timezone TEXT NOT NULL DEFAULT 'America/Bogota',
is_active BOOLEAN NOT NULL DEFAULT true,
next_run_at TIMESTAMPTZ NOT NULL,
last_run_at TIMESTAMPTZ,
last_status TEXT,
last_error TEXT,
claim_token UUID,
claimed_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
created_by UUID REFERENCES users(id) ON DELETE SET NULL,
updated_by UUID REFERENCES users(id) ON DELETE SET NULL,
CONSTRAINT anh_report_schedules_unit_key UNIQUE (operator_name, contract_number),
CONSTRAINT anh_report_schedules_time_minute CHECK (date_trunc('minute', local_time) = local_time)
);
CREATE INDEX IF NOT EXISTS idx_anh_report_schedules_due
ON anh_report_schedules (next_run_at)
WHERE is_active = true;
CREATE INDEX IF NOT EXISTS idx_anh_report_schedules_claimed_at
ON anh_report_schedules (claimed_at)
WHERE claimed_at IS NOT NULL;
INSERT INTO anh_report_schedules (operator_name, contract_number, local_time, timezone, is_active, next_run_at)
SELECT DISTINCT
operator_name,
contract_number,
TIME '06:00',
'America/Bogota',
true,
CASE
WHEN (((NOW() AT TIME ZONE 'America/Bogota')::date + TIME '06:00') AT TIME ZONE 'America/Bogota') > NOW()
THEN (((NOW() AT TIME ZONE 'America/Bogota')::date + TIME '06:00') AT TIME ZONE 'America/Bogota')
ELSE ((((NOW() AT TIME ZONE 'America/Bogota')::date + 1) + TIME '06:00') AT TIME ZONE 'America/Bogota')
END
FROM edge_projects
WHERE is_active = true
AND operator_name IS NOT NULL
AND contract_number IS NOT NULL
ON CONFLICT (operator_name, contract_number) DO NOTHING;
ALTER TABLE anh_reports
ADD COLUMN IF NOT EXISTS schedule_id UUID REFERENCES anh_report_schedules(id) ON DELETE SET NULL,
ADD COLUMN IF NOT EXISTS operator_name TEXT,
ADD COLUMN IF NOT EXISTS contract_number TEXT,
ADD COLUMN IF NOT EXISTS generation_source TEXT NOT NULL DEFAULT 'manual',
ADD COLUMN IF NOT EXISTS approved_at TIMESTAMPTZ,
ADD COLUMN IF NOT EXISTS approved_by UUID REFERENCES users(id) ON DELETE SET NULL;
CREATE UNIQUE INDEX IF NOT EXISTS idx_anh_reports_generation_scope_unique
ON anh_reports (operator_name, contract_number, period_start, period_end)
WHERE operator_name IS NOT NULL
AND contract_number IS NOT NULL
AND period_start IS NOT NULL
AND period_end IS NOT NULL;

View File

@@ -0,0 +1,10 @@
-- Remove trial/placeholder ANH artifacts. These were never confirmed contracts
-- and must not appear as regulatory reports.
DELETE FROM anh_reports
WHERE upper(btrim(COALESCE(contract_number, content ->> 'CONTRATO', ''))) LIKE 'PENDING-%'
OR upper(btrim(COALESCE(contract_number, content ->> 'CONTRATO', ''))) LIKE 'TRIAL-%';
DELETE FROM anh_report_schedules
WHERE upper(btrim(contract_number)) LIKE 'PENDING-%'
OR upper(btrim(contract_number)) LIKE 'TRIAL-%';

View File

@@ -0,0 +1,48 @@
ALTER TABLE anh_reports
ADD COLUMN IF NOT EXISTS root_report_id UUID REFERENCES anh_reports(id) ON DELETE RESTRICT,
ADD COLUMN IF NOT EXISTS previous_report_id UUID REFERENCES anh_reports(id) ON DELETE RESTRICT,
ADD COLUMN IF NOT EXISTS superseded_by_report_id UUID REFERENCES anh_reports(id) ON DELETE SET NULL,
ADD COLUMN IF NOT EXISTS correction_version INT NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS rejection_reason TEXT,
ADD COLUMN IF NOT EXISTS rejected_at TIMESTAMPTZ,
ADD COLUMN IF NOT EXISTS rejected_by UUID REFERENCES users(id) ON DELETE SET NULL,
ADD COLUMN IF NOT EXISTS correction_reason TEXT,
ADD COLUMN IF NOT EXISTS correction_deadline_at TIMESTAMPTZ,
ADD COLUMN IF NOT EXISTS correction_created_at TIMESTAMPTZ,
ADD COLUMN IF NOT EXISTS correction_created_by UUID REFERENCES users(id) ON DELETE SET NULL,
ADD COLUMN IF NOT EXISTS available_for_anh_at TIMESTAMPTZ,
ADD COLUMN IF NOT EXISTS available_for_anh_by UUID REFERENCES users(id) ON DELETE SET NULL,
ADD COLUMN IF NOT EXISTS availability_note TEXT,
ADD COLUMN IF NOT EXISTS superseded_at TIMESTAMPTZ;
UPDATE anh_reports
SET root_report_id = id
WHERE root_report_id IS NULL;
ALTER TABLE anh_reports
ADD CONSTRAINT anh_reports_correction_version_non_negative
CHECK (correction_version >= 0),
ADD CONSTRAINT anh_reports_correction_reason_required
CHECK (correction_version = 0 OR NULLIF(BTRIM(correction_reason), '') IS NOT NULL),
ADD CONSTRAINT anh_reports_rejection_reason_required
CHECK ((rejected_at IS NULL AND rejection_reason IS NULL) OR (rejected_at IS NOT NULL AND NULLIF(BTRIM(rejection_reason), '') IS NOT NULL));
DROP INDEX IF EXISTS idx_anh_reports_generation_scope_unique;
CREATE UNIQUE INDEX IF NOT EXISTS idx_anh_reports_generation_scope_version_unique
ON anh_reports (operator_name, contract_number, period_start, period_end, correction_version)
WHERE operator_name IS NOT NULL
AND contract_number IS NOT NULL
AND period_start IS NOT NULL
AND period_end IS NOT NULL;
CREATE UNIQUE INDEX IF NOT EXISTS idx_anh_reports_active_draft_unique
ON anh_reports (operator_name, contract_number, period_start, period_end)
WHERE status IN ('GENERATED', 'CORRECTION_DRAFT')
AND operator_name IS NOT NULL
AND contract_number IS NOT NULL
AND period_start IS NOT NULL
AND period_end IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_anh_reports_root_version
ON anh_reports (root_report_id, correction_version);

View File

@@ -0,0 +1,10 @@
ALTER TABLE telemetry_alarms
ADD COLUMN IF NOT EXISTS acknowledged BOOLEAN,
ADD COLUMN IF NOT EXISTS acknowledged_at TIMESTAMPTZ,
ADD COLUMN IF NOT EXISTS acknowledged_by UUID;
ALTER TABLE telemetry_alarms
ALTER COLUMN acknowledged SET DEFAULT FALSE;
CREATE INDEX IF NOT EXISTS idx_telemetry_alarms_acknowledged
ON telemetry_alarms(project_id, acknowledged, timestamp DESC);

View File

@@ -0,0 +1,15 @@
-- =============================================================================
-- Migration: Short-lived one-time WebSocket telemetry tickets
-- =============================================================================
CREATE TABLE IF NOT EXISTS websocket_tickets (
token_hash TEXT PRIMARY KEY,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
project_id UUID NOT NULL REFERENCES edge_projects(id) ON DELETE CASCADE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
expires_at TIMESTAMPTZ NOT NULL,
used_at TIMESTAMPTZ NULL
);
CREATE INDEX IF NOT EXISTS idx_websocket_tickets_project ON websocket_tickets(project_id);
CREATE INDEX IF NOT EXISTS idx_websocket_tickets_expires_at ON websocket_tickets(expires_at);

View File

@@ -1,8 +1,11 @@
pub struct BuiltEmail { pub struct BuiltEmail {
pub subject: String, pub subject: String,
pub body: String, pub body: String,
pub html_body: Option<String>,
} }
const OMNIOIL_LOGO_URL: &str = "https://www.omnioil.app/omnioil-logo-transparente.svg";
pub fn activation_link(token: &str) -> String { pub fn activation_link(token: &str) -> String {
let base_url = std::env::var("INVITATION_BASE_URL") let base_url = std::env::var("INVITATION_BASE_URL")
.unwrap_or_else(|_| "https://app.omnioil.app".to_string()); .unwrap_or_else(|_| "https://app.omnioil.app".to_string());
@@ -13,18 +16,49 @@ pub fn activation_link(token: &str) -> String {
) )
} }
pub fn activation_email(full_name: &str, company: &str, token: &str) -> BuiltEmail { pub fn activation_email(full_name: &str, _company: &str, token: &str) -> BuiltEmail {
let url = activation_link(token); let url = activation_link(token);
BuiltEmail { BuiltEmail {
subject: "Activa tu cuenta OmniOil".to_string(), subject: "Tu acceso de prueba a OmniOil ya está disponible".to_string(),
body: format!( body: format!(
"Hola {full_name},\n\n\ "Hola {full_name},\n\n\
Aprobamos la solicitud de acceso para {company}.\n\n\ Gracias por tu interés en OmniOil. Tu acceso de prueba por 30 días ya fue aprobado.\n\n\
Activa tu cuenta y crea tu contraseña desde este enlace:\n\ Para comenzar, activa tu cuenta y crea una contraseña segura desde el siguiente enlace:\n\
{url}\n\n\ {url}\n\n\
El enlace vence en 7 días. Si no reconocés esta solicitud, podés ignorar este correo.\n\n\ Una vez activada la cuenta, podrás ingresar a la plataforma desde:\n\
https://app.omnioil.app/login\n\n\
Por seguridad, este enlace vence en 7 días. Si no solicitaste este acceso, puedes ignorar este mensaje o escribirnos a soporte@omnioil.app.\n\n\
Equipo OmniOil" Equipo OmniOil"
), ),
html_body: Some(format!(
r#"<!doctype html>
<html lang="es">
<body style="margin:0;background:#080808;padding:32px 16px;font-family:Arial,Helvetica,sans-serif;color:#f8fafc;">
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="max-width:640px;margin:0 auto;background:#0d1117;border:1px solid rgba(255,255,255,0.10);border-radius:24px;overflow:hidden;">
<tr>
<td style="padding:32px 32px 20px;text-align:left;background:linear-gradient(135deg,rgba(249,115,22,0.18),rgba(13,17,23,0));">
<img src="{OMNIOIL_LOGO_URL}" alt="OmniOil" width="132" style="display:block;max-width:132px;height:auto;margin-bottom:28px;" />
<p style="margin:0 0 10px;color:#fb923c;font-size:12px;font-weight:700;letter-spacing:0.18em;text-transform:uppercase;">Prueba gratuita aprobada</p>
<h1 style="margin:0;color:#ffffff;font-size:28px;line-height:1.2;font-weight:800;">Tu acceso a OmniOil ya está disponible</h1>
</td>
</tr>
<tr>
<td style="padding:8px 32px 32px;color:#cbd5e1;font-size:16px;line-height:1.7;">
<p style="margin:0 0 18px;">Hola {full_name},</p>
<p style="margin:0 0 18px;">Gracias por tu interés en OmniOil. Tu acceso de prueba por 30 días fue aprobado y ya podés activar tu cuenta.</p>
<p style="margin:0 0 28px;">Creá una contraseña segura desde el siguiente enlace:</p>
<p style="margin:0 0 28px;">
<a href="{url}" style="display:inline-block;background:#f97316;color:#111827;text-decoration:none;font-weight:800;padding:14px 22px;border-radius:14px;">Activar mi cuenta</a>
</p>
<p style="margin:0 0 18px;">Después de activar tu cuenta, ingresá a la plataforma desde <a href="https://app.omnioil.app/login" style="color:#fb923c;text-decoration:none;font-weight:700;">app.omnioil.app/login</a>.</p>
<p style="margin:0 0 18px;color:#94a3b8;font-size:14px;">Por seguridad, este enlace vence en 7 días. Si no solicitaste este acceso, podés ignorar este mensaje o escribirnos a <a href="mailto:soporte@omnioil.app" style="color:#fb923c;text-decoration:none;">soporte@omnioil.app</a>.</p>
<p style="margin:28px 0 0;color:#e5e7eb;font-weight:700;">Equipo OmniOil</p>
</td>
</tr>
</table>
</body>
</html>"#
)),
} }
} }
@@ -38,6 +72,33 @@ pub fn access_ready_email(full_name: &str, company: &str) -> BuiltEmail {
https://app.omnioil.app/login\n\n\ https://app.omnioil.app/login\n\n\
Equipo OmniOil" Equipo OmniOil"
), ),
html_body: None,
}
}
pub fn access_request_acknowledgement_email(full_name: &str) -> BuiltEmail {
BuiltEmail {
subject: "Recibimos tu solicitud de acceso OmniOil".to_string(),
body: format!(
"Hola {full_name},\n\n\
Recibimos tu solicitud de acceso a OmniOil. Nuestro equipo revisará la información y te responderá con la aceptación o negación en menos de 24 horas.\n\n\
Si necesitás agregar información, escribinos a soporte@omnioil.app.\n\n\
Equipo OmniOil"
),
html_body: None,
}
}
pub fn access_request_rejection_email(full_name: &str) -> BuiltEmail {
BuiltEmail {
subject: "Respuesta a tu solicitud de acceso OmniOil".to_string(),
body: format!(
"Hola {full_name},\n\n\
Revisamos tu solicitud de acceso a OmniOil y no podemos aprobarla en este momento.\n\n\
Si necesitás más información o querés actualizar los datos enviados, escribinos a soporte@omnioil.app.\n\n\
Equipo OmniOil"
),
html_body: None,
} }
} }
@@ -58,12 +119,16 @@ pub fn platform_operator_activation_email(full_name: &str, token: &str) -> Built
El enlace vence en 7 días. Si no reconocés esta invitación, podés ignorar este correo.\n\n\ El enlace vence en 7 días. Si no reconocés esta invitación, podés ignorar este correo.\n\n\
Equipo OmniOil" Equipo OmniOil"
), ),
html_body: None,
} }
} }
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::{activation_email, activation_link, platform_operator_activation_email}; use super::{
OMNIOIL_LOGO_URL, access_request_acknowledgement_email, access_request_rejection_email,
activation_email, activation_link, platform_operator_activation_email,
};
use std::sync::{Mutex, OnceLock}; use std::sync::{Mutex, OnceLock};
fn env_lock() -> std::sync::MutexGuard<'static, ()> { fn env_lock() -> std::sync::MutexGuard<'static, ()> {
@@ -100,6 +165,15 @@ mod tests {
let email = activation_email("Ada Lovelace", "Analytical Engines", "prod-token-456"); let email = activation_email("Ada Lovelace", "Analytical Engines", "prod-token-456");
assert!(email.body.contains("acceso de prueba por 30 días"));
assert!(email.body.contains("soporte@omnioil.app"));
assert!(
email
.html_body
.as_deref()
.is_some_and(|html| html.contains(OMNIOIL_LOGO_URL))
);
assert!(email.body.contains("https://app.omnioil.app/login"));
assert!( assert!(
email email
.body .body
@@ -125,4 +199,24 @@ mod tests {
); );
assert!(email.body.contains("http://localhost:3002/login")); assert!(email.body.contains("http://localhost:3002/login"));
} }
#[test]
fn acknowledgement_email_sets_customer_expectation_under_24_hours() {
let email = access_request_acknowledgement_email("Ana Lopez");
assert_eq!(email.subject, "Recibimos tu solicitud de acceso OmniOil");
assert!(email.body.contains("menos de 24 horas"));
assert!(email.body.contains("soporte@omnioil.app"));
}
#[test]
fn rejection_email_is_generic_and_omits_internal_reason() {
let email = access_request_rejection_email("Ana Lopez");
assert_eq!(email.subject, "Respuesta a tu solicitud de acceso OmniOil");
assert!(email.body.contains("no podemos aprobarla"));
assert!(!email.body.contains("fraude"));
assert!(!email.body.contains("riesgo"));
assert!(email.body.contains("soporte@omnioil.app"));
}
} }

View File

@@ -6,10 +6,9 @@ use axum::{
pub async fn add_version_header(request: Request<Body>, next: Next) -> Response<Body> { pub async fn add_version_header(request: Request<Body>, next: Next) -> Response<Body> {
let mut response = next.run(request).await; let mut response = next.run(request).await;
response.headers_mut().insert( response
"X-API-Version", .headers_mut()
axum::http::HeaderValue::from_static("1"), .insert("X-API-Version", axum::http::HeaderValue::from_static("1"));
);
response.headers_mut().insert( response.headers_mut().insert(
"X-Powered-By", "X-Powered-By",
axum::http::HeaderValue::from_static("OmniOil/1.0"), axum::http::HeaderValue::from_static("OmniOil/1.0"),

View File

@@ -388,6 +388,20 @@ pub fn role_satisfies_platform_admin_claims(role: &str) -> bool {
role == "platform_admin" role == "platform_admin"
} }
pub fn require_access_request_reviewer(claims: &Claims) -> Result<(), crate::errors::AppError> {
let email = claims.email.trim().to_ascii_lowercase();
if matches!(
email.as_str(),
"w.farfan@omnioil.app" | "h.ortegon@omnioil.app"
) {
return Ok(());
}
Err(crate::errors::AppError::Forbidden(
"No tienes permisos para gestionar solicitudes de acceso".to_string(),
))
}
impl<S> FromRequestParts<S> for PlatformAdminClaims impl<S> FromRequestParts<S> for PlatformAdminClaims
where where
sqlx::PgPool: axum::extract::FromRef<S>, sqlx::PgPool: axum::extract::FromRef<S>,

View File

@@ -22,7 +22,12 @@ pub async fn establish_connection() -> Result<DbPool, sqlx::Error> {
if retries >= max_retries { if retries >= max_retries {
return Err(e); return Err(e);
} }
tracing::warn!("⏳ Fallo conexión a DB (intento {}/{}): {}. Reintentando en 5s...", retries, max_retries, e); tracing::warn!(
"⏳ Fallo conexión a DB (intento {}/{}): {}. Reintentando en 5s...",
retries,
max_retries,
e
);
tokio::time::sleep(std::time::Duration::from_secs(5)).await; tokio::time::sleep(std::time::Duration::from_secs(5)).await;
} }
} }

View File

@@ -1,11 +1,12 @@
use axum::{ use axum::{
http::StatusCode, http::StatusCode,
response::{IntoResponse, Response, Json}, response::{IntoResponse, Json, Response},
}; };
use serde_json::json; use serde_json::json;
use tracing::error; use tracing::error;
use uuid::Uuid; use uuid::Uuid;
#[derive(Debug)]
pub enum AppError { pub enum AppError {
Database(sqlx::Error), Database(sqlx::Error),
Internal(anyhow::Error), Internal(anyhow::Error),
@@ -21,21 +22,23 @@ impl IntoResponse for AppError {
let (status, message) = match self { let (status, message) = match self {
AppError::Database(ref e) => { AppError::Database(ref e) => {
error!(correlation_id = %correlation_id, "Database Error: {:?}", e); error!(correlation_id = %correlation_id, "Database Error: {:?}", e);
(StatusCode::INTERNAL_SERVER_ERROR, "Error interno de base de datos".to_string()) (
StatusCode::INTERNAL_SERVER_ERROR,
"Error interno de base de datos".to_string(),
)
} }
AppError::Internal(ref e) => { AppError::Internal(ref e) => {
error!(correlation_id = %correlation_id, "Internal Error: {:?}", e); error!(correlation_id = %correlation_id, "Internal Error: {:?}", e);
(StatusCode::INTERNAL_SERVER_ERROR, "Error interno del servidor".to_string()) (
StatusCode::INTERNAL_SERVER_ERROR,
"Error interno del servidor".to_string(),
)
} }
AppError::NotFound(entity) => { AppError::NotFound(entity) => {
(StatusCode::NOT_FOUND, format!("{} no encontrado", entity)) (StatusCode::NOT_FOUND, format!("{} no encontrado", entity))
} }
AppError::Forbidden(msg) => { AppError::Forbidden(msg) => (StatusCode::FORBIDDEN, msg),
(StatusCode::FORBIDDEN, msg) AppError::BadRequest(msg) => (StatusCode::BAD_REQUEST, msg),
}
AppError::BadRequest(msg) => {
(StatusCode::BAD_REQUEST, msg)
}
}; };
( (

View File

@@ -38,6 +38,13 @@ struct CreatedInvitation {
token: String, token: String,
} }
#[cfg_attr(test, derive(Debug, PartialEq, Eq))]
struct InvitationEmailDeliveryUpdate {
status: &'static str,
error: Option<String>,
sent_at_should_be_set: Option<bool>,
}
struct ProjectDefaults { struct ProjectDefaults {
name: String, name: String,
client: String, client: String,
@@ -47,13 +54,6 @@ struct ProjectDefaults {
metadata: serde_json::Value, metadata: serde_json::Value,
} }
#[cfg_attr(test, derive(Debug, PartialEq, Eq))]
struct InvitationEmailDeliveryUpdate {
status: &'static str,
error: Option<String>,
sent_at_should_be_set: Option<bool>,
}
#[cfg_attr(test, derive(Debug, PartialEq, Eq))] #[cfg_attr(test, derive(Debug, PartialEq, Eq))]
enum ResendDeliveryKind { enum ResendDeliveryKind {
ActivationInvitation, ActivationInvitation,
@@ -108,6 +108,7 @@ pub struct AccessRequestRow {
pub updated_at: DateTime<Utc>, pub updated_at: DateTime<Utc>,
pub invitation_email_status: Option<String>, pub invitation_email_status: Option<String>,
pub invitation_email_sent_at: Option<DateTime<Utc>>, pub invitation_email_sent_at: Option<DateTime<Utc>>,
pub trial_ends_at: Option<DateTime<Utc>>,
pub invitation_expires_at: Option<DateTime<Utc>>, pub invitation_expires_at: Option<DateTime<Utc>>,
pub invitation_consumed_at: Option<DateTime<Utc>>, pub invitation_consumed_at: Option<DateTime<Utc>>,
pub invitation_revoked_at: Option<DateTime<Utc>>, pub invitation_revoked_at: Option<DateTime<Utc>>,
@@ -121,14 +122,22 @@ const ACCESS_REQUEST_SELECT: &str = r#"
ar.provisioned_project_id, ar.provisioned_project_id,
p.name AS provisioned_project_name, p.name AS provisioned_project_name,
ar.provisioned_at, ar.request_ip, ar.user_agent, ar.created_at, ar.updated_at, ar.provisioned_at, ar.request_ip, ar.user_agent, ar.created_at, ar.updated_at,
inv.email_status AS invitation_email_status, inv.email_status AS invitation_email_status,
inv.email_sent_at AS invitation_email_sent_at, inv.email_sent_at AS invitation_email_sent_at,
inv.expires_at AS invitation_expires_at, sub.trial_ends_at AS trial_ends_at,
inv.consumed_at AS invitation_consumed_at, inv.expires_at AS invitation_expires_at,
inv.revoked_at AS invitation_revoked_at inv.consumed_at AS invitation_consumed_at,
inv.revoked_at AS invitation_revoked_at
FROM access_requests ar FROM access_requests ar
LEFT JOIN users u ON u.id = ar.provisioned_user_id LEFT JOIN users u ON u.id = ar.provisioned_user_id
LEFT JOIN edge_projects p ON p.id = ar.provisioned_project_id LEFT JOIN edge_projects p ON p.id = ar.provisioned_project_id
LEFT JOIN LATERAL (
SELECT trial_ends_at
FROM subscriptions
WHERE project_id = ar.provisioned_project_id
ORDER BY created_at DESC
LIMIT 1
) sub ON true
LEFT JOIN LATERAL ( LEFT JOIN LATERAL (
SELECT email_status, email_sent_at, expires_at, consumed_at, revoked_at SELECT email_status, email_sent_at, expires_at, consumed_at, revoked_at
FROM access_request_invitations FROM access_request_invitations
@@ -313,8 +322,10 @@ async fn mark_invitation_email_failed(pool: &PgPool, invitation_id: Uuid, error:
.await; .await;
} }
fn invitation_email_delivery_update(result: Result<(), String>) -> InvitationEmailDeliveryUpdate { fn invitation_email_delivery_update(
match result { delivery_result: Result<(), String>,
) -> InvitationEmailDeliveryUpdate {
match delivery_result {
Ok(()) => InvitationEmailDeliveryUpdate { Ok(()) => InvitationEmailDeliveryUpdate {
status: "sent", status: "sent",
error: None, error: None,
@@ -388,6 +399,8 @@ pub async fn approve_access_request(
State(pool): State<PgPool>, State(pool): State<PgPool>,
Path(id): Path<Uuid>, Path(id): Path<Uuid>,
) -> Result<Json<AccessRequestRow>, AppError> { ) -> Result<Json<AccessRequestRow>, AppError> {
crate::auth::require_access_request_reviewer(&platform.0)?;
let platform_user_id = Uuid::parse_str(&platform.0.sub) let platform_user_id = Uuid::parse_str(&platform.0.sub)
.map_err(|_| AppError::BadRequest("ID de operador inválido".to_string()))?; .map_err(|_| AppError::BadRequest("ID de operador inválido".to_string()))?;
@@ -496,7 +509,7 @@ pub async fn approve_access_request(
sqlx::query( sqlx::query(
r#"INSERT INTO subscriptions (project_id, status, trial_ends_at, notes) r#"INSERT INTO subscriptions (project_id, status, trial_ends_at, notes)
VALUES ($1, 'trial', NOW() + INTERVAL '7 days', $2) VALUES ($1, 'trial', NOW() + INTERVAL '30 days', $2)
ON CONFLICT (project_id) DO NOTHING"#, ON CONFLICT (project_id) DO NOTHING"#,
) )
.bind(project_id) .bind(project_id)
@@ -562,6 +575,7 @@ pub async fn approve_access_request(
to: &request.email, to: &request.email,
subject: &email.subject, subject: &email.subject,
body: &email.body, body: &email.body,
html_body: email.html_body.as_deref(),
}) })
.await; .await;
@@ -663,12 +677,286 @@ pub async fn approve_access_request(
Ok(Json(row)) Ok(Json(row))
} }
pub async fn auto_approve_access_request(
pool: &PgPool,
id: Uuid,
headers: &axum::http::HeaderMap,
) -> Result<AccessRequestRow, AppError> {
let mut tx = pool.begin().await?;
let request = sqlx::query_as::<_, ApprovalRequestRow>(
r#"SELECT id, full_name, email, company, nit, position, phone, message, status
FROM access_requests
WHERE id = $1
FOR UPDATE"#,
)
.bind(id)
.fetch_optional(&mut *tx)
.await?
.ok_or_else(|| AppError::NotFound("Solicitud de acceso".to_string()))?;
if request.status != "pending" {
return Err(AppError::BadRequest(
"La solicitud no puede aprobarse automáticamente".to_string(),
));
}
let base_project_name = project_name_base(&request.company);
let project_name_exists: bool =
sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM edge_projects WHERE name = $1)")
.bind(&base_project_name)
.fetch_one(&mut *tx)
.await?;
let project_name = if project_name_exists {
format!("{}-{}", base_project_name, request_suffix(request.id))
} else {
base_project_name
};
let project_defaults = project_defaults(&request, &project_name);
let project_id: Uuid = sqlx::query_scalar(
r#"INSERT INTO edge_projects
(name, client, operator_name, contract_number, description, metadata)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING id"#,
)
.bind(&project_defaults.name)
.bind(&project_defaults.client)
.bind(&project_defaults.operator_name)
.bind(&project_defaults.contract_number)
.bind(&project_defaults.description)
.bind(project_defaults.metadata)
.fetch_one(&mut *tx)
.await?;
let existing_user = sqlx::query_as::<_, ExistingUserRow>(
r#"SELECT u.id, r.name AS role_name, u.is_active
FROM users u
JOIN roles r ON r.id = u.role_id
WHERE u.email = $1"#,
)
.bind(request.email.trim().to_lowercase())
.fetch_optional(&mut *tx)
.await?;
let (user_id, user_is_active, user_was_created) = if let Some(user) = existing_user {
if matches!(
user.role_name.as_str(),
"platform_admin" | "platform_operator" | "superadmin"
) {
return Err(AppError::BadRequest(
"El email pertenece a un usuario proveedor y no puede provisionarse como cliente"
.to_string(),
));
}
(user.id, user.is_active, false)
} else {
let admin_role_id: i32 = sqlx::query_scalar("SELECT id FROM roles WHERE name = 'admin'")
.fetch_optional(&mut *tx)
.await?
.ok_or_else(|| AppError::Internal(anyhow::anyhow!("Rol admin no encontrado")))?;
let unusable_password = crate::invitation_tokens::generate_activation_token();
let password_hash = crate::passwords::hash_password(&unusable_password)?;
let user_id: Uuid = sqlx::query_scalar(
r#"INSERT INTO users (email, full_name, password_hash, role_id, is_active)
VALUES ($1, $2, $3, $4, false)
RETURNING id"#,
)
.bind(request.email.trim().to_lowercase())
.bind(&request.full_name)
.bind(password_hash)
.bind(admin_role_id)
.fetch_one(&mut *tx)
.await?;
(user_id, false, true)
};
sqlx::query(
r#"INSERT INTO user_project_access (user_id, project_id, project_role, is_active)
VALUES ($1, $2, 'owner', true)
ON CONFLICT (user_id, project_id)
DO UPDATE SET project_role = 'owner', is_active = true, updated_at = NOW()"#,
)
.bind(user_id)
.bind(project_id)
.execute(&mut *tx)
.await?;
sqlx::query(
r#"INSERT INTO subscriptions (project_id, status, trial_ends_at, notes)
VALUES ($1, 'trial', NOW() + INTERVAL '30 days', $2)
ON CONFLICT (project_id) DO NOTHING"#,
)
.bind(project_id)
.bind(format!(
"Trial automático de 30 días creado desde solicitud de acceso {}",
request.id
))
.execute(&mut *tx)
.await?;
let invitation = if user_is_active {
None
} else {
let token = crate::invitation_tokens::generate_activation_token();
let token_hash = crate::invitation_tokens::hash_activation_token(&token);
let invitation_id: Uuid = sqlx::query_scalar(
r#"INSERT INTO access_request_invitations
(access_request_id, user_id, project_id, token_hash, expires_at, created_by)
VALUES ($1, $2, $3, $4, NOW() + INTERVAL '7 days', NULL)
RETURNING id"#,
)
.bind(request.id)
.bind(user_id)
.bind(project_id)
.bind(token_hash)
.fetch_one(&mut *tx)
.await?;
Some(CreatedInvitation {
id: invitation_id,
token,
})
};
sqlx::query(
r#"UPDATE access_requests
SET status = 'approved', reviewed_by = NULL, reviewed_at = NOW(),
rejection_reason = NULL, provisioned_user_id = $2,
provisioned_project_id = $3, provisioned_at = NOW(), updated_at = NOW()
WHERE id = $1"#,
)
.bind(request.id)
.bind(user_id)
.bind(project_id)
.execute(&mut *tx)
.await?;
tx.commit().await?;
let email = if let Some(invitation) = &invitation {
crate::access_request_emails::activation_email(
&request.full_name,
&request.company,
&invitation.token,
)
} else {
crate::access_request_emails::access_ready_email(&request.full_name, &request.company)
};
let email_result = crate::smtp_mailer::send_email(crate::smtp_mailer::EmailMessage {
to: &request.email,
subject: &email.subject,
body: &email.body,
html_body: email.html_body.as_deref(),
})
.await;
let invitation_id = invitation.as_ref().map(|invitation| invitation.id);
let email_audit_result = match (&invitation, email_result) {
(Some(invitation), Ok(())) => {
mark_invitation_email_sent(pool, invitation.id).await;
Ok(())
}
(Some(invitation), Err(err)) => {
let error = err.to_string();
mark_invitation_email_failed(pool, invitation.id, &error).await;
Err(error)
}
(None, Err(err)) => {
let error = err.to_string();
tracing::warn!(request_id = %request.id, error = %error, "Access-ready email failed");
Err(error)
}
(None, Ok(())) => Ok(()),
};
let row = fetch_access_request(pool, request.id).await?;
let metadata = provisioning_audit_metadata(
&request,
project_id,
user_id,
user_was_created,
invitation.is_some(),
);
for (action, metadata) in [
("access_request.auto_approve", metadata),
(
"access_request.project_provisioned",
serde_json::json!({ "project_id": project_id }),
),
(
if user_was_created {
"access_request.user_created"
} else {
"access_request.user_linked"
},
serde_json::json!({ "user_id": user_id, "email": &request.email }),
),
(
"access_request.trial_created",
serde_json::json!({ "project_id": project_id, "status": "trial", "trial_days": 30 }),
),
] {
crate::audit::log(
pool,
crate::audit::AuditEntry::new(action)
.with_headers(headers)
.with_resource(format!("access_request:{}", request.id))
.with_metadata(metadata),
)
.await;
}
if let Some(invitation_id) = invitation_id {
crate::audit::log(
pool,
crate::audit::AuditEntry::new("access_request.invitation_created")
.with_headers(headers)
.with_resource(format!("access_request:{}", request.id))
.with_metadata(serde_json::json!({
"invitation_id": invitation_id,
"project_id": project_id,
"user_id": user_id,
})),
)
.await;
}
crate::audit::log(
pool,
crate::audit::AuditEntry::new(if email_audit_result.is_ok() {
"access_request.email_sent"
} else {
"access_request.email_failed"
})
.with_headers(headers)
.with_resource(format!("access_request:{}", request.id))
.with_metadata(serde_json::json!({
"invitation_id": invitation_id,
"project_id": project_id,
"user_id": user_id,
"email": &request.email,
"error": email_audit_result.err(),
})),
)
.await;
Ok(row)
}
/// POST /api/platform/access-requests/:id/invitation/resend /// POST /api/platform/access-requests/:id/invitation/resend
pub async fn resend_access_request_invitation( pub async fn resend_access_request_invitation(
platform: PlatformAdminClaims, platform: PlatformAdminClaims,
State(pool): State<PgPool>, State(pool): State<PgPool>,
Path(id): Path<Uuid>, Path(id): Path<Uuid>,
) -> Result<Json<AccessRequestRow>, AppError> { ) -> Result<Json<AccessRequestRow>, AppError> {
crate::auth::require_access_request_reviewer(&platform.0)?;
let platform_user_id = Uuid::parse_str(&platform.0.sub) let platform_user_id = Uuid::parse_str(&platform.0.sub)
.map_err(|_| AppError::BadRequest("ID de operador inválido".to_string()))?; .map_err(|_| AppError::BadRequest("ID de operador inválido".to_string()))?;
@@ -751,6 +1039,7 @@ pub async fn resend_access_request_invitation(
to: &request.email, to: &request.email,
subject: &email.subject, subject: &email.subject,
body: &email.body, body: &email.body,
html_body: email.html_body.as_deref(),
}) })
.await; .await;
@@ -811,6 +1100,8 @@ pub async fn reject_access_request(
Path(id): Path<Uuid>, Path(id): Path<Uuid>,
Json(payload): Json<RejectAccessRequestPayload>, Json(payload): Json<RejectAccessRequestPayload>,
) -> Result<Json<AccessRequestRow>, AppError> { ) -> Result<Json<AccessRequestRow>, AppError> {
crate::auth::require_access_request_reviewer(&platform.0)?;
let reason = payload.reason.trim(); let reason = payload.reason.trim();
if reason.len() < 3 { if reason.len() < 3 {
@@ -838,6 +1129,20 @@ pub async fn reject_access_request(
let row = fetch_access_request(&pool, rejected_id).await?; let row = fetch_access_request(&pool, rejected_id).await?;
let rejection_email =
crate::access_request_emails::access_request_rejection_email(&row.full_name);
let rejection_email_result = crate::smtp_mailer::send_email(crate::smtp_mailer::EmailMessage {
to: &row.email,
subject: &rejection_email.subject,
body: &rejection_email.body,
html_body: rejection_email.html_body.as_deref(),
})
.await;
if let Err(error) = &rejection_email_result {
tracing::warn!(request_id = %row.id, error = %error, "Access request rejection email failed");
}
crate::audit::log( crate::audit::log(
&pool, &pool,
crate::audit::AuditEntry::new("access_request.reject") crate::audit::AuditEntry::new("access_request.reject")
@@ -848,6 +1153,7 @@ pub async fn reject_access_request(
"company": &row.company, "company": &row.company,
"nit": &row.nit, "nit": &row.nit,
"reason": reason, "reason": reason,
"rejection_email_status": if rejection_email_result.is_ok() { "sent" } else { "failed" },
})), })),
) )
.await; .await;
@@ -861,6 +1167,8 @@ pub async fn mark_access_request_in_review(
State(pool): State<PgPool>, State(pool): State<PgPool>,
Path(id): Path<Uuid>, Path(id): Path<Uuid>,
) -> Result<(StatusCode, Json<AccessRequestRow>), AppError> { ) -> Result<(StatusCode, Json<AccessRequestRow>), AppError> {
crate::auth::require_access_request_reviewer(&platform.0)?;
let platform_user_id = Uuid::parse_str(&platform.0.sub) let platform_user_id = Uuid::parse_str(&platform.0.sub)
.map_err(|_| AppError::BadRequest("ID de operador inválido".to_string()))?; .map_err(|_| AppError::BadRequest("ID de operador inválido".to_string()))?;
@@ -1041,6 +1349,22 @@ mod tests {
); );
} }
#[test]
fn access_request_select_exposes_trial_expiry_from_subscription_without_filtering_requests() {
assert!(
ACCESS_REQUEST_SELECT.contains("sub.trial_ends_at AS trial_ends_at"),
"access request responses should expose trial expiry from the provisioned project subscription"
);
assert!(
ACCESS_REQUEST_SELECT.contains("LEFT JOIN LATERAL"),
"subscription trial lookup must keep unprovisioned requests in list/detail responses"
);
assert!(
ACCESS_REQUEST_SELECT.contains("WHERE project_id = ar.provisioned_project_id"),
"subscription trial lookup should be joined through provisioned_project_id"
);
}
#[test] #[test]
fn project_defaults_follow_design_mapping_for_company_operator_and_contract() { fn project_defaults_follow_design_mapping_for_company_operator_and_contract() {
let request = approval_request_row(); let request = approval_request_row();

View File

@@ -1,9 +1,109 @@
use axum::{Json, extract::{Path, State}, http::StatusCode}; use crate::auth::{AdminClaims, Claims, role_satisfies_platform_claims};
use crate::errors::AppError;
use axum::{
Json,
extract::{Path, State},
http::StatusCode,
};
use serde::Deserialize;
use shared_lib::models::{AlertRule, CreateAlertRuleRequest};
use sqlx::PgPool; use sqlx::PgPool;
use uuid::Uuid; use uuid::Uuid;
use shared_lib::models::{AlertRule, CreateAlertRuleRequest};
use crate::auth::{Claims, AdminClaims}; #[derive(Deserialize)]
use crate::errors::AppError; pub struct AcknowledgeAlarmsRequest {
pub ids: Vec<Uuid>,
}
fn is_platform_global(claims: &Claims) -> bool {
role_satisfies_platform_claims(&claims.role)
}
fn alarm_select_sql(has_project: bool, platform_global: bool) -> &'static str {
match (has_project, platform_global) {
(true, true) => {
r#"SELECT row_to_json(t) FROM (
SELECT ta.id, ta.project_id, ta.asset_id, ta.variable_alias, ta.current_value,
ta.alarm_min, ta.alarm_max, ta.alarm_type, ta.message, COALESCE(ta.acknowledged, FALSE) AS acknowledged,
ta.acknowledged_at, ta.acknowledged_by, ta.timestamp
FROM telemetry_alarms ta
WHERE ta.project_id = $1
ORDER BY ta.timestamp DESC LIMIT $2
) t"#
}
(true, false) => {
r#"SELECT row_to_json(t) FROM (
SELECT ta.id, ta.project_id, ta.asset_id, ta.variable_alias, ta.current_value,
ta.alarm_min, ta.alarm_max, ta.alarm_type, ta.message, COALESCE(ta.acknowledged, FALSE) AS acknowledged,
ta.acknowledged_at, ta.acknowledged_by, ta.timestamp
FROM telemetry_alarms ta
JOIN user_project_access upa ON upa.project_id = ta.project_id
WHERE ta.project_id = $1
AND upa.user_id = $2
AND upa.is_active = TRUE
ORDER BY ta.timestamp DESC LIMIT $3
) t"#
}
(false, true) => {
r#"SELECT row_to_json(t) FROM (
SELECT ta.id, ta.project_id, ta.asset_id, ta.variable_alias, ta.current_value,
ta.alarm_min, ta.alarm_max, ta.alarm_type, ta.message, COALESCE(ta.acknowledged, FALSE) AS acknowledged,
ta.acknowledged_at, ta.acknowledged_by, ta.timestamp
FROM telemetry_alarms ta
ORDER BY ta.timestamp DESC LIMIT $1
) t"#
}
(false, false) => {
r#"SELECT row_to_json(t) FROM (
SELECT ta.id, ta.project_id, ta.asset_id, ta.variable_alias, ta.current_value,
ta.alarm_min, ta.alarm_max, ta.alarm_type, ta.message, COALESCE(ta.acknowledged, FALSE) AS acknowledged,
ta.acknowledged_at, ta.acknowledged_by, ta.timestamp
FROM telemetry_alarms ta
JOIN user_project_access upa ON upa.project_id = ta.project_id
WHERE upa.user_id = $1
AND upa.is_active = TRUE
ORDER BY ta.timestamp DESC LIMIT $2
) t"#
}
}
}
fn alarm_ack_scope_clause(platform_global: bool) -> &'static str {
if platform_global {
""
} else {
r#"AND EXISTS (
SELECT 1
FROM user_project_access upa
WHERE upa.user_id = $2
AND upa.project_id = ta.project_id
AND upa.is_active = TRUE
)"#
}
}
fn acknowledge_alarm_sql(platform_global: bool, bulk: bool) -> String {
let id_predicate = if bulk {
"ta.id = ANY($1)"
} else {
"ta.id = $1"
};
format!(
r#"WITH updated AS (
UPDATE telemetry_alarms ta
SET acknowledged = TRUE,
acknowledged_at = COALESCE(ta.acknowledged_at, NOW()),
acknowledged_by = COALESCE(ta.acknowledged_by, $2)
WHERE {id_predicate}
{scope_clause}
RETURNING ta.id, ta.project_id, ta.asset_id, ta.variable_alias, ta.current_value,
ta.alarm_min, ta.alarm_max, ta.alarm_type, ta.message, ta.acknowledged,
ta.acknowledged_at, ta.acknowledged_by, ta.timestamp
)
SELECT row_to_json(updated) FROM updated"#,
scope_clause = alarm_ack_scope_clause(platform_global)
)
}
/// GET /api/alert-rules?project_id=xxx /// GET /api/alert-rules?project_id=xxx
pub async fn list_alert_rules( pub async fn list_alert_rules(
@@ -13,25 +113,36 @@ pub async fn list_alert_rules(
) -> Result<Json<Vec<AlertRule>>, AppError> { ) -> Result<Json<Vec<AlertRule>>, AppError> {
let user_id = Uuid::parse_str(&claims.sub) let user_id = Uuid::parse_str(&claims.sub)
.map_err(|_| AppError::BadRequest("Invalid user ID in token".to_string()))?; .map_err(|_| AppError::BadRequest("Invalid user ID in token".to_string()))?;
let project_id = params.get("project_id") let project_id = params
.get("project_id")
.and_then(|s| Uuid::parse_str(s).ok()); .and_then(|s| Uuid::parse_str(s).ok());
let rules = if claims.role == "admin" || claims.role == "superadmin" { let rules = if claims.role == "admin" || claims.role == "superadmin" {
match project_id { match project_id {
Some(pid) => sqlx::query_as::<_, AlertRule>( Some(pid) => {
"SELECT * FROM alert_rules WHERE project_id = $1 ORDER BY created_at DESC" sqlx::query_as::<_, AlertRule>(
).bind(pid).fetch_all(&pool).await?, "SELECT * FROM alert_rules WHERE project_id = $1 ORDER BY created_at DESC",
None => sqlx::query_as::<_, AlertRule>( )
"SELECT * FROM alert_rules ORDER BY created_at DESC" .bind(pid)
).fetch_all(&pool).await?, .fetch_all(&pool)
.await?
}
None => {
sqlx::query_as::<_, AlertRule>("SELECT * FROM alert_rules ORDER BY created_at DESC")
.fetch_all(&pool)
.await?
}
} }
} else { } else {
sqlx::query_as::<_, AlertRule>( sqlx::query_as::<_, AlertRule>(
r#"SELECT ar.* FROM alert_rules ar r#"SELECT ar.* FROM alert_rules ar
JOIN user_project_access upa ON ar.project_id = upa.project_id JOIN user_project_access upa ON ar.project_id = upa.project_id
WHERE upa.user_id = $1 AND upa.is_active = true WHERE upa.user_id = $1 AND upa.is_active = true
ORDER BY ar.created_at DESC"# ORDER BY ar.created_at DESC"#,
).bind(user_id).fetch_all(&pool).await? )
.bind(user_id)
.fetch_all(&pool)
.await?
}; };
Ok(Json(rules)) Ok(Json(rules))
@@ -130,46 +241,194 @@ pub async fn list_alarms(
claims: Claims, claims: Claims,
axum::extract::Query(params): axum::extract::Query<std::collections::HashMap<String, String>>, axum::extract::Query(params): axum::extract::Query<std::collections::HashMap<String, String>>,
) -> Result<Json<Vec<serde_json::Value>>, AppError> { ) -> Result<Json<Vec<serde_json::Value>>, AppError> {
let limit: i64 = params.get("limit").and_then(|s| s.parse().ok()).unwrap_or(50).min(500); let limit: i64 = params
let project_id = params.get("project_id").and_then(|s| Uuid::parse_str(s).ok()); .get("limit")
.and_then(|s| s.parse().ok())
.unwrap_or(50)
.min(500);
let project_id = params
.get("project_id")
.and_then(|s| Uuid::parse_str(s).ok());
let user_id = Uuid::parse_str(&claims.sub)
.map_err(|_| AppError::BadRequest("Invalid user ID in token".to_string()))?;
let platform_global = is_platform_global(&claims);
let alarms = match project_id { let alarms = match project_id {
Some(pid) if platform_global => {
sqlx::query_scalar::<_, serde_json::Value>(alarm_select_sql(true, platform_global))
.bind(pid)
.bind(limit)
.fetch_all(&pool)
.await?
}
Some(pid) => { Some(pid) => {
sqlx::query_scalar::<_, serde_json::Value>( sqlx::query_scalar::<_, serde_json::Value>(alarm_select_sql(true, platform_global))
r#"SELECT row_to_json(t) FROM ( .bind(pid)
SELECT id, project_id, asset_id, variable_alias, current_value, .bind(user_id)
alarm_min, alarm_max, alarm_type, message, timestamp .bind(limit)
FROM telemetry_alarms .fetch_all(&pool)
WHERE project_id = $1 .await?
ORDER BY timestamp DESC LIMIT $2 }
) t"# None if platform_global => {
).bind(pid).bind(limit).fetch_all(&pool).await? sqlx::query_scalar::<_, serde_json::Value>(alarm_select_sql(false, platform_global))
}, .bind(limit)
None if claims.role == "admin" || claims.role == "superadmin" => { .fetch_all(&pool)
sqlx::query_scalar::<_, serde_json::Value>( .await?
r#"SELECT row_to_json(t) FROM ( }
SELECT id, project_id, asset_id, variable_alias, current_value,
alarm_min, alarm_max, alarm_type, message, timestamp
FROM telemetry_alarms
ORDER BY timestamp DESC LIMIT $1
) t"#
).bind(limit).fetch_all(&pool).await?
},
None => { None => {
let user_id = Uuid::parse_str(&claims.sub) sqlx::query_scalar::<_, serde_json::Value>(alarm_select_sql(false, platform_global))
.map_err(|_| AppError::BadRequest("Invalid user ID in token".to_string()))?; .bind(user_id)
sqlx::query_scalar::<_, serde_json::Value>( .bind(limit)
r#"SELECT row_to_json(t) FROM ( .fetch_all(&pool)
SELECT ta.id, ta.project_id, ta.asset_id, ta.variable_alias, ta.current_value, .await?
ta.alarm_min, ta.alarm_max, ta.alarm_type, ta.message, ta.timestamp
FROM telemetry_alarms ta
JOIN user_project_access upa ON ta.project_id = upa.project_id
WHERE upa.user_id = $1 AND upa.is_active = true
ORDER BY ta.timestamp DESC LIMIT $2
) t"#
).bind(user_id).bind(limit).fetch_all(&pool).await?
} }
}; };
Ok(Json(alarms)) Ok(Json(alarms))
} }
/// PATCH /api/alarms/:id/acknowledge — marcar una alarma como reconocida
pub async fn acknowledge_alarm(
State(pool): State<PgPool>,
claims: Claims,
Path(id): Path<Uuid>,
) -> Result<Json<serde_json::Value>, AppError> {
let user_id = Uuid::parse_str(&claims.sub)
.map_err(|_| AppError::BadRequest("Invalid user ID in token".to_string()))?;
let platform_global = is_platform_global(&claims);
let alarm =
sqlx::query_scalar::<_, serde_json::Value>(&acknowledge_alarm_sql(platform_global, false))
.bind(id)
.bind(user_id)
.fetch_optional(&pool)
.await?
.ok_or(AppError::NotFound("Alarma".to_string()))?;
Ok(Json(alarm))
}
/// PATCH /api/alarms/acknowledge — marcar múltiples alarmas como reconocidas
pub async fn acknowledge_alarms(
State(pool): State<PgPool>,
claims: Claims,
Json(req): Json<AcknowledgeAlarmsRequest>,
) -> Result<Json<Vec<serde_json::Value>>, AppError> {
if req.ids.is_empty() {
return Err(AppError::BadRequest(
"Debe enviar al menos una alarma".to_string(),
));
}
let user_id = Uuid::parse_str(&claims.sub)
.map_err(|_| AppError::BadRequest("Invalid user ID in token".to_string()))?;
let platform_global = is_platform_global(&claims);
let alarms =
sqlx::query_scalar::<_, serde_json::Value>(&acknowledge_alarm_sql(platform_global, true))
.bind(&req.ids)
.bind(user_id)
.fetch_all(&pool)
.await?;
if alarms.is_empty() {
return Err(AppError::NotFound("Alarmas".to_string()));
}
Ok(Json(alarms))
}
#[cfg(test)]
mod tests {
use super::{
acknowledge_alarm_sql, alarm_ack_scope_clause, alarm_select_sql, is_platform_global,
};
use crate::auth::Claims;
use uuid::Uuid;
fn claims(role: &str) -> Claims {
Claims {
sub: Uuid::new_v4().to_string(),
role: role.to_string(),
email: format!("{role}@example.com"),
projects: Vec::new(),
exp: 0,
}
}
#[test]
fn customer_alarm_list_without_project_is_scoped_by_project_access() {
let sql = alarm_select_sql(false, false);
assert!(sql.contains("JOIN user_project_access upa"));
assert!(sql.contains("upa.user_id = $1"));
assert!(sql.contains("upa.project_id = ta.project_id"));
assert!(sql.contains("upa.is_active = TRUE"));
}
#[test]
fn platform_alarm_list_without_project_omits_project_access_scope() {
let sql = alarm_select_sql(false, true);
assert!(!sql.contains("user_project_access"));
assert!(sql.contains("FROM telemetry_alarms"));
assert!(sql.contains("LIMIT $1"));
}
#[test]
fn customer_alarm_list_for_project_is_scoped_by_project_access() {
let sql = alarm_select_sql(true, false);
assert!(sql.contains("JOIN user_project_access upa"));
assert!(sql.contains("ta.project_id = $1"));
assert!(sql.contains("upa.user_id = $2"));
assert!(sql.contains("LIMIT $3"));
}
#[test]
fn platform_alarm_list_for_project_omits_project_access_scope() {
let sql = alarm_select_sql(true, true);
assert!(!sql.contains("user_project_access"));
assert!(sql.contains("ta.project_id = $1"));
assert!(sql.contains("LIMIT $2"));
}
#[test]
fn customer_ack_scope_requires_project_access() {
let clause = alarm_ack_scope_clause(false);
assert!(clause.contains("EXISTS"));
assert!(clause.contains("FROM user_project_access upa"));
assert!(clause.contains("upa.user_id = $2"));
assert!(clause.contains("upa.project_id = ta.project_id"));
assert!(clause.contains("upa.is_active = TRUE"));
}
#[test]
fn platform_ack_scope_does_not_require_project_access() {
let clause = alarm_ack_scope_clause(true);
assert!(!clause.contains("user_project_access"));
assert!(clause.trim().is_empty());
}
#[test]
fn customer_single_and_bulk_ack_sql_include_project_access_scope() {
let single_sql = acknowledge_alarm_sql(false, false);
let bulk_sql = acknowledge_alarm_sql(false, true);
assert!(single_sql.contains("WHERE ta.id = $1"));
assert!(single_sql.contains("FROM user_project_access upa"));
assert!(bulk_sql.contains("WHERE ta.id = ANY($1)"));
assert!(bulk_sql.contains("FROM user_project_access upa"));
}
#[test]
fn only_platform_roles_are_platform_global_for_alarms() {
assert!(is_platform_global(&claims("platform_admin")));
assert!(is_platform_global(&claims("platform_operator")));
assert!(!is_platform_global(&claims("admin")));
assert!(!is_platform_global(&claims("superadmin")));
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
use serde_json::Value;
use sqlx::PgPool; use sqlx::PgPool;
use uuid::Uuid; use uuid::Uuid;
use serde_json::Value;
pub async fn log_audit( pub async fn log_audit(
pool: &PgPool, pool: &PgPool,

View File

@@ -44,6 +44,23 @@ fn extract_refresh_cookie(headers: &HeaderMap) -> Option<String> {
}) })
} }
fn is_active_access_request_unique_violation(error: &sqlx::Error) -> bool {
error
.as_database_error()
.and_then(|database_error| database_error.constraint())
== Some("idx_access_requests_active_email_unique")
}
fn duplicate_access_request_response() -> axum::response::Response {
(
StatusCode::CONFLICT,
Json(json!({
"error": "Ya existe una solicitud activa para este email"
})),
)
.into_response()
}
#[derive(Deserialize, validator::Validate)] #[derive(Deserialize, validator::Validate)]
pub struct LoginPayload { pub struct LoginPayload {
#[validate(email(message = "Email inválido"))] #[validate(email(message = "Email inválido"))]
@@ -60,16 +77,8 @@ pub struct RequestAccessPayload {
full_name: String, full_name: String,
#[validate(email)] #[validate(email)]
email: String, email: String,
#[validate(length(min = 2, max = 200))] #[validate(length(max = 50))]
company: String, whatsapp: Option<String>,
#[validate(length(min = 3, max = 30))]
nit: String,
#[validate(length(min = 2, max = 150))]
position: String,
#[validate(length(min = 7, max = 50))]
phone: String,
#[validate(length(max = 2000))]
message: Option<String>,
#[validate(length(min = 1))] #[validate(length(min = 1))]
turnstile_token: String, turnstile_token: String,
} }
@@ -359,21 +368,6 @@ pub async fn login(
Err(AuthError::InvalidCredentials) Err(AuthError::InvalidCredentials)
} }
fn is_valid_nit(nit: &str) -> bool {
let mut parts = nit.split('-');
let Some(number) = parts.next() else {
return false;
};
let Some(check_digit) = parts.next() else {
return false;
};
parts.next().is_none()
&& (9..=10).contains(&number.len())
&& number.chars().all(|c| c.is_ascii_digit())
&& check_digit.len() == 1
&& check_digit.chars().all(|c| c.is_ascii_digit())
}
async fn verify_turnstile(token: &str, headers: &HeaderMap) -> Result<(), AuthError> { async fn verify_turnstile(token: &str, headers: &HeaderMap) -> Result<(), AuthError> {
let secret = std::env::var("TURNSTILE_SECRET_KEY") let secret = std::env::var("TURNSTILE_SECRET_KEY")
.map_err(|_| AuthError::Internal(anyhow::anyhow!("TURNSTILE_SECRET_KEY must be set")))?; .map_err(|_| AuthError::Internal(anyhow::anyhow!("TURNSTILE_SECRET_KEY must be set")))?;
@@ -412,6 +406,21 @@ async fn verify_turnstile(token: &str, headers: &HeaderMap) -> Result<(), AuthEr
} }
} }
fn is_valid_nit(nit: &str) -> bool {
let mut parts = nit.split('-');
let Some(number) = parts.next() else {
return false;
};
let Some(check_digit) = parts.next() else {
return false;
};
parts.next().is_none()
&& (9..=10).contains(&number.len())
&& number.chars().all(|c| c.is_ascii_digit())
&& check_digit.len() == 1
&& check_digit.chars().all(|c| c.is_ascii_digit())
}
pub async fn request_access( pub async fn request_access(
State(pool): State<DbPool>, State(pool): State<DbPool>,
headers: HeaderMap, headers: HeaderMap,
@@ -419,14 +428,6 @@ pub async fn request_access(
RequestAccessPayload, RequestAccessPayload,
>, >,
) -> Result<impl IntoResponse, AuthError> { ) -> Result<impl IntoResponse, AuthError> {
if !is_valid_nit(&payload.nit) {
return Ok((
StatusCode::UNPROCESSABLE_ENTITY,
Json(json!({ "error": "Formato NIT inválido" })),
)
.into_response());
}
verify_turnstile(&payload.turnstile_token, &headers).await?; verify_turnstile(&payload.turnstile_token, &headers).await?;
let request_ip = headers let request_ip = headers
@@ -444,51 +445,114 @@ pub async fn request_access(
.get("User-Agent") .get("User-Agent")
.and_then(|v| v.to_str().ok()) .and_then(|v| v.to_str().ok())
.map(str::to_string); .map(str::to_string);
let email = payload.email.trim().to_ascii_lowercase();
sqlx::query( let has_active_request: bool = sqlx::query_scalar(
r#"INSERT INTO access_requests r#"SELECT EXISTS(
(full_name, email, company, nit, position, phone, message, request_ip, user_agent) SELECT 1
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)"#, FROM access_requests
WHERE lower(email) = $1
AND status IN ('pending', 'in_review', 'approved')
)"#,
) )
.bind(payload.full_name.trim()) .bind(&email)
.bind(payload.email.trim().to_lowercase()) .fetch_one(&pool)
.bind(payload.company.trim())
.bind(payload.nit.trim())
.bind(payload.position.trim())
.bind(payload.phone.trim())
.bind(
payload
.message
.as_deref()
.map(str::trim)
.filter(|s| !s.is_empty()),
)
.bind(&request_ip)
.bind(&user_agent)
.execute(&pool)
.await .await
.map_err(|e| { .map_err(|e| {
tracing::error!("Access request insert error: {}", e); tracing::error!("Access request duplicate lookup error: {}", e);
AuthError::Internal(anyhow::anyhow!("Error al registrar solicitud")) AuthError::Internal(anyhow::anyhow!("Error al validar solicitud"))
})?; })?;
if has_active_request {
return Ok(duplicate_access_request_response());
}
let request_id = sqlx::query_scalar(
r#"INSERT INTO access_requests
(full_name, email, company, nit, position, phone, message, request_ip, user_agent)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
RETURNING id"#,
)
.bind(payload.full_name.trim())
.bind(&email)
.bind(payload.full_name.trim())
.bind("PENDING")
.bind("Trial lead")
.bind(
payload
.whatsapp
.as_deref()
.map(str::trim)
.filter(|s| !s.is_empty())
.unwrap_or(""),
)
.bind(Option::<&str>::None)
.bind(&request_ip)
.bind(&user_agent)
.fetch_one(&pool)
.await;
let request_id = match request_id {
Ok(request_id) => request_id,
Err(error) => {
if is_active_access_request_unique_violation(&error) {
return Ok(duplicate_access_request_response());
}
tracing::error!("Access request insert error: {}", error);
return Err(AuthError::Internal(anyhow::anyhow!(
"Error al registrar solicitud"
)));
}
};
crate::audit::log( crate::audit::log(
&pool, &pool,
crate::audit::AuditEntry::new("access_request.create") crate::audit::AuditEntry::new("access_request.create")
.with_headers(&headers) .with_headers(&headers)
.with_metadata(json!({ .with_metadata(json!({
"email": payload.email, "email": &payload.email,
"company": payload.company, "whatsapp_provided": payload.whatsapp.as_deref().is_some_and(|value| !value.trim().is_empty()),
"nit": payload.nit,
})), })),
) )
.await; .await;
let approved_request = if let Ok(row) =
crate::handlers::access_requests::auto_approve_access_request(&pool, request_id, &headers)
.await
{
row
} else {
let _ = sqlx::query(
r#"UPDATE access_requests
SET status = 'rejected', rejection_reason = 'auto_approval_failed', updated_at = NOW()
WHERE id = $1 AND status = 'pending'"#,
)
.bind(request_id)
.execute(&pool)
.await;
tracing::error!(request_id = %request_id, "Access request auto-approval failed");
return Err(AuthError::Internal(anyhow::anyhow!(
"Error al aprobar la prueba gratuita"
)));
};
if approved_request.invitation_email_status.as_deref() == Some("failed") {
return Ok((
StatusCode::ACCEPTED,
Json(AccessRequestResponse {
status: "approved_email_failed",
message: "Tu prueba gratuita fue aprobada, pero no pudimos enviar el correo de activación. Escríbenos a soporte@omnioil.app.",
}),
)
.into_response());
}
Ok(( Ok((
StatusCode::ACCEPTED, StatusCode::CREATED,
Json(AccessRequestResponse { Json(AccessRequestResponse {
status: "pending", status: "approved",
message: "Solicitud recibida. Nuestro equipo revisará la información.", message: "Tu prueba gratuita fue aprobada. Revisa tu correo para activar la cuenta.",
}), }),
) )
.into_response()) .into_response())
@@ -660,11 +724,242 @@ fn hash_password(password: &str) -> Result<String, AuthError> {
.map_err(|e| AuthError::Internal(anyhow::anyhow!("Hash error: {}", e))) .map_err(|e| AuthError::Internal(anyhow::anyhow!("Hash error: {}", e)))
} }
#[derive(Deserialize, Validate)]
pub struct PublicRegisterPayload {
#[validate(email(message = "Email inválido"))]
pub email: String,
#[validate(length(min = 6, message = "Contraseña debe tener al menos 6 caracteres"))]
pub password: String,
#[validate(length(min = 3, message = "Nombre completo inválido"))]
pub full_name: String,
#[validate(length(min = 2, message = "Nombre de la empresa inválido"))]
pub company: String,
#[validate(length(min = 3, max = 30))]
pub nit: String,
}
pub async fn register( pub async fn register(
State(_pool): State<DbPool>, State(state): State<crate::AppState>,
Json(_payload): Json<SetupRegisterPayload>, headers: HeaderMap,
) -> Result<Json<serde_json::Value>, AuthError> { crate::validation::ValidatedJson(payload): crate::validation::ValidatedJson<
Err(AuthError::RegistrationDisabled) PublicRegisterPayload,
>,
) -> Result<impl IntoResponse, AuthError> {
if !is_valid_nit(&payload.nit) {
return Ok((
StatusCode::UNPROCESSABLE_ENTITY,
Json(json!({ "error": "Formato NIT inválido" })),
)
.into_response());
}
let password_hash = hash_password(&payload.password)?;
let mut tx =
state.pool.begin().await.map_err(|e| {
AuthError::Internal(anyhow::anyhow!("Error al iniciar transacción: {}", e))
})?;
let user_exists: bool =
sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM users WHERE email = $1)")
.bind(payload.email.trim().to_lowercase())
.fetch_one(&mut *tx)
.await
.map_err(|e| {
AuthError::Internal(anyhow::anyhow!(
"Error al verificar existencia de usuario: {}",
e
))
})?;
if user_exists {
return Ok((
StatusCode::CONFLICT,
Json(json!({ "error": "El correo electrónico ya está registrado" })),
)
.into_response());
}
let admin_role_id: i32 = sqlx::query_scalar("SELECT id FROM roles WHERE name = 'admin'")
.fetch_optional(&mut *tx)
.await
.map_err(|e| AuthError::Internal(anyhow::anyhow!("Error al buscar rol: {}", e)))?
.ok_or_else(|| AuthError::Internal(anyhow::anyhow!("Rol admin no encontrado")))?;
let user_id: uuid::Uuid = sqlx::query_scalar(
"INSERT INTO users (email, password_hash, full_name, role_id, is_active) VALUES ($1, $2, $3, $4, true) RETURNING id",
)
.bind(payload.email.trim().to_lowercase())
.bind(&password_hash)
.bind(payload.full_name.trim())
.bind(admin_role_id)
.fetch_one(&mut *tx)
.await
.map_err(|e| AuthError::Internal(anyhow::anyhow!("Error al crear usuario: {}", e)))?;
let base_project_name = {
let sanitized: String = payload
.company
.chars()
.map(|ch| {
if ch.is_ascii_alphanumeric() || ch == ' ' || ch == '-' || ch == '_' {
ch
} else {
' '
}
})
.collect();
let collapsed = sanitized.split_whitespace().collect::<Vec<_>>().join(" ");
let base = if collapsed.is_empty() {
"Cliente OmniOil"
} else {
collapsed.as_str()
};
base.chars().take(80).collect::<String>()
};
let project_name_exists: bool =
sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM edge_projects WHERE name = $1)")
.bind(&base_project_name)
.fetch_one(&mut *tx)
.await
.map_err(|e| {
AuthError::Internal(anyhow::anyhow!(
"Error al verificar existencia de proyecto: {}",
e
))
})?;
let project_name = if project_name_exists {
format!(
"{}-{}",
base_project_name,
&uuid::Uuid::new_v4().to_string()[..8]
)
} else {
base_project_name
};
let project_id: uuid::Uuid = sqlx::query_scalar(
r#"INSERT INTO edge_projects
(name, client, operator_name, contract_number, description, metadata)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING id"#,
)
.bind(&project_name)
.bind(&payload.company)
.bind(&payload.company)
.bind(format!("TRIAL-{}", payload.nit.trim()))
.bind(format!(
"Proyecto trial para {} (NIT {})",
payload.company, payload.nit
))
.bind(serde_json::json!({ "auto_onboarded": true, "company_nit": payload.nit }))
.fetch_one(&mut *tx)
.await
.map_err(|e| AuthError::Internal(anyhow::anyhow!("Error al insertar proyecto: {}", e)))?;
sqlx::query(
r#"INSERT INTO user_project_access (user_id, project_id, project_role, is_active)
VALUES ($1, $2, 'owner', true)"#,
)
.bind(user_id)
.bind(project_id)
.execute(&mut *tx)
.await
.map_err(|e| {
AuthError::Internal(anyhow::anyhow!(
"Error al asociar usuario a proyecto: {}",
e
))
})?;
sqlx::query(
r#"INSERT INTO subscriptions (project_id, status, trial_ends_at, notes)
VALUES ($1, 'trial', NOW() + INTERVAL '30 days', $2)"#,
)
.bind(project_id)
.bind("Trial de 30 días creado automáticamente al registrarse")
.execute(&mut *tx)
.await
.map_err(|e| AuthError::Internal(anyhow::anyhow!("Error al crear suscripción: {}", e)))?;
tx.commit()
.await
.map_err(|e| AuthError::Internal(anyhow::anyhow!("Error al realizar commit: {}", e)))?;
let event = shared_lib::mqtt_messages::ProjectCreatedEvent {
project_id,
deployment_id: Some(project_id),
name: project_name.clone(),
client: payload.company.clone(),
timestamp: chrono::Utc::now(),
};
if let Ok(payload_str) = serde_json::to_string(&event) {
let _ = state
.mqtt_client
.publish(
"omnioil/events/project_created",
rumqttc::QoS::AtLeastOnce,
false,
payload_str,
)
.await;
}
crate::audit::log(
&state.pool,
crate::audit::AuditEntry::new("user.register")
.with_user(user_id, &payload.email)
.with_headers(&headers),
)
.await;
let tokens = create_jwt_tokens(
&user_id.to_string(),
"admin",
&payload.email,
vec![project_id],
)
.map_err(|e| AuthError::Internal(anyhow::anyhow!("Error al crear tokens: {}", e)))?;
use sha2::{Digest, Sha256};
let mut hasher = Sha256::new();
hasher.update(tokens.refresh_token.as_bytes());
let refresh_hash = format!("{:x}", hasher.finalize());
sqlx::query("INSERT INTO refresh_tokens (user_id, token_hash, expires_at) VALUES ($1, $2, $3)")
.bind(user_id)
.bind(refresh_hash)
.bind(chrono::Utc::now() + chrono::Duration::days(7))
.execute(&state.pool)
.await
.map_err(|e| {
tracing::error!("Error storing refresh token: {}", e);
AuthError::Internal(anyhow::anyhow!("Error de sesión"))
})?;
let cookie = build_refresh_cookie(&tokens.refresh_token, 7 * 24 * 3600);
let mut response_headers = HeaderMap::new();
response_headers.insert(SET_COOKIE, cookie.parse().unwrap());
Ok((
response_headers,
Json(json!({
"access_token": tokens.access_token,
"token": tokens.access_token,
"role": "admin",
"email": payload.email,
"projects": [
{
"id": project_id,
"name": project_name
}
]
})),
)
.into_response())
} }
#[derive(serde::Serialize)] #[derive(serde::Serialize)]

View File

@@ -98,6 +98,11 @@ pub struct ProjectBillingRow {
pub period_end: DateTime<Utc>, pub period_end: DateTime<Utc>,
} }
#[derive(sqlx::FromRow)]
struct ProjectLookup {
pub name: String,
}
#[derive(Deserialize)] #[derive(Deserialize)]
pub struct UpdateSubscriptionRequest { pub struct UpdateSubscriptionRequest {
pub tier: Option<String>, pub tier: Option<String>,
@@ -173,12 +178,11 @@ pub async fn get_billing_usage(
}; };
// Get project name // Get project name
let project = sqlx::query!( let project =
"SELECT id, name FROM edge_projects WHERE id = $1", sqlx::query_as::<_, ProjectLookup>("SELECT name FROM edge_projects WHERE id = $1")
project_id .bind(project_id)
) .fetch_optional(&pool)
.fetch_optional(&pool) .await;
.await;
let project = match project { let project = match project {
Ok(Some(p)) => p, Ok(Some(p)) => p,
@@ -448,14 +452,13 @@ pub async fn take_usage_snapshot(
/// Idempotent daily snapshot for all active projects. Called by watchdog. /// Idempotent daily snapshot for all active projects. Called by watchdog.
pub async fn take_daily_snapshots(pool: &PgPool) -> anyhow::Result<usize> { pub async fn take_daily_snapshots(pool: &PgPool) -> anyhow::Result<usize> {
let projects = sqlx::query!("SELECT id FROM edge_projects WHERE is_active = true") let projects: Vec<Uuid> =
.fetch_all(pool) sqlx::query_scalar("SELECT id FROM edge_projects WHERE is_active = true")
.await?; .fetch_all(pool)
.await?;
let mut count = 0usize; let mut count = 0usize;
for row in &projects { for project_id in &projects {
let project_id = row.id;
let active_tags: i64 = sqlx::query_scalar( let active_tags: i64 = sqlx::query_scalar(
r#"SELECT COUNT(*) r#"SELECT COUNT(*)
FROM edge_variables ev FROM edge_variables ev

View File

@@ -0,0 +1,112 @@
use crate::AppState;
use axum::{Json, extract::State, http::StatusCode, response::IntoResponse};
use serde::Deserialize;
use serde_json::Value;
#[derive(Deserialize, Debug)]
pub struct StripeEvent {
#[serde(rename = "type")]
pub event_type: String,
pub data: StripeData,
}
#[derive(Deserialize, Debug)]
pub struct StripeData {
pub object: StripeObject,
}
#[derive(Deserialize, Debug)]
pub struct StripeObject {
pub client_reference_id: Option<String>,
pub metadata: Option<Value>,
}
pub async fn stripe_webhook(
State(state): State<AppState>,
Json(payload): Json<StripeEvent>,
) -> impl IntoResponse {
tracing::info!("📩 Received Stripe Webhook event: {}", payload.event_type);
let event_type = payload.event_type.as_str();
if event_type == "checkout.session.completed"
|| event_type == "customer.subscription.updated"
|| event_type == "customer.subscription.created"
{
// Try to get project ID from client_reference_id or metadata
let project_id_str = payload.data.object.client_reference_id.clone().or_else(|| {
payload
.data
.object
.metadata
.as_ref()
.and_then(|m| m.get("project_id"))
.and_then(|v| v.as_str().map(|s| s.to_string()))
});
if let Some(ref id_str) = project_id_str {
if let Ok(project_id) = uuid::Uuid::parse_str(id_str) {
// Update subscription status to 'active'
// Upgrade tier to 'standard' and set limit to 200 variables
let update_result = sqlx::query(
r#"UPDATE subscriptions
SET status = 'active', tier = 'standard', tag_limit = 200, updated_at = NOW()
WHERE project_id = $1"#,
)
.bind(project_id)
.execute(&state.pool)
.await;
match update_result {
Ok(res) => {
if res.rows_affected() > 0 {
tracing::info!(
"✅ Successfully activated subscription for project: {}",
project_id
);
} else {
// If no subscription exists for this project, let's insert one!
let insert_result = sqlx::query(
r#"INSERT INTO subscriptions (project_id, tier, tag_limit, status, notes)
VALUES ($1, 'standard', 200, 'active', 'Creado vía Stripe Webhook')"#,
)
.bind(project_id)
.execute(&state.pool)
.await;
if insert_result.is_ok() {
tracing::info!(
"✅ Created standard active subscription for project: {}",
project_id
);
} else {
tracing::error!(
"❌ Failed to insert active subscription for project: {}",
project_id
);
}
}
}
Err(e) => {
tracing::error!(
"❌ Failed to update subscription for project {}: {:?}",
project_id,
e
);
return (StatusCode::INTERNAL_SERVER_ERROR, "Database error")
.into_response();
}
}
} else {
tracing::warn!(
"⚠️ Stripe event contained invalid UUID project ID: {}",
id_str
);
}
} else {
tracing::warn!("⚠️ Stripe event did not contain project_id / client_reference_id");
}
}
(StatusCode::OK, "Webhook processed successfully").into_response()
}

View File

@@ -1,8 +1,8 @@
use axum::{extract::State, response::IntoResponse, Json};
use chrono::{DateTime, Utc};
use serde_json::{json, Value};
use crate::db::DbPool;
use crate::auth::Claims; use crate::auth::Claims;
use crate::db::DbPool;
use axum::{Json, extract::State, response::IntoResponse};
use chrono::{DateTime, Utc};
use serde_json::{Value, json};
use uuid::Uuid; use uuid::Uuid;
#[derive(sqlx::FromRow)] #[derive(sqlx::FromRow)]
@@ -13,10 +13,7 @@ struct TelemetryRow {
data: Value, data: Value,
} }
pub async fn get_summary( pub async fn get_summary(State(pool): State<DbPool>, claims: Claims) -> impl IntoResponse {
State(pool): State<DbPool>,
claims: Claims,
) -> impl IntoResponse {
let user_id = Uuid::parse_str(&claims.sub).unwrap(); let user_id = Uuid::parse_str(&claims.sub).unwrap();
let records = sqlx::query_as::<_, TelemetryRow>( let records = sqlx::query_as::<_, TelemetryRow>(

View File

@@ -1,7 +1,8 @@
use axum::response::Html; use axum::response::Html;
pub async fn swagger_ui() -> Html<String> { pub async fn swagger_ui() -> Html<String> {
Html(format!(r#"<!DOCTYPE html> Html(format!(
r#"<!DOCTYPE html>
<html lang="es"> <html lang="es">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
@@ -36,7 +37,8 @@ pub async fn swagger_ui() -> Html<String> {
}}); }});
</script> </script>
</body> </body>
</html>"#)) </html>"#
))
} }
pub async fn openapi_yaml() -> axum::response::Response { pub async fn openapi_yaml() -> axum::response::Response {

View File

@@ -45,7 +45,9 @@ pub async fn list_downtime(
.await?; .await?;
if access.is_none() { if access.is_none() {
return Err(AppError::Forbidden("Sin permiso para ver paradas de este activo".to_string())); return Err(AppError::Forbidden(
"Sin permiso para ver paradas de este activo".to_string(),
));
} }
let downtime: Vec<OperationDowntime> = sqlx::query_as::<Postgres, OperationDowntime>( let downtime: Vec<OperationDowntime> = sqlx::query_as::<Postgres, OperationDowntime>(
@@ -78,7 +80,9 @@ pub async fn create_downtime(
.await?; .await?;
if access.is_none() { if access.is_none() {
return Err(AppError::Forbidden("Sin permiso para registrar paradas en este activo".to_string())); return Err(AppError::Forbidden(
"Sin permiso para registrar paradas en este activo".to_string(),
));
} }
let downtime: OperationDowntime = sqlx::query_as::<Postgres, OperationDowntime>( let downtime: OperationDowntime = sqlx::query_as::<Postgres, OperationDowntime>(

View File

@@ -2,18 +2,18 @@
//! //!
//! Este módulo centraliza la telemetría enviada por los agentes instalados en las plantas. //! Este módulo centraliza la telemetría enviada por los agentes instalados en las plantas.
use crate::AppState;
use crate::auth::Claims;
use axum::{ use axum::{
Json,
extract::{Path, Query, State}, extract::{Path, Query, State},
http::StatusCode, http::StatusCode,
Json,
}; };
use rumqttc::QoS;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use shared_lib::edge_models::*; use shared_lib::edge_models::*;
use sqlx::PgPool; use sqlx::PgPool;
use uuid::Uuid; use uuid::Uuid;
use crate::auth::Claims;
use crate::AppState;
use rumqttc::QoS;
use crate::handlers::update_info::fetch_sha256_internal; use crate::handlers::update_info::fetch_sha256_internal;
@@ -49,13 +49,13 @@ pub async fn trigger_ota_update(
} }
// Resolve project_id from agent_id (agents are keyed by project_id) // Resolve project_id from agent_id (agents are keyed by project_id)
let project_id: Uuid = sqlx::query_scalar( let project_id: Uuid = sqlx::query_scalar("SELECT id FROM edge_projects WHERE id = $1")
"SELECT id FROM edge_projects WHERE id = $1" .bind(agent_id)
) .fetch_optional(&state.pool)
.bind(agent_id) .await?
.fetch_optional(&state.pool) .ok_or_else(|| {
.await? crate::errors::AppError::NotFound(format!("Agent/project {} not found", agent_id))
.ok_or_else(|| crate::errors::AppError::NotFound(format!("Agent/project {} not found", agent_id)))?; })?;
// Resolve target version: explicit request → AGENT_LATEST_VERSION env → error // Resolve target version: explicit request → AGENT_LATEST_VERSION env → error
let target_version = match req.version.as_deref() { let target_version = match req.version.as_deref() {
@@ -71,8 +71,8 @@ pub async fn trigger_ota_update(
// Build the download URL pointing to this backend's proxy endpoint. // Build the download URL pointing to this backend's proxy endpoint.
// The agent makes ONE outbound HTTPS GET using this URL — no stored credentials needed. // The agent makes ONE outbound HTTPS GET using this URL — no stored credentials needed.
let api_base = std::env::var("API_BASE_URL") let api_base =
.unwrap_or_else(|_| "https://api.omnioil.com".to_string()); std::env::var("API_BASE_URL").unwrap_or_else(|_| "https://api.omnioil.com".to_string());
let download_url = format!( let download_url = format!(
"{}/api/edge/download-agent?version={}&asset_type={}", "{}/api/edge/download-agent?version={}&asset_type={}",
api_base.trim_end_matches('/'), api_base.trim_end_matches('/'),
@@ -94,13 +94,17 @@ pub async fn trigger_ota_update(
"issued_at": chrono::Utc::now().to_rfc3339(), "issued_at": chrono::Utc::now().to_rfc3339(),
}); });
let payload_bytes = serde_json::to_vec(&command_payload) let payload_bytes = serde_json::to_vec(&command_payload).map_err(|e| {
.map_err(|e| crate::errors::AppError::Internal(anyhow::anyhow!("Serialization error: {}", e)))?; crate::errors::AppError::Internal(anyhow::anyhow!("Serialization error: {}", e))
})?;
state.mqtt_client state
.mqtt_client
.publish(&topic, QoS::AtLeastOnce, false, payload_bytes) .publish(&topic, QoS::AtLeastOnce, false, payload_bytes)
.await .await
.map_err(|e| crate::errors::AppError::Internal(anyhow::anyhow!("MQTT publish failed: {}", e)))?; .map_err(|e| {
crate::errors::AppError::Internal(anyhow::anyhow!("MQTT publish failed: {}", e))
})?;
tracing::info!( tracing::info!(
"📡 OTA command published to {} by {} (target: v{}, sha256: {})", "📡 OTA command published to {} by {} (target: v{}, sha256: {})",
@@ -224,7 +228,9 @@ pub async fn get_agent_health(
.await?; .await?;
if access.is_none() { if access.is_none() {
return Err(AppError::Forbidden("Sin acceso al estado de este proyecto".to_string())); return Err(AppError::Forbidden(
"Sin acceso al estado de este proyecto".to_string(),
));
} }
} }
// Obtener último log de health para saber si sigue comunicando // Obtener último log de health para saber si sigue comunicando

View File

@@ -1,22 +1,25 @@
//! Handlers para activos (Pozos, Tanques, etc.) //! Handlers para activos (Pozos, Tanques, etc.)
use crate::auth::Claims;
use axum::{ use axum::{
Json,
extract::{Path, Query, State}, extract::{Path, Query, State},
http::StatusCode, http::StatusCode,
Json,
}; };
use sqlx::PgPool;
use uuid::Uuid;
use serde::Deserialize; use serde::Deserialize;
use serde_json::Value; use serde_json::Value;
use shared_lib::models::{EdgeAsset, CreateEdgeAssetRequest}; use shared_lib::models::{CreateEdgeAssetRequest, EdgeAsset};
use crate::auth::Claims; use sqlx::PgPool;
use uuid::Uuid;
#[derive(Deserialize)] #[derive(Deserialize)]
pub struct UpdateAssetRequest { pub struct UpdateAssetRequest {
pub name: String, pub name: String,
pub is_active: bool, pub is_active: bool,
pub metadata: Option<Value>, pub metadata: Option<Value>,
pub last_calibration_date: Option<chrono::NaiveDate>,
pub next_calibration_date: Option<chrono::NaiveDate>,
pub calibration_certificate_url: Option<String>,
pub external_api_url: Option<String>, pub external_api_url: Option<String>,
pub external_api_key: Option<String>, pub external_api_key: Option<String>,
pub is_collector_enabled: Option<bool>, pub is_collector_enabled: Option<bool>,
@@ -41,11 +44,13 @@ pub async fn list_assets_by_project(
.await?; .await?;
if access.is_none() { if access.is_none() {
return Err(AppError::Forbidden("Sin acceso a los activos de este proyecto".to_string())); return Err(AppError::Forbidden(
"Sin acceso a los activos de este proyecto".to_string(),
));
} }
let assets = sqlx::query_as::<_, EdgeAsset>( let assets = sqlx::query_as::<_, EdgeAsset>(
"SELECT * FROM edge_assets WHERE project_id = $1 ORDER BY created_at DESC" "SELECT * FROM edge_assets WHERE project_id = $1 ORDER BY created_at DESC",
) )
.bind(project_id) .bind(project_id)
.fetch_all(&pool) .fetch_all(&pool)
@@ -71,13 +76,19 @@ pub async fn create_asset(
.await?; .await?;
if access.is_none() { if access.is_none() {
return Err(AppError::Forbidden("Sin permiso para crear activos en este proyecto".to_string())); return Err(AppError::Forbidden(
"Sin permiso para crear activos en este proyecto".to_string(),
));
} }
let asset = sqlx::query_as::<_, EdgeAsset>( let asset = sqlx::query_as::<_, EdgeAsset>(
r#"INSERT INTO edge_assets (project_id, code, name, asset_type, metadata, external_api_url, external_api_key, is_collector_enabled) r#"INSERT INTO edge_assets (
VALUES ($1, $2, $3, $4, $5, $6, $7, $8) project_id, code, name, asset_type, metadata,
RETURNING *"# external_api_url, external_api_key, is_collector_enabled,
last_calibration_date, next_calibration_date, calibration_certificate_url
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
RETURNING *"#,
) )
.bind(req.project_id) .bind(req.project_id)
.bind(&req.code) .bind(&req.code)
@@ -87,6 +98,9 @@ pub async fn create_asset(
.bind(req.external_api_url) .bind(req.external_api_url)
.bind(req.external_api_key) .bind(req.external_api_key)
.bind(req.is_collector_enabled.unwrap_or(false)) .bind(req.is_collector_enabled.unwrap_or(false))
.bind(req.last_calibration_date)
.bind(req.next_calibration_date)
.bind(&req.calibration_certificate_url)
.fetch_one(&pool) .fetch_one(&pool)
.await?; .await?;
@@ -116,7 +130,7 @@ pub async fn get_asset(
.await?; .await?;
if access.is_none() { if access.is_none() {
return Err(AppError::Forbidden("Sin acceso a este activo".to_string())); return Err(AppError::Forbidden("Sin acceso a este activo".to_string()));
} }
Ok(Json(asset)) Ok(Json(asset))
@@ -147,15 +161,18 @@ pub async fn update_asset(
.await?; .await?;
if access.is_none() { if access.is_none() {
return Err(AppError::Forbidden("Sin permiso para actualizar este activo".to_string())); return Err(AppError::Forbidden(
"Sin permiso para actualizar este activo".to_string(),
));
} }
let updated = sqlx::query_as::<_, EdgeAsset>( let updated = sqlx::query_as::<_, EdgeAsset>(
r#"UPDATE edge_assets r#"UPDATE edge_assets
SET name = $1, is_active = $2, metadata = $3, SET name = $1, is_active = $2, metadata = $3,
external_api_url = $4, external_api_key = $5, is_collector_enabled = $6, external_api_url = $4, external_api_key = $5, is_collector_enabled = $6,
last_calibration_date = $7, next_calibration_date = $8, calibration_certificate_url = $9,
updated_at = NOW() updated_at = NOW()
WHERE id = $7 WHERE id = $10
RETURNING *"# RETURNING *"#
) )
.bind(&req.name) .bind(&req.name)
@@ -164,6 +181,9 @@ pub async fn update_asset(
.bind(req.external_api_url) .bind(req.external_api_url)
.bind(req.external_api_key) .bind(req.external_api_key)
.bind(req.is_collector_enabled.unwrap_or(false)) .bind(req.is_collector_enabled.unwrap_or(false))
.bind(req.last_calibration_date)
.bind(req.next_calibration_date)
.bind(&req.calibration_certificate_url)
.bind(id) .bind(id)
.fetch_optional(&pool) .fetch_optional(&pool)
.await? .await?
@@ -183,8 +203,9 @@ pub async fn list_tanks_by_project(
claims: Claims, claims: Claims,
Query(params): Query<TankQueryParams>, Query(params): Query<TankQueryParams>,
) -> Result<Json<Vec<EdgeAsset>>, AppError> { ) -> Result<Json<Vec<EdgeAsset>>, AppError> {
let project_id = params.project_id let project_id = params.project_id.ok_or(AppError::BadRequest(
.ok_or(AppError::BadRequest("project_id es obligatorio".to_string()))?; "project_id es obligatorio".to_string(),
))?;
// Validar acceso al proyecto // Validar acceso al proyecto
let user_id = Uuid::parse_str(&claims.sub) let user_id = Uuid::parse_str(&claims.sub)
@@ -197,7 +218,9 @@ pub async fn list_tanks_by_project(
.await?; .await?;
if access.is_none() { if access.is_none() {
return Err(AppError::Forbidden("Sin acceso a los tanques de este proyecto".to_string())); return Err(AppError::Forbidden(
"Sin acceso a los tanques de este proyecto".to_string(),
));
} }
let tanks = sqlx::query_as::<_, EdgeAsset>( let tanks = sqlx::query_as::<_, EdgeAsset>(
@@ -234,7 +257,9 @@ pub async fn delete_asset(
.await?; .await?;
if access.is_none() { if access.is_none() {
return Err(AppError::Forbidden("Sin permiso para eliminar este activo".to_string())); return Err(AppError::Forbidden(
"Sin permiso para eliminar este activo".to_string(),
));
} }
sqlx::query("DELETE FROM edge_assets WHERE id = $1") sqlx::query("DELETE FROM edge_assets WHERE id = $1")

View File

@@ -9,10 +9,10 @@ use axum::{
extract::{Path, State}, extract::{Path, State},
http::StatusCode, http::StatusCode,
}; };
use chrono;
use shared_lib::edge_models::*; use shared_lib::edge_models::*;
use sqlx::PgPool; use sqlx::PgPool;
use uuid::Uuid; use uuid::Uuid;
use chrono;
/// GET /api/edge/projects/:project_id/deployments /// GET /api/edge/projects/:project_id/deployments
/// ///
@@ -129,7 +129,11 @@ pub async fn trigger_build(
if let Ok(payload) = serde_json::to_string(&event) { if let Ok(payload) = serde_json::to_string(&event) {
let mqtt_clone = mqtt.clone(); let mqtt_clone = mqtt.clone();
tracing::info!("📡 Enviando señal de construcción para despliegue {}: {}", deployment.id, payload); tracing::info!(
"📡 Enviando señal de construcción para despliegue {}: {}",
deployment.id,
payload
);
tokio::spawn(async move { tokio::spawn(async move {
let _ = mqtt_clone let _ = mqtt_clone
.publish( .publish(

View File

@@ -1,14 +1,14 @@
//! Handlers para CRUD de dispositivos Edge. //! Handlers para CRUD de dispositivos Edge.
use crate::auth::Claims;
use axum::{ use axum::{
Json,
extract::{Path, State}, extract::{Path, State},
http::StatusCode, http::StatusCode,
Json,
}; };
use shared_lib::edge_models::*;
use sqlx::PgPool; use sqlx::PgPool;
use uuid::Uuid; use uuid::Uuid;
use shared_lib::edge_models::*;
use crate::auth::Claims;
/// GET /api/edge/assets/:asset_id/devices — Listar dispositivos de un activo. /// GET /api/edge/assets/:asset_id/devices — Listar dispositivos de un activo.
pub async fn list_devices( pub async fn list_devices(
@@ -21,7 +21,7 @@ pub async fn list_devices(
let access = sqlx::query( let access = sqlx::query(
r#"SELECT 1 FROM edge_assets ea r#"SELECT 1 FROM edge_assets ea
JOIN user_project_access upa ON ea.project_id = upa.project_id JOIN user_project_access upa ON ea.project_id = upa.project_id
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).unwrap())
@@ -30,12 +30,15 @@ pub async fn list_devices(
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
if access.is_none() { if access.is_none() {
return Err((StatusCode::FORBIDDEN, "Access denied to this asset's devices".to_string())); return Err((
StatusCode::FORBIDDEN,
"Access denied to this asset's devices".to_string(),
));
} }
} }
let devices = sqlx::query_as::<_, EdgeDevice>( let devices = sqlx::query_as::<_, EdgeDevice>(
"SELECT * FROM edge_devices WHERE asset_id = $1 ORDER BY name" "SELECT * FROM edge_devices WHERE asset_id = $1 ORDER BY name",
) )
.bind(asset_id) .bind(asset_id)
.fetch_all(&pool) .fetch_all(&pool)
@@ -57,7 +60,7 @@ pub async fn create_device(
let access = sqlx::query( let access = sqlx::query(
r#"SELECT 1 FROM edge_assets ea r#"SELECT 1 FROM edge_assets ea
JOIN user_project_access upa ON ea.project_id = upa.project_id JOIN user_project_access upa ON ea.project_id = upa.project_id
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).unwrap())
@@ -66,14 +69,17 @@ pub async fn create_device(
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
if access.is_none() { if access.is_none() {
return Err((StatusCode::FORBIDDEN, "Access denied to create devices for this asset".to_string())); return Err((
StatusCode::FORBIDDEN,
"Access denied to create devices for this asset".to_string(),
));
} }
} }
let device = sqlx::query_as::<_, EdgeDevice>( let device = sqlx::query_as::<_, EdgeDevice>(
r#"INSERT INTO edge_devices (asset_id, name, protocol, host, port, protocol_config) r#"INSERT INTO edge_devices (asset_id, name, protocol, host, port, protocol_config)
VALUES ($1, $2, $3, $4, $5, $6) VALUES ($1, $2, $3, $4, $5, $6)
RETURNING *"# RETURNING *"#,
) )
.bind(asset_id) .bind(asset_id)
.bind(&req.name) .bind(&req.name)
@@ -101,7 +107,7 @@ pub async fn update_device(
r#"SELECT 1 FROM edge_devices ed r#"SELECT 1 FROM edge_devices ed
JOIN edge_assets ea ON ed.asset_id = ea.id JOIN edge_assets ea ON ed.asset_id = ea.id
JOIN user_project_access upa ON ea.project_id = upa.project_id JOIN user_project_access upa ON ea.project_id = upa.project_id
WHERE ed.id = $1 AND upa.user_id = $2 AND upa.is_active = true"# WHERE ed.id = $1 AND upa.user_id = $2 AND upa.is_active = true"#,
) )
.bind(id) .bind(id)
.bind(Uuid::parse_str(&claims.sub).unwrap()) .bind(Uuid::parse_str(&claims.sub).unwrap())
@@ -110,7 +116,10 @@ pub async fn update_device(
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
if access.is_none() { if access.is_none() {
return Err((StatusCode::FORBIDDEN, "Access denied to this device".to_string())); return Err((
StatusCode::FORBIDDEN,
"Access denied to this device".to_string(),
));
} }
} }
@@ -123,7 +132,7 @@ pub async fn update_device(
protocol_config = COALESCE($6, protocol_config), protocol_config = COALESCE($6, protocol_config),
is_enabled = COALESCE($7, is_enabled) is_enabled = COALESCE($7, is_enabled)
WHERE id = $1 WHERE id = $1
RETURNING *"# RETURNING *"#,
) )
.bind(id) .bind(id)
.bind(&req.name) .bind(&req.name)
@@ -152,7 +161,7 @@ pub async fn delete_device(
r#"SELECT 1 FROM edge_devices ed r#"SELECT 1 FROM edge_devices ed
JOIN edge_assets ea ON ed.asset_id = ea.id JOIN edge_assets ea ON ed.asset_id = ea.id
JOIN user_project_access upa ON ea.project_id = upa.project_id JOIN user_project_access upa ON ea.project_id = upa.project_id
WHERE ed.id = $1 AND upa.user_id = $2 AND upa.is_active = true"# WHERE ed.id = $1 AND upa.user_id = $2 AND upa.is_active = true"#,
) )
.bind(id) .bind(id)
.bind(Uuid::parse_str(&claims.sub).unwrap()) .bind(Uuid::parse_str(&claims.sub).unwrap())
@@ -161,7 +170,10 @@ pub async fn delete_device(
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
if access.is_none() { if access.is_none() {
return Err((StatusCode::FORBIDDEN, "Access denied to delete this device".to_string())); return Err((
StatusCode::FORBIDDEN,
"Access denied to delete this device".to_string(),
));
} }
} }
@@ -221,18 +233,25 @@ pub async fn trigger_opcua_scan(
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))? .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
.ok_or_else(|| (StatusCode::NOT_FOUND, "Device not found".to_string()))?; .ok_or_else(|| (StatusCode::NOT_FOUND, "Device not found".to_string()))?;
let protocol: String = row.try_get("protocol") let protocol: String = row
.try_get("protocol")
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
let host: String = row.try_get("host") let host: String = row
.try_get("host")
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
let port: i32 = row.try_get("port") let port: i32 = row
.try_get("port")
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
let project_id: Uuid = row.try_get("project_id") let project_id: Uuid = row
.try_get("project_id")
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
// Verify this is an OPC UA device // Verify this is an OPC UA device
if protocol != "OPC_UA" { if protocol != "OPC_UA" {
return Err((StatusCode::BAD_REQUEST, "Device protocol is not OPC_UA".to_string())); return Err((
StatusCode::BAD_REQUEST,
"Device protocol is not OPC_UA".to_string(),
));
} }
// Check access // Check access
@@ -281,17 +300,25 @@ pub async fn trigger_opcua_scan(
"issued_at": chrono::Utc::now().to_rfc3339(), "issued_at": chrono::Utc::now().to_rfc3339(),
}); });
let payload = serde_json::to_vec(&cmd) let payload =
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; serde_json::to_vec(&cmd).map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
state.mqtt_client state
.mqtt_client
.publish(&topic, QoS::AtLeastOnce, false, payload) .publish(&topic, QoS::AtLeastOnce, false, payload)
.await .await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("MQTT publish failed: {}", e)))?; .map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("MQTT publish failed: {}", e),
)
})?;
tracing::info!( tracing::info!(
"🔍 OPC UA scan triggered for device {} (project {}). Scan ID: {}", "🔍 OPC UA scan triggered for device {} (project {}). Scan ID: {}",
device_id, project_id, scan_id device_id,
project_id,
scan_id
); );
Ok(( Ok((
@@ -323,7 +350,7 @@ pub async fn get_opcua_scan_result(
r#"SELECT 1 FROM edge_devices ed r#"SELECT 1 FROM edge_devices ed
JOIN edge_assets ea ON ed.asset_id = ea.id JOIN edge_assets ea ON ed.asset_id = ea.id
JOIN user_project_access upa ON ea.project_id = upa.project_id JOIN user_project_access upa ON ea.project_id = upa.project_id
WHERE ed.id = $1 AND upa.user_id = $2 AND upa.is_active = true"# WHERE ed.id = $1 AND upa.user_id = $2 AND upa.is_active = true"#,
) )
.bind(device_id) .bind(device_id)
.bind(user_id) .bind(user_id)
@@ -345,15 +372,20 @@ pub async fn get_opcua_scan_result(
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))? .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
.ok_or_else(|| (StatusCode::NOT_FOUND, "Scan not found".to_string()))?; .ok_or_else(|| (StatusCode::NOT_FOUND, "Scan not found".to_string()))?;
let status: String = row.try_get("status") let status: String = row
.try_get("status")
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
let result: Option<serde_json::Value> = row.try_get("result") let result: Option<serde_json::Value> = row
.try_get("result")
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
let error_message: Option<String> = row.try_get("error_message") let error_message: Option<String> = row
.try_get("error_message")
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
let created_at: chrono::DateTime<chrono::Utc> = row.try_get("created_at") let created_at: chrono::DateTime<chrono::Utc> = row
.try_get("created_at")
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
let expires_at: chrono::DateTime<chrono::Utc> = row.try_get("expires_at") let expires_at: chrono::DateTime<chrono::Utc> = row
.try_get("expires_at")
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
Ok(Json(serde_json::json!({ Ok(Json(serde_json::json!({

View File

@@ -1,15 +1,16 @@
//! Handlers para CRUD de proyectos Edge. //! Handlers para CRUD de proyectos Edge.
use crate::auth::Claims;
use crate::handlers::anh_reports::ensure_default_anh_schedule_for_confirmed_contract;
use axum::{ use axum::{
Json, Json,
extract::{Path, State}, extract::{Path, Query, State},
http::StatusCode, http::StatusCode,
}; };
use shared_lib::edge_models::*;
use crate::auth::Claims;
use sqlx::PgPool;
use uuid::Uuid;
use chrono; use chrono;
use shared_lib::edge_models::*;
use sqlx::{PgPool, Row};
use uuid::Uuid;
/// GET /api/edge/projects — Listar todos los proyectos. /// GET /api/edge/projects — Listar todos los proyectos.
pub async fn list_projects( pub async fn list_projects(
@@ -22,7 +23,10 @@ pub async fn list_projects(
WHERE upa.user_id = $1 AND upa.is_active = true WHERE upa.user_id = $1 AND upa.is_active = true
ORDER BY p.created_at DESC"#, ORDER BY p.created_at DESC"#,
) )
.bind(Uuid::parse_str(&claims.sub).map_err(|_| (StatusCode::UNAUTHORIZED, "Invalid user ID".to_string()))?) .bind(
Uuid::parse_str(&claims.sub)
.map_err(|_| (StatusCode::UNAUTHORIZED, "Invalid user ID".to_string()))?,
)
.fetch_all(&pool) .fetch_all(&pool)
.await .await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
@@ -39,9 +43,15 @@ pub async fn create_project(
) -> Result<(StatusCode, Json<EdgeProject>), (StatusCode, String)> { ) -> Result<(StatusCode, Json<EdgeProject>), (StatusCode, String)> {
// Solo admins pueden crear proyectos globales // Solo admins pueden crear proyectos globales
if claims.role != "admin" { if claims.role != "admin" {
return Err((StatusCode::FORBIDDEN, "Only admins can create projects".to_string())); return Err((
StatusCode::FORBIDDEN,
"Only admins can create projects".to_string(),
));
} }
let mut tx = pool.begin().await.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; let mut tx = pool
.begin()
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
let project = sqlx::query_as::<_, EdgeProject>( let project = sqlx::query_as::<_, EdgeProject>(
r#"INSERT INTO edge_projects (name, client, operator_name, contract_number, description, metadata) r#"INSERT INTO edge_projects (name, client, operator_name, contract_number, description, metadata)
@@ -59,7 +69,21 @@ pub async fn create_project(
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
// Autolink al creador as owner // Autolink al creador as owner
let user_id = Uuid::parse_str(&claims.sub).map_err(|_| (StatusCode::UNAUTHORIZED, "Invalid user ID in token".to_string()))?; let user_id = Uuid::parse_str(&claims.sub).map_err(|_| {
(
StatusCode::UNAUTHORIZED,
"Invalid user ID in token".to_string(),
)
})?;
ensure_default_anh_schedule_for_confirmed_contract(
&mut *tx,
&req.operator_name,
&req.contract_number,
Some(user_id),
)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("{:?}", e)))?;
sqlx::query( sqlx::query(
"INSERT INTO user_project_access (user_id, project_id, project_role) VALUES ($1, $2, 'owner')" "INSERT INTO user_project_access (user_id, project_id, project_role) VALUES ($1, $2, 'owner')"
@@ -70,7 +94,9 @@ pub async fn create_project(
.await .await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
tx.commit().await.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; tx.commit()
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
// ─── Dispatch Domain Event via MQTT (Internal Messaging) ────────────────────────────── // ─── Dispatch Domain Event via MQTT (Internal Messaging) ──────────────────────────────
let event = shared_lib::mqtt_messages::ProjectCreatedEvent { let event = shared_lib::mqtt_messages::ProjectCreatedEvent {
@@ -102,14 +128,41 @@ pub async fn create_project(
}); });
} }
crate::audit::log(&pool, crate::audit::AuditEntry::new("project.create") crate::audit::log(
.with_user(user_id, &claims.email) &pool,
.with_resource(format!("project:{}", project.id)) crate::audit::AuditEntry::new("project.create")
).await; .with_user(user_id, &claims.email)
.with_resource(format!("project:{}", project.id)),
)
.await;
Ok((StatusCode::CREATED, Json(project))) Ok((StatusCode::CREATED, Json(project)))
} }
#[cfg(test)]
mod tests {
#[test]
fn create_project_wires_default_anh_schedule_before_commit() {
let source = include_str!("edge_projects.rs");
let schedule_call = source
.find("ensure_default_anh_schedule_for_confirmed_contract(\n &mut *tx")
.expect("create_project should call ANH schedule provisioning helper");
let user_id_parse = source
.find("let user_id = Uuid::parse_str(&claims.sub)")
.expect("create_project should parse the authenticated user id");
let access_insert = source
.find("INSERT INTO user_project_access")
.expect("create_project should create owner access");
let commit = source
.find("tx.commit()")
.expect("create_project should commit transaction");
assert!(user_id_parse < schedule_call);
assert!(schedule_call < commit);
assert!(access_insert < commit);
}
}
/// GET /api/edge/projects/:id — Detalle de un proyecto. /// GET /api/edge/projects/:id — Detalle de un proyecto.
pub async fn get_project( pub async fn get_project(
State(pool): State<PgPool>, State(pool): State<PgPool>,
@@ -125,7 +178,10 @@ pub async fn get_project(
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
if access.is_none() { if access.is_none() {
return Err((StatusCode::FORBIDDEN, "Access denied to this project".to_string())); return Err((
StatusCode::FORBIDDEN,
"Access denied to this project".to_string(),
));
} }
let project = sqlx::query_as::<_, EdgeProject>("SELECT * FROM edge_projects WHERE id = $1") let project = sqlx::query_as::<_, EdgeProject>("SELECT * FROM edge_projects WHERE id = $1")
@@ -154,7 +210,10 @@ pub async fn update_project(
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
if access.is_none() { if access.is_none() {
return Err((StatusCode::FORBIDDEN, "Only project owners or authorized admins can update project details".to_string())); return Err((
StatusCode::FORBIDDEN,
"Only project owners or authorized admins can update project details".to_string(),
));
} }
let project = sqlx::query_as::<_, EdgeProject>( let project = sqlx::query_as::<_, EdgeProject>(
@@ -196,7 +255,10 @@ pub async fn delete_project(
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
if access.is_none() { if access.is_none() {
return Err((StatusCode::FORBIDDEN, "Only project owners can delete projects".to_string())); return Err((
StatusCode::FORBIDDEN,
"Only project owners can delete projects".to_string(),
));
} }
let result = sqlx::query("DELETE FROM edge_projects WHERE id = $1") let result = sqlx::query("DELETE FROM edge_projects WHERE id = $1")
@@ -227,7 +289,10 @@ pub async fn get_project_full_config(
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
if access.is_none() { if access.is_none() {
return Err((StatusCode::FORBIDDEN, "Access denied to this project".to_string())); return Err((
StatusCode::FORBIDDEN,
"Access denied to this project".to_string(),
));
} }
use shared_lib::models::EdgeAsset; use shared_lib::models::EdgeAsset;
@@ -300,7 +365,10 @@ pub async fn download_installer(
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
if access.is_none() { if access.is_none() {
return Err((StatusCode::FORBIDDEN, "Access denied to this project installer".to_string())); return Err((
StatusCode::FORBIDDEN,
"Access denied to this project installer".to_string(),
));
} }
let project = sqlx::query_as::<_, EdgeProject>("SELECT * FROM edge_projects WHERE id = $1") let project = sqlx::query_as::<_, EdgeProject>("SELECT * FROM edge_projects WHERE id = $1")
.bind(id) .bind(id)
@@ -323,13 +391,12 @@ pub async fn download_installer(
return Err(( return Err((
StatusCode::INTERNAL_SERVER_ERROR, StatusCode::INTERNAL_SERVER_ERROR,
format!("Invalid installer key format: '{}'", installer_key), format!("Invalid installer key format: '{}'", installer_key),
)) ));
} }
}; };
// URL interna de MinIO (solo accesible desde la red Docker) // URL interna de MinIO (solo accesible desde la red Docker)
let endpoint_url = let endpoint_url = std::env::var("MINIO_ENDPOINT_URL").expect("MINIO_ENDPOINT_URL must be set");
std::env::var("MINIO_ENDPOINT_URL").expect("MINIO_ENDPOINT_URL must be set");
// Configurar credenciales explícitas // Configurar credenciales explícitas
let access_key = std::env::var("AWS_ACCESS_KEY_ID") let access_key = std::env::var("AWS_ACCESS_KEY_ID")
@@ -359,7 +426,12 @@ pub async fn download_installer(
.build(); .build();
let client = aws_sdk_s3::Client::from_conf(s3_config); let client = aws_sdk_s3::Client::from_conf(s3_config);
tracing::info!("📥 Intentando descargar desde S3 bucket: {}, key: {} en {}", bucket_name, key_name, endpoint_url); tracing::info!(
"📥 Intentando descargar desde S3 bucket: {}, key: {} en {}",
bucket_name,
key_name,
endpoint_url
);
let get_res = client let get_res = client
.get_object() .get_object()
@@ -371,7 +443,12 @@ pub async fn download_installer(
let get_req = match get_res { let get_req = match get_res {
Ok(res) => res, Ok(res) => res,
Err(e) => { Err(e) => {
tracing::error!("❌ Error de S3 al recuperar archivo (bucket: {}, key: {}): {:?}", bucket_name, key_name, e); tracing::error!(
"❌ Error de S3 al recuperar archivo (bucket: {}, key: {}): {:?}",
bucket_name,
key_name,
e
);
return Err(( return Err((
StatusCode::INTERNAL_SERVER_ERROR, StatusCode::INTERNAL_SERVER_ERROR,
format!("Failed to retrieve file from S3 ({}): {}", key_name, e), format!("Failed to retrieve file from S3 ({}): {}", key_name, e),
@@ -415,7 +492,10 @@ pub async fn deploy_project(
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
if access.is_none() { if access.is_none() {
return Err((StatusCode::FORBIDDEN, "Access denied to deploy this project".to_string())); return Err((
StatusCode::FORBIDDEN,
"Access denied to deploy this project".to_string(),
));
} }
// 2. Obtener config FULL (Project + Assets + Devices + Variables) // 2. Obtener config FULL (Project + Assets + Devices + Variables)
@@ -457,7 +537,10 @@ pub async fn deploy_project(
device_configs.push(DeviceConfig { device, variables }); device_configs.push(DeviceConfig { device, variables });
} }
asset_configs.push(AssetConfig { asset, devices: device_configs }); asset_configs.push(AssetConfig {
asset,
devices: device_configs,
});
} }
let full_config = ProjectConfig { let full_config = ProjectConfig {
@@ -467,16 +550,28 @@ pub async fn deploy_project(
// 3. Publicar en MQTT para que el sistema de despliegue lo procese // 3. Publicar en MQTT para que el sistema de despliegue lo procese
// Tópico: omnioil/control/deploy/{project_id} // Tópico: omnioil/control/deploy/{project_id}
let payload = serde_json::to_string(&full_config) let payload = serde_json::to_string(&full_config).map_err(|e| {
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to serialize config: {}", e)))?; (
StatusCode::INTERNAL_SERVER_ERROR,
format!("Failed to serialize config: {}", e),
)
})?;
let topic = format!("omnioil/control/deploy/{}", id); let topic = format!("omnioil/control/deploy/{}", id);
mqtt.publish(topic, rumqttc::QoS::AtLeastOnce, false, payload) mqtt.publish(topic, rumqttc::QoS::AtLeastOnce, false, payload)
.await .await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("MQTT Publish failed: {}", e)))?; .map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("MQTT Publish failed: {}", e),
)
})?;
tracing::info!("🚀 Despliegue solicitado para proyecto: {}. Configuración publicada en MQTT.", id); tracing::info!(
"🚀 Despliegue solicitado para proyecto: {}. Configuración publicada en MQTT.",
id
);
Ok(Json(serde_json::json!({ Ok(Json(serde_json::json!({
"message": "Despliegue iniciado correctamente", "message": "Despliegue iniciado correctamente",
@@ -485,15 +580,12 @@ pub async fn deploy_project(
}))) })))
} }
/// GET /api/edge/projects/:id/linux-install — Genera script de instalación Linux. /// POST /api/edge/projects/:id/linux-install-token — Genera un token de descarga temporal de un solo uso.
/// pub async fn generate_download_token(
/// 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>, State(pool): State<PgPool>,
claims: Claims, claims: Claims,
Path(id): Path<Uuid>, Path(id): Path<Uuid>,
) -> Result<Response, (StatusCode, String)> { ) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
// Verificar acceso al proyecto // Verificar acceso al proyecto
let access = sqlx::query( let access = sqlx::query(
"SELECT 1 FROM user_project_access WHERE user_id = $1 AND project_id = $2 AND is_active = true" "SELECT 1 FROM user_project_access WHERE user_id = $1 AND project_id = $2 AND is_active = true"
@@ -505,12 +597,135 @@ pub async fn linux_install_script(
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
if access.is_none() { if access.is_none() {
return Err((StatusCode::FORBIDDEN, "Acceso denegado a este proyecto".to_string())); return Err((
StatusCode::FORBIDDEN,
"Acceso denegado a este proyecto".to_string(),
));
} }
let download_token = uuid::Uuid::new_v4().to_string();
let expires_at = chrono::Utc::now() + chrono::Duration::minutes(10);
sqlx::query(
"INSERT INTO agent_download_tokens (token, project_id, expires_at) VALUES ($1, $2, $3)",
)
.bind(&download_token)
.bind(id)
.bind(expires_at)
.execute(&pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
Ok(Json(serde_json::json!({ "token": download_token })))
}
#[derive(serde::Deserialize)]
pub struct InstallScriptQuery {
token: Option<String>,
}
/// 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. Puede autenticarse con sesión activa (JWT)
/// o mediante un token de descarga temporal de un solo uso en la query param: ?token=...
pub async fn linux_install_script(
State(pool): State<PgPool>,
headers: axum::http::HeaderMap,
Query(query): Query<InstallScriptQuery>,
Path(id): Path<Uuid>,
) -> Result<Response, (StatusCode, String)> {
// Intentar extraer y verificar claims desde los headers de forma manual
let claims_opt = if let Some(auth_header) = headers
.get(axum::http::header::AUTHORIZATION)
.and_then(|value| value.to_str().ok())
{
if auth_header.starts_with("Bearer ") {
let token = &auth_header[7..];
if let Ok(token_data) = crate::auth::verify_jwt(token) {
let claims = token_data.claims;
if let Ok(user_id) = uuid::Uuid::parse_str(&claims.sub) {
let user_active: Option<bool> =
sqlx::query_scalar("SELECT is_active FROM users WHERE id = $1")
.bind(user_id)
.fetch_optional(&pool)
.await
.unwrap_or(None);
if user_active == Some(true) {
Some(claims)
} else {
None
}
} else {
None
}
} else {
None
}
} else {
None
}
} else {
None
};
let project_id = if let Some(claims) = claims_opt {
// 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(),
));
}
id
} else {
// Validar por token de descarga temporal
let token_str = query.token.ok_or((
StatusCode::UNAUTHORIZED,
"Token de descarga o sesión JWT faltante".to_string(),
))?;
let token_row = sqlx::query(
"SELECT project_id FROM agent_download_tokens WHERE token = $1 AND expires_at > NOW()",
)
.bind(&token_str)
.fetch_optional(&pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
.ok_or((
StatusCode::UNAUTHORIZED,
"Token de descarga inválido o expirado".to_string(),
))?;
let token_project_id: Uuid = token_row.get(0);
if token_project_id != id {
return Err((
StatusCode::FORBIDDEN,
"El token no corresponde a este proyecto".to_string(),
));
}
// Eliminar el token para que sea de un solo uso
let _ = sqlx::query("DELETE FROM agent_download_tokens WHERE token = $1")
.bind(&token_str)
.execute(&pool)
.await;
id
};
// Verificar que el proyecto existe // Verificar que el proyecto existe
let project = sqlx::query_as::<_, EdgeProject>("SELECT * FROM edge_projects WHERE id = $1") let project = sqlx::query_as::<_, EdgeProject>("SELECT * FROM edge_projects WHERE id = $1")
.bind(id) .bind(project_id)
.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()))?
@@ -525,7 +740,7 @@ pub async fn linux_install_script(
r#"INSERT INTO agent_provisioning_tokens (project_id, token, expires_at) r#"INSERT INTO agent_provisioning_tokens (project_id, token, expires_at)
VALUES ($1, $2, $3)"#, VALUES ($1, $2, $3)"#,
) )
.bind(id) .bind(project_id)
.bind(&provisioning_token) .bind(&provisioning_token)
.bind(token_expires_at) .bind(token_expires_at)
.execute(&pool) .execute(&pool)
@@ -533,7 +748,7 @@ pub async fn linux_install_script(
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
// Crear/renovar usuario MQTT de provisioning temporal // Crear/renovar usuario MQTT de provisioning temporal
let prov_mqtt_user = format!("provision:{}", id); let prov_mqtt_user = format!("provision:{}", project_id);
sqlx::query( sqlx::query(
"INSERT INTO mqtt_users (username, password_hash) "INSERT INTO mqtt_users (username, password_hash)
VALUES ($1, crypt($2, gen_salt('bf', 10))) VALUES ($1, crypt($2, gen_salt('bf', 10)))
@@ -547,8 +762,8 @@ pub async fn linux_install_script(
// ACLs de provisioning // ACLs de provisioning
let prov_acls = vec![ let prov_acls = vec![
(format!("provision/request/{}", id), 2i32), (format!("provision/request/{}", project_id), 2i32),
(format!("provision/response/{}", id), 4i32), (format!("provision/response/{}", project_id), 4i32),
]; ];
for (topic, rw) in prov_acls { for (topic, rw) in prov_acls {
sqlx::query( sqlx::query(
@@ -571,7 +786,7 @@ pub async fn linux_install_script(
.unwrap_or_else(|_| "https://tu-servidor.omnioil.io".to_string()); .unwrap_or_else(|_| "https://tu-servidor.omnioil.io".to_string());
let script = generate_install_script( let script = generate_install_script(
&id.to_string(), &project_id.to_string(),
&provisioning_token, &provisioning_token,
&mqtt_host, &mqtt_host,
&mqtt_port, &mqtt_port,
@@ -585,7 +800,10 @@ pub async fn linux_install_script(
(header::CONTENT_TYPE, "text/x-shellscript; charset=utf-8"), (header::CONTENT_TYPE, "text/x-shellscript; charset=utf-8"),
( (
header::CONTENT_DISPOSITION, header::CONTENT_DISPOSITION,
&format!("attachment; filename=\"install-omnioil-{}.sh\"", &id.to_string()[..8]), &format!(
"attachment; filename=\"install-omnioil-{}.sh\"",
&project_id.to_string()[..8]
),
), ),
], ],
script, script,
@@ -602,7 +820,8 @@ fn generate_install_script(
server_url: &str, server_url: &str,
project_name: &str, project_name: &str,
) -> String { ) -> String {
format!(r#"#!/bin/bash format!(
r#"#!/bin/bash
# ============================================================================= # =============================================================================
# OmniOil Edge Agent — Instalador Linux # OmniOil Edge Agent — Instalador Linux
# Proyecto: {project_name} # Proyecto: {project_name}
@@ -744,11 +963,15 @@ pub async fn download_linux_binary(
let arch_tag = match arch.as_str() { let arch_tag = match arch.as_str() {
"x86_64" | "amd64" => "x86_64", "x86_64" | "amd64" => "x86_64",
"aarch64" | "arm64" => "aarch64", "aarch64" | "arm64" => "aarch64",
_ => return Err((StatusCode::BAD_REQUEST, format!("Arquitectura no soportada: {}", arch))), _ => {
return Err((
StatusCode::BAD_REQUEST,
format!("Arquitectura no soportada: {}", arch),
));
}
}; };
let endpoint_url = std::env::var("MINIO_ENDPOINT_URL") let endpoint_url = std::env::var("MINIO_ENDPOINT_URL").expect("MINIO_ENDPOINT_URL must be set");
.expect("MINIO_ENDPOINT_URL must be set");
let access_key = std::env::var("AWS_ACCESS_KEY_ID") let access_key = std::env::var("AWS_ACCESS_KEY_ID")
.or_else(|_| std::env::var("MINIO_USER")) .or_else(|_| std::env::var("MINIO_USER"))
.expect("MinIO credentials must be set"); .expect("MinIO credentials must be set");
@@ -756,9 +979,8 @@ pub async fn download_linux_binary(
.or_else(|_| std::env::var("MINIO_PASSWORD")) .or_else(|_| std::env::var("MINIO_PASSWORD"))
.expect("MinIO credentials must be set"); .expect("MinIO credentials must be set");
let credentials = aws_sdk_s3::config::Credentials::new( let credentials =
access_key, secret_key, None, None, "minio", aws_sdk_s3::config::Credentials::new(access_key, secret_key, None, None, "minio");
);
let config = aws_config::defaults(aws_config::BehaviorVersion::latest()) let config = aws_config::defaults(aws_config::BehaviorVersion::latest())
.region(aws_sdk_s3::config::Region::new("us-east-1")) .region(aws_sdk_s3::config::Region::new("us-east-1"))
.endpoint_url(&endpoint_url) .endpoint_url(&endpoint_url)
@@ -771,25 +993,38 @@ pub async fn download_linux_binary(
let client = aws_sdk_s3::Client::from_conf(s3_config); let client = aws_sdk_s3::Client::from_conf(s3_config);
let key = format!("agents/linux/{}/edge-agent", arch_tag); let key = format!("agents/linux/{}/edge-agent", arch_tag);
let bucket = std::env::var("S3_INSTALLERS_BUCKET") let bucket =
.unwrap_or_else(|_| "omnioil-releases".to_string()); std::env::var("S3_INSTALLERS_BUCKET").unwrap_or_else(|_| "omnioil-releases".to_string());
let get_res = client.get_object() let get_res = client
.get_object()
.bucket(&bucket) .bucket(&bucket)
.key(&key) .key(&key)
.send() .send()
.await .await
.map_err(|e| (StatusCode::NOT_FOUND, format!("Binario Linux no disponible aún: {}", e)))?; .map_err(|e| {
(
StatusCode::NOT_FOUND,
format!("Binario Linux no disponible aún: {}", e),
)
})?;
let bytes = get_res.body.collect().await let bytes = get_res
.body
.collect()
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
Ok(( Ok((
[ [
(header::CONTENT_TYPE, "application/octet-stream"), (header::CONTENT_TYPE, "application/octet-stream"),
(header::CONTENT_DISPOSITION, "attachment; filename=\"edge-agent\""), (
header::CONTENT_DISPOSITION,
"attachment; filename=\"edge-agent\"",
),
], ],
bytes.into_bytes(), bytes.into_bytes(),
).into_response()) )
.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).
@@ -808,13 +1043,19 @@ pub async fn rebuild_installer(
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
if access.is_none() && claims.role != "admin" { if access.is_none() && claims.role != "admin" {
return Err((StatusCode::FORBIDDEN, "Only admins or authorized users can trigger installer rebuilds".to_string())); return Err((
StatusCode::FORBIDDEN,
"Only admins or authorized users can trigger installer rebuilds".to_string(),
));
} }
// Si no tiene acceso directo y es admin, tal vez queramos permitirlo en el futuro, // Si no tiene acceso directo y es admin, tal vez queramos permitirlo en el futuro,
// pero el requerimiento es separacion TOTAL. // pero el requerimiento es separacion TOTAL.
// Así que exigimos acceso directo. // Así que exigimos acceso directo.
if access.is_none() { if access.is_none() {
return Err((StatusCode::FORBIDDEN, "Access denied to this project".to_string())); return Err((
StatusCode::FORBIDDEN,
"Access denied to this project".to_string(),
));
} }
// 2. Obtener datos básicos del proyecto // 2. Obtener datos básicos del proyecto
@@ -842,14 +1083,31 @@ pub async fn rebuild_installer(
timestamp: chrono::Utc::now(), timestamp: chrono::Utc::now(),
}; };
let payload = serde_json::to_string(&event) let payload = serde_json::to_string(&event).map_err(|e| {
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to serialize event: {}", e)))?; (
StatusCode::INTERNAL_SERVER_ERROR,
format!("Failed to serialize event: {}", e),
)
})?;
mqtt.publish("omnioil/events/project_created", rumqttc::QoS::AtLeastOnce, false, payload) mqtt.publish(
.await "omnioil/events/project_created",
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("MQTT Publish failed: {}", e)))?; rumqttc::QoS::AtLeastOnce,
false,
payload,
)
.await
.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("MQTT Publish failed: {}", e),
)
})?;
tracing::info!("🛠️ Reconstrucción de instalador solicitada para proyecto: {}", id); tracing::info!(
"🛠️ Reconstrucción de instalador solicitada para proyecto: {}",
id
);
Ok(Json(serde_json::json!({ Ok(Json(serde_json::json!({
"message": "Reconstrucción de instalador iniciada", "message": "Reconstrucción de instalador iniciada",

View File

@@ -1,14 +1,14 @@
//! Handlers para CRUD de variables Edge. //! Handlers para CRUD de variables Edge.
use crate::auth::Claims;
use axum::{ use axum::{
Json,
extract::{Path, State}, extract::{Path, State},
http::StatusCode, http::StatusCode,
Json,
}; };
use shared_lib::edge_models::*;
use sqlx::PgPool; use sqlx::PgPool;
use uuid::Uuid; use uuid::Uuid;
use shared_lib::edge_models::*;
use crate::auth::Claims;
use crate::errors::AppError; use crate::errors::AppError;
@@ -27,7 +27,7 @@ pub async fn list_variables(
r#"SELECT 1 FROM edge_devices ed r#"SELECT 1 FROM edge_devices ed
JOIN edge_assets ea ON ed.asset_id = ea.id JOIN edge_assets ea ON ed.asset_id = ea.id
JOIN user_project_access upa ON ea.project_id = upa.project_id JOIN user_project_access upa ON ea.project_id = upa.project_id
WHERE ed.id = $1 AND upa.user_id = $2 AND upa.is_active = true"# WHERE ed.id = $1 AND upa.user_id = $2 AND upa.is_active = true"#,
) )
.bind(device_id) .bind(device_id)
.bind(user_id) .bind(user_id)
@@ -35,12 +35,14 @@ pub async fn list_variables(
.await?; .await?;
if access.is_none() { if access.is_none() {
return Err(AppError::Forbidden("Sin acceso a las variables de este dispositivo".to_string())); return Err(AppError::Forbidden(
"Sin acceso a las variables de este dispositivo".to_string(),
));
} }
} }
let variables = sqlx::query_as::<_, EdgeVariable>( let variables = sqlx::query_as::<_, EdgeVariable>(
"SELECT * FROM edge_variables WHERE device_id = $1 ORDER BY alias" "SELECT * FROM edge_variables WHERE device_id = $1 ORDER BY alias",
) )
.bind(device_id) .bind(device_id)
.fetch_all(&pool) .fetch_all(&pool)
@@ -65,7 +67,7 @@ pub async fn create_variable(
r#"SELECT 1 FROM edge_devices ed r#"SELECT 1 FROM edge_devices ed
JOIN edge_assets ea ON ed.asset_id = ea.id JOIN edge_assets ea ON ed.asset_id = ea.id
JOIN user_project_access upa ON ea.project_id = upa.project_id JOIN user_project_access upa ON ea.project_id = upa.project_id
WHERE ed.id = $1 AND upa.user_id = $2 AND upa.is_active = true"# WHERE ed.id = $1 AND upa.user_id = $2 AND upa.is_active = true"#,
) )
.bind(device_id) .bind(device_id)
.bind(user_id) .bind(user_id)
@@ -73,7 +75,9 @@ pub async fn create_variable(
.await?; .await?;
if access.is_none() { if access.is_none() {
return Err(AppError::Forbidden("Sin permiso para crear variables en este dispositivo".to_string())); return Err(AppError::Forbidden(
"Sin permiso para crear variables en este dispositivo".to_string(),
));
} }
} }
@@ -84,7 +88,7 @@ pub async fn create_variable(
byte_order, metadata byte_order, metadata
) )
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
RETURNING *"# RETURNING *"#,
) )
.bind(device_id) .bind(device_id)
.bind(&req.address) .bind(&req.address)
@@ -115,12 +119,12 @@ pub async fn update_variable(
let user_id = Uuid::parse_str(&claims.sub) let user_id = Uuid::parse_str(&claims.sub)
.map_err(|_| AppError::BadRequest("User ID inválido".to_string()))?; .map_err(|_| AppError::BadRequest("User ID inválido".to_string()))?;
let access = sqlx::query( let access = sqlx::query(
r#"SELECT 1 FROM edge_variables ev r#"SELECT 1 FROM edge_variables ev
JOIN edge_devices ed ON ev.device_id = ed.id JOIN edge_devices ed ON ev.device_id = ed.id
JOIN edge_assets ea ON ed.asset_id = ea.id JOIN edge_assets ea ON ed.asset_id = ea.id
JOIN user_project_access upa ON ea.project_id = upa.project_id JOIN user_project_access upa ON ea.project_id = upa.project_id
WHERE ev.id = $1 AND upa.user_id = $2 AND upa.is_active = true"# WHERE ev.id = $1 AND upa.user_id = $2 AND upa.is_active = true"#,
) )
.bind(id) .bind(id)
.bind(user_id) .bind(user_id)
@@ -128,7 +132,9 @@ pub async fn update_variable(
.await?; .await?;
if access.is_none() { if access.is_none() {
return Err(AppError::Forbidden("Sin acceso a esta variable".to_string())); return Err(AppError::Forbidden(
"Sin acceso a esta variable".to_string(),
));
} }
} }
@@ -146,7 +152,7 @@ pub async fn update_variable(
metadata = COALESCE($11, metadata), metadata = COALESCE($11, metadata),
is_enabled = COALESCE($12, is_enabled) is_enabled = COALESCE($12, is_enabled)
WHERE id = $1 WHERE id = $1
RETURNING *"# RETURNING *"#,
) )
.bind(id) .bind(id)
.bind(&req.address) .bind(&req.address)
@@ -178,12 +184,12 @@ pub async fn delete_variable(
let user_id = Uuid::parse_str(&claims.sub) let user_id = Uuid::parse_str(&claims.sub)
.map_err(|_| AppError::BadRequest("User ID inválido".to_string()))?; .map_err(|_| AppError::BadRequest("User ID inválido".to_string()))?;
let access = sqlx::query( let access = sqlx::query(
r#"SELECT 1 FROM edge_variables ev r#"SELECT 1 FROM edge_variables ev
JOIN edge_devices ed ON ev.device_id = ed.id JOIN edge_devices ed ON ev.device_id = ed.id
JOIN edge_assets ea ON ed.asset_id = ea.id JOIN edge_assets ea ON ed.asset_id = ea.id
JOIN user_project_access upa ON ea.project_id = upa.project_id JOIN user_project_access upa ON ea.project_id = upa.project_id
WHERE ev.id = $1 AND upa.user_id = $2 AND upa.is_active = true"# WHERE ev.id = $1 AND upa.user_id = $2 AND upa.is_active = true"#,
) )
.bind(id) .bind(id)
.bind(user_id) .bind(user_id)
@@ -191,7 +197,9 @@ pub async fn delete_variable(
.await?; .await?;
if access.is_none() { if access.is_none() {
return Err(AppError::Forbidden("Sin permiso para eliminar esta variable".to_string())); return Err(AppError::Forbidden(
"Sin permiso para eliminar esta variable".to_string(),
));
} }
} }

View File

@@ -48,7 +48,11 @@ pub async fn health_check(State(pool): State<PgPool>) -> Json<HealthResponse> {
}, },
}; };
let overall = if db_status.status == "ok" { "ok" } else { "degraded" }; let overall = if db_status.status == "ok" {
"ok"
} else {
"degraded"
};
Json(HealthResponse { Json(HealthResponse {
status: overall, status: overall,

View File

@@ -0,0 +1,844 @@
use crate::{AppState, auth::Claims};
use axum::{
Json,
extract::{Path, State},
http::StatusCode,
response::IntoResponse,
};
use csv::ReaderBuilder;
use serde::Deserialize;
use serde_json::{Value, json};
use sqlx::Row;
use std::collections::HashMap;
use uuid::Uuid;
#[derive(Deserialize, Debug)]
pub struct ModbusTcpRecord {
#[serde(rename = "WellCode")]
pub well_code: String,
#[serde(rename = "DeviceName")]
pub device_name: String,
#[serde(rename = "Host")]
pub host: String,
#[serde(rename = "Port")]
pub port: i32,
#[serde(rename = "UnitId")]
pub unit_id: u8,
#[serde(rename = "VariableAlias")]
pub variable_alias: String,
#[serde(rename = "Address")]
pub address: String,
#[serde(rename = "DataType")]
pub data_type: String,
#[serde(rename = "LastCalibrationDate")]
pub last_calibration_date: Option<chrono::NaiveDate>,
#[serde(rename = "NextCalibrationDate")]
pub next_calibration_date: Option<chrono::NaiveDate>,
#[serde(rename = "CalibrationCertificateUrl")]
pub calibration_certificate_url: Option<String>,
}
#[derive(Deserialize, Debug)]
pub struct S7Record {
#[serde(rename = "WellCode")]
pub well_code: String,
#[serde(rename = "DeviceName")]
pub device_name: String,
#[serde(rename = "Host")]
pub host: String,
#[serde(rename = "Port")]
pub port: i32,
#[serde(rename = "Rack")]
pub rack: u16,
#[serde(rename = "Slot")]
pub slot: u16,
#[serde(rename = "VariableAlias")]
pub variable_alias: String,
#[serde(rename = "Address")]
pub address: String,
#[serde(rename = "DataType")]
pub data_type: String,
#[serde(rename = "LastCalibrationDate")]
pub last_calibration_date: Option<chrono::NaiveDate>,
#[serde(rename = "NextCalibrationDate")]
pub next_calibration_date: Option<chrono::NaiveDate>,
#[serde(rename = "CalibrationCertificateUrl")]
pub calibration_certificate_url: Option<String>,
}
#[derive(Deserialize, Debug)]
pub struct OpcUaRecord {
#[serde(rename = "WellCode")]
pub well_code: String,
#[serde(rename = "DeviceName")]
pub device_name: String,
#[serde(rename = "Endpoint")]
pub endpoint: String,
#[serde(rename = "VariableAlias")]
pub variable_alias: String,
#[serde(rename = "NodeId")]
pub node_id: String,
#[serde(rename = "LastCalibrationDate")]
pub last_calibration_date: Option<chrono::NaiveDate>,
#[serde(rename = "NextCalibrationDate")]
pub next_calibration_date: Option<chrono::NaiveDate>,
#[serde(rename = "CalibrationCertificateUrl")]
pub calibration_certificate_url: Option<String>,
}
#[derive(Deserialize, Debug)]
pub struct MqttSparkplugBRecord {
#[serde(rename = "WellCode")]
pub well_code: String,
#[serde(rename = "DeviceName")]
pub device_name: String,
#[serde(rename = "BrokerHost")]
pub broker_host: String,
#[serde(rename = "BrokerPort")]
pub broker_port: i32,
#[serde(rename = "GroupId")]
pub group_id: String,
#[serde(rename = "EdgeNodeId")]
pub edge_node_id: String,
#[serde(rename = "DeviceId")]
pub device_id: String,
#[serde(rename = "VariableAlias")]
pub variable_alias: String,
#[serde(rename = "SparkplugMetric")]
pub sparkplug_metric: String,
#[serde(rename = "LastCalibrationDate")]
pub last_calibration_date: Option<chrono::NaiveDate>,
#[serde(rename = "NextCalibrationDate")]
pub next_calibration_date: Option<chrono::NaiveDate>,
#[serde(rename = "CalibrationCertificateUrl")]
pub calibration_certificate_url: Option<String>,
}
#[derive(Deserialize, Debug)]
pub struct SqlDbRecord {
#[serde(rename = "WellCode")]
pub well_code: String,
#[serde(rename = "DeviceName")]
pub device_name: String,
#[serde(rename = "ConnectionString")]
pub connection_string: String,
#[serde(rename = "Query")]
pub query: String,
#[serde(rename = "VariableAlias")]
pub variable_alias: String,
#[serde(rename = "ColumnName")]
pub column_name: String,
#[serde(rename = "LastCalibrationDate")]
pub last_calibration_date: Option<chrono::NaiveDate>,
#[serde(rename = "NextCalibrationDate")]
pub next_calibration_date: Option<chrono::NaiveDate>,
#[serde(rename = "CalibrationCertificateUrl")]
pub calibration_certificate_url: Option<String>,
}
fn parse_data_type(
s: &str,
) -> Result<shared_lib::edge_models::variables::VariableDataType, String> {
match s.to_lowercase().as_str() {
"bool" | "boolean" => Ok(shared_lib::edge_models::variables::VariableDataType::Bool),
"int16" => Ok(shared_lib::edge_models::variables::VariableDataType::Int16),
"uint16" => Ok(shared_lib::edge_models::variables::VariableDataType::Uint16),
"int32" => Ok(shared_lib::edge_models::variables::VariableDataType::Int32),
"uint32" => Ok(shared_lib::edge_models::variables::VariableDataType::Uint32),
"float32" | "float" => Ok(shared_lib::edge_models::variables::VariableDataType::Float32),
"float64" | "double" => Ok(shared_lib::edge_models::variables::VariableDataType::Float64),
"string" | "text" => Ok(shared_lib::edge_models::variables::VariableDataType::StringType),
_ => Err(format!("Tipo de dato no soportado: {}", s)),
}
}
fn parse_endpoint_host_port(endpoint: &str) -> (String, i32) {
if let Some(stripped) = endpoint.strip_prefix("opc.tcp://") {
let parts: Vec<&str> = stripped
.split('/')
.next()
.unwrap_or("")
.split(':')
.collect();
if parts.len() == 2 {
let host = parts[0].to_string();
let port = parts[1].parse::<i32>().unwrap_or(4840);
return (host, port);
} else if parts.len() == 1 {
return (parts[0].to_string(), 4840);
}
}
(endpoint.to_string(), 4840)
}
// Endpoint for template download
pub async fn download_import_template(
Path((_project_id, protocol)): Path<(Uuid, String)>,
) -> impl IntoResponse {
let (filename, content) = match protocol.to_uppercase().as_str() {
"MODBUS_TCP" => (
"template_modbus_tcp.csv",
"WellCode,DeviceName,Host,Port,UnitId,VariableAlias,Address,DataType,LastCalibrationDate,NextCalibrationDate,CalibrationCertificateUrl\nPOZO-X1,FlowMeter1,192.168.1.100,502,1,Temperature,HR:40001,float32,2026-01-15,2027-01-15,http://example.com/cert.pdf\n",
),
"S7" => (
"template_s7.csv",
"WellCode,DeviceName,Host,Port,Rack,Slot,VariableAlias,Address,DataType,LastCalibrationDate,NextCalibrationDate,CalibrationCertificateUrl\nPOZO-X1,PLC_S7,192.168.1.50,102,0,1,Level,DB1.DBW0,int16,2026-01-15,2027-01-15,http://example.com/cert.pdf\n",
),
"OPC_UA" => (
"template_opc_ua.csv",
"WellCode,DeviceName,Endpoint,VariableAlias,NodeId,LastCalibrationDate,NextCalibrationDate,CalibrationCertificateUrl\nPOZO-X1,SCADA_OPC,opc.tcp://192.168.1.10:4840,Pressure,ns=2;s=Well1.Pressure,2026-01-15,2027-01-15,http://example.com/cert.pdf\n",
),
"MQTT_SPARKPLUG_B" => (
"template_mqtt_sparkplug_b.csv",
"WellCode,DeviceName,BrokerHost,BrokerPort,GroupId,EdgeNodeId,DeviceId,VariableAlias,SparkplugMetric,LastCalibrationDate,NextCalibrationDate,CalibrationCertificateUrl\nPOZO-X1,SparkDevice,192.168.1.80,1883,FieldGroup,Node1,Meter1,FlowRate,Outputs/FlowRate,2026-01-15,2027-01-15,http://example.com/cert.pdf\n",
),
"SQL_DB" => (
"template_sql_db.csv",
"WellCode,DeviceName,ConnectionString,Query,VariableAlias,ColumnName,LastCalibrationDate,NextCalibrationDate,CalibrationCertificateUrl\nPOZO-X1,HistDB,postgresql://user:pass@192.168.1.90/db,SELECT temp FROM logs LIMIT 1,Temperature,temp,2026-01-15,2027-01-15,http://example.com/cert.pdf\n",
),
_ => return (StatusCode::BAD_REQUEST, "Protocolo no soportado").into_response(),
};
(
StatusCode::OK,
[
("Content-Type", "text/csv"),
(
"Content-Disposition",
&format!("attachment; filename=\"{}\"", filename),
),
],
content,
)
.into_response()
}
async fn get_or_create_well(
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
project_id: Uuid,
well_code: &str,
last_calibration_date: Option<chrono::NaiveDate>,
next_calibration_date: Option<chrono::NaiveDate>,
calibration_certificate_url: Option<String>,
cache: &mut HashMap<String, Uuid>,
imported_count: &mut i32,
) -> Result<Uuid, (StatusCode, String)> {
let trimmed = well_code.trim();
if let Some(&id) = cache.get(trimmed) {
return Ok(id);
}
let row = sqlx::query("SELECT id FROM edge_assets WHERE code = $1 AND project_id = $2")
.bind(trimmed)
.bind(project_id)
.fetch_optional(&mut **tx)
.await
.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("Error al buscar pozo: {}", e),
)
})?;
if let Some(r) = row {
let id: Uuid = r
.try_get("id")
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
cache.insert(trimmed.to_string(), id);
// Actualizar calibración si se provee en el CSV para pozo existente
if last_calibration_date.is_some()
|| next_calibration_date.is_some()
|| calibration_certificate_url.is_some()
{
sqlx::query(
r#"UPDATE edge_assets
SET last_calibration_date = COALESCE($1, last_calibration_date),
next_calibration_date = COALESCE($2, next_calibration_date),
calibration_certificate_url = COALESCE($3, calibration_certificate_url),
updated_at = NOW()
WHERE id = $4"#,
)
.bind(last_calibration_date)
.bind(next_calibration_date)
.bind(calibration_certificate_url)
.bind(id)
.execute(&mut **tx)
.await
.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("Error al actualizar calibración de pozo: {}", e),
)
})?;
}
Ok(id)
} else {
let id = Uuid::new_v4();
let well_name = format!("Pozo {}", trimmed);
sqlx::query(
r#"INSERT INTO edge_assets (id, project_id, code, name, asset_type, is_active, last_calibration_date, next_calibration_date, calibration_certificate_url)
VALUES ($1, $2, $3, $4, 'POZO', true, $5, $6, $7)"#,
)
.bind(id)
.bind(project_id)
.bind(trimmed)
.bind(&well_name)
.bind(last_calibration_date)
.bind(next_calibration_date)
.bind(calibration_certificate_url)
.execute(&mut **tx)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Error al insertar pozo: {}", e)))?;
*imported_count += 1;
cache.insert(trimmed.to_string(), id);
Ok(id)
}
}
async fn get_or_create_device(
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
asset_id: Uuid,
device_name: &str,
protocol: &str,
host: &str,
port: i32,
protocol_config: Value,
cache: &mut HashMap<(Uuid, String), Uuid>,
imported_count: &mut i32,
) -> Result<Uuid, (StatusCode, String)> {
let key = (asset_id, device_name.trim().to_string());
if let Some(&id) = cache.get(&key) {
return Ok(id);
}
let row = sqlx::query("SELECT id FROM edge_devices WHERE asset_id = $1 AND name = $2")
.bind(asset_id)
.bind(&key.1)
.fetch_optional(&mut **tx)
.await
.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("Error al buscar dispositivo: {}", e),
)
})?;
if let Some(r) = row {
let id: Uuid = r
.try_get("id")
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
cache.insert(key, id);
Ok(id)
} else {
let id = Uuid::new_v4();
sqlx::query(
r#"INSERT INTO edge_devices (id, asset_id, name, protocol, host, port, protocol_config, is_enabled)
VALUES ($1, $2, $3, $4, $5, $6, $7, true)"#,
)
.bind(id)
.bind(asset_id)
.bind(&key.1)
.bind(protocol)
.bind(host)
.bind(port)
.bind(protocol_config)
.execute(&mut **tx)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Error al insertar dispositivo: {}", e)))?;
*imported_count += 1;
cache.insert(key, id);
Ok(id)
}
}
async fn create_variable(
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
device_id: Uuid,
address: &str,
data_type: shared_lib::edge_models::variables::VariableDataType,
alias: &str,
unit: Option<&str>,
metadata: Value,
) -> Result<(), (StatusCode, String)> {
let dt_str = match data_type {
shared_lib::edge_models::variables::VariableDataType::Bool => "bool",
shared_lib::edge_models::variables::VariableDataType::Int16 => "int16",
shared_lib::edge_models::variables::VariableDataType::Uint16 => "uint16",
shared_lib::edge_models::variables::VariableDataType::Int32 => "int32",
shared_lib::edge_models::variables::VariableDataType::Uint32 => "uint32",
shared_lib::edge_models::variables::VariableDataType::Float32 => "float32",
shared_lib::edge_models::variables::VariableDataType::Float64 => "float64",
shared_lib::edge_models::variables::VariableDataType::StringType => "string",
};
let exists: bool = sqlx::query_scalar(
"SELECT EXISTS(SELECT 1 FROM edge_variables WHERE device_id = $1 AND alias = $2)",
)
.bind(device_id)
.bind(alias.trim())
.fetch_one(&mut **tx)
.await
.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("Error al verificar existencia de variable: {}", e),
)
})?;
if exists {
sqlx::query(
r#"UPDATE edge_variables
SET address = $3, data_type = $4, unit = $5, metadata = $6, updated_at = NOW()
WHERE device_id = $1 AND alias = $2"#,
)
.bind(device_id)
.bind(alias.trim())
.bind(address.trim())
.bind(dt_str)
.bind(unit)
.bind(metadata)
.execute(&mut **tx)
.await
.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("Error al actualizar variable: {}", e),
)
})?;
} else {
sqlx::query(
r#"INSERT INTO edge_variables (device_id, address, data_type, alias, unit, metadata, is_enabled)
VALUES ($1, $2, $3, $4, $5, $6, true)"#,
)
.bind(device_id)
.bind(address.trim())
.bind(dt_str)
.bind(alias.trim())
.bind(unit)
.bind(metadata)
.execute(&mut **tx)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Error al insertar variable: {}", e)))?;
}
Ok(())
}
// POST /api/edge/projects/{project_id}/import-csv/{protocol}
pub async fn import_csv_by_protocol(
State(state): State<AppState>,
claims: Claims,
Path((project_id, protocol)): Path<(Uuid, String)>,
body_csv: String,
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
if claims.role != "admin" {
let user_id = Uuid::parse_str(&claims.sub)
.map_err(|_| (StatusCode::BAD_REQUEST, "Invalid user ID".to_string()))?;
let access = sqlx::query(
"SELECT 1 FROM user_project_access WHERE user_id = $1 AND project_id = $2 AND is_active = true"
)
.bind(user_id)
.bind(project_id)
.fetch_optional(&state.pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
if access.is_none() {
return Err((
StatusCode::FORBIDDEN,
"Access denied to this project".to_string(),
));
}
}
let mut tx = state.pool.begin().await.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("Failed to start transaction: {}", e),
)
})?;
let mut rdr = ReaderBuilder::new()
.has_headers(true)
.flexible(false)
.from_reader(body_csv.as_bytes());
let mut line_num = 1;
let protocol_upper = protocol.to_uppercase();
let mut wells_cache: HashMap<String, Uuid> = HashMap::new();
let mut devices_cache: HashMap<(Uuid, String), Uuid> = HashMap::new();
let mut imported_wells = 0;
let mut imported_devices = 0;
let mut imported_variables = 0;
let headers = rdr
.headers()
.map_err(|e| {
(
StatusCode::BAD_REQUEST,
format!("Fila de cabecera CSV inválida: {}", e),
)
})?
.clone();
for result in rdr.records() {
line_num += 1;
let record = result.map_err(|e| {
(
StatusCode::BAD_REQUEST,
format!("Error en línea {}: CSV mal formateado ({})", line_num, e),
)
})?;
match protocol_upper.as_str() {
"MODBUS_TCP" => {
let rec: ModbusTcpRecord = record.deserialize(Some(&headers)).map_err(|e| {
(
StatusCode::BAD_REQUEST,
format!(
"Error en línea {}: no coincide con el formato Modbus TCP ({})",
line_num, e
),
)
})?;
if rec.well_code.trim().is_empty()
|| rec.device_name.trim().is_empty()
|| rec.variable_alias.trim().is_empty()
{
return Err((
StatusCode::BAD_REQUEST,
format!(
"Error en línea {}: WellCode, DeviceName y VariableAlias son campos requeridos",
line_num
),
));
}
let asset_id = get_or_create_well(
&mut tx,
project_id,
&rec.well_code,
rec.last_calibration_date,
rec.next_calibration_date,
rec.calibration_certificate_url.clone(),
&mut wells_cache,
&mut imported_wells,
)
.await?;
let device_config = json!({ "unit_id": rec.unit_id });
let device_id = get_or_create_device(
&mut tx,
asset_id,
&rec.device_name,
"MODBUS_TCP",
&rec.host,
rec.port,
device_config,
&mut devices_cache,
&mut imported_devices,
)
.await?;
let dt = parse_data_type(&rec.data_type).map_err(|e| {
(
StatusCode::BAD_REQUEST,
format!("Error en línea {}: {}", line_num, e),
)
})?;
create_variable(
&mut tx,
device_id,
&rec.address,
dt,
&rec.variable_alias,
None,
json!({}),
)
.await?;
imported_variables += 1;
}
"S7" => {
let rec: S7Record = record.deserialize(Some(&headers)).map_err(|e| {
(
StatusCode::BAD_REQUEST,
format!(
"Error en línea {}: no coincide con el formato Siemens S7 ({})",
line_num, e
),
)
})?;
if rec.well_code.trim().is_empty()
|| rec.device_name.trim().is_empty()
|| rec.variable_alias.trim().is_empty()
{
return Err((
StatusCode::BAD_REQUEST,
format!(
"Error en línea {}: WellCode, DeviceName y VariableAlias son campos requeridos",
line_num
),
));
}
let asset_id = get_or_create_well(
&mut tx,
project_id,
&rec.well_code,
rec.last_calibration_date,
rec.next_calibration_date,
rec.calibration_certificate_url.clone(),
&mut wells_cache,
&mut imported_wells,
)
.await?;
let device_config = json!({ "rack": rec.rack, "slot": rec.slot });
let device_id = get_or_create_device(
&mut tx,
asset_id,
&rec.device_name,
"S7",
&rec.host,
rec.port,
device_config,
&mut devices_cache,
&mut imported_devices,
)
.await?;
let dt = parse_data_type(&rec.data_type).map_err(|e| {
(
StatusCode::BAD_REQUEST,
format!("Error en línea {}: {}", line_num, e),
)
})?;
create_variable(
&mut tx,
device_id,
&rec.address,
dt,
&rec.variable_alias,
None,
json!({}),
)
.await?;
imported_variables += 1;
}
"OPC_UA" => {
let rec: OpcUaRecord = record.deserialize(Some(&headers)).map_err(|e| {
(
StatusCode::BAD_REQUEST,
format!(
"Error en línea {}: no coincide con el formato OPC UA ({})",
line_num, e
),
)
})?;
if rec.well_code.trim().is_empty()
|| rec.device_name.trim().is_empty()
|| rec.variable_alias.trim().is_empty()
{
return Err((
StatusCode::BAD_REQUEST,
format!(
"Error en línea {}: WellCode, DeviceName y VariableAlias son campos requeridos",
line_num
),
));
}
let asset_id = get_or_create_well(
&mut tx,
project_id,
&rec.well_code,
rec.last_calibration_date,
rec.next_calibration_date,
rec.calibration_certificate_url.clone(),
&mut wells_cache,
&mut imported_wells,
)
.await?;
let device_config = json!({ "endpoint": rec.endpoint });
let (host, port) = parse_endpoint_host_port(&rec.endpoint);
let device_id = get_or_create_device(
&mut tx,
asset_id,
&rec.device_name,
"OPC_UA",
&host,
port,
device_config,
&mut devices_cache,
&mut imported_devices,
)
.await?;
create_variable(
&mut tx,
device_id,
&rec.node_id,
shared_lib::edge_models::variables::VariableDataType::Float32,
&rec.variable_alias,
None,
json!({}),
)
.await?;
imported_variables += 1;
}
"MQTT_SPARKPLUG_B" => {
let rec: MqttSparkplugBRecord = record.deserialize(Some(&headers)).map_err(|e| {
(StatusCode::BAD_REQUEST, format!("Error en línea {}: no coincide con el formato MQTT Sparkplug B ({})", line_num, e))
})?;
if rec.well_code.trim().is_empty()
|| rec.device_name.trim().is_empty()
|| rec.variable_alias.trim().is_empty()
{
return Err((
StatusCode::BAD_REQUEST,
format!(
"Error en línea {}: WellCode, DeviceName y VariableAlias son campos requeridos",
line_num
),
));
}
let asset_id = get_or_create_well(
&mut tx,
project_id,
&rec.well_code,
rec.last_calibration_date,
rec.next_calibration_date,
rec.calibration_certificate_url.clone(),
&mut wells_cache,
&mut imported_wells,
)
.await?;
let device_config = json!({
"group_id": rec.group_id,
"edge_node_id": rec.edge_node_id,
"device_id": rec.device_id
});
let device_id = get_or_create_device(
&mut tx,
asset_id,
&rec.device_name,
"MQTT_SPARKPLUG_B",
&rec.broker_host,
rec.broker_port,
device_config,
&mut devices_cache,
&mut imported_devices,
)
.await?;
create_variable(
&mut tx,
device_id,
&rec.sparkplug_metric,
shared_lib::edge_models::variables::VariableDataType::Float32,
&rec.variable_alias,
None,
json!({}),
)
.await?;
imported_variables += 1;
}
"SQL_DB" => {
let rec: SqlDbRecord = record.deserialize(Some(&headers)).map_err(|e| {
(
StatusCode::BAD_REQUEST,
format!(
"Error en línea {}: no coincide con el formato SQL Database ({})",
line_num, e
),
)
})?;
if rec.well_code.trim().is_empty()
|| rec.device_name.trim().is_empty()
|| rec.variable_alias.trim().is_empty()
{
return Err((
StatusCode::BAD_REQUEST,
format!(
"Error en línea {}: WellCode, DeviceName y VariableAlias son campos requeridos",
line_num
),
));
}
let asset_id = get_or_create_well(
&mut tx,
project_id,
&rec.well_code,
rec.last_calibration_date,
rec.next_calibration_date,
rec.calibration_certificate_url.clone(),
&mut wells_cache,
&mut imported_wells,
)
.await?;
let device_config = json!({ "connection_string": rec.connection_string });
let device_id = get_or_create_device(
&mut tx,
asset_id,
&rec.device_name,
"SQL_DB",
&rec.connection_string,
0,
device_config,
&mut devices_cache,
&mut imported_devices,
)
.await?;
let metadata = json!({ "query": rec.query });
create_variable(
&mut tx,
device_id,
&rec.column_name,
shared_lib::edge_models::variables::VariableDataType::Float32,
&rec.variable_alias,
None,
metadata,
)
.await?;
imported_variables += 1;
}
_ => {
return Err((
StatusCode::BAD_REQUEST,
"Protocolo no soportado para importación".to_string(),
));
}
}
}
tx.commit().await.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("Failed to commit transaction: {}", e),
)
})?;
Ok(Json(json!({
"status": "success",
"wells_imported": imported_wells,
"devices_imported": imported_devices,
"variables_imported": imported_variables,
})))
}

View File

@@ -43,6 +43,7 @@ struct PlatformInvitationActivationRow {
user_email: String, user_email: String,
} }
#[allow(dead_code)]
fn invitation_can_activate( fn invitation_can_activate(
expires_at: DateTime<Utc>, expires_at: DateTime<Utc>,
consumed_at: Option<DateTime<Utc>>, consumed_at: Option<DateTime<Utc>>,

View File

@@ -1,6 +1,6 @@
use crate::auth::Claims; use crate::auth::Claims;
use crate::handlers::audit_helper;
use crate::errors::AppError; use crate::errors::AppError;
use crate::handlers::audit_helper;
use axum::{ use axum::{
Json, Json,
extract::{Query, State}, extract::{Query, State},
@@ -44,7 +44,9 @@ pub async fn list_lab_results(
.await?; .await?;
if access.is_none() { if access.is_none() {
return Err(AppError::Forbidden("Sin permiso para ver resultados de este activo".to_string())); return Err(AppError::Forbidden(
"Sin permiso para ver resultados de este activo".to_string(),
));
} }
let records: Vec<LabResult> = sqlx::query_as::<Postgres, LabResult>( let records: Vec<LabResult> = sqlx::query_as::<Postgres, LabResult>(
@@ -78,7 +80,11 @@ pub async fn create_lab_result(
let project_id: Uuid = match project_row { let project_id: Uuid = match project_row {
Some(row) => row.get("project_id"), Some(row) => row.get("project_id"),
None => return Err(AppError::Forbidden("Sin permiso para registrar resultados en este activo".to_string())), None => {
return Err(AppError::Forbidden(
"Sin permiso para registrar resultados en este activo".to_string(),
));
}
}; };
let record: LabResult = sqlx::query_as::<Postgres, LabResult>( let record: LabResult = sqlx::query_as::<Postgres, LabResult>(
@@ -107,7 +113,8 @@ pub async fn create_lab_result(
None, None,
Some(serde_json::to_value(&record).unwrap_or_default()), Some(serde_json::to_value(&record).unwrap_or_default()),
None, None,
).await; )
.await;
Ok((StatusCode::CREATED, Json(record))) Ok((StatusCode::CREATED, Json(record)))
} }

View File

@@ -46,7 +46,9 @@ pub async fn list_meter_readings(
.await?; .await?;
if access.is_none() { if access.is_none() {
return Err(AppError::Forbidden("Sin permiso para ver lecturas de este activo".to_string())); return Err(AppError::Forbidden(
"Sin permiso para ver lecturas de este activo".to_string(),
));
} }
let records: Vec<MeterReading> = sqlx::query_as::<Postgres, MeterReading>( let records: Vec<MeterReading> = sqlx::query_as::<Postgres, MeterReading>(
@@ -79,7 +81,9 @@ pub async fn create_meter_reading(
.await?; .await?;
if access.is_none() { if access.is_none() {
return Err(AppError::Forbidden("Sin permiso para registrar lecturas en este activo".to_string())); return Err(AppError::Forbidden(
"Sin permiso para registrar lecturas en este activo".to_string(),
));
} }
let record: MeterReading = sqlx::query_as::<Postgres, MeterReading>( let record: MeterReading = sqlx::query_as::<Postgres, MeterReading>(

View File

@@ -4,6 +4,7 @@ pub mod anh_reports;
pub mod audit_helper; pub mod audit_helper;
pub mod auth; pub mod auth;
pub mod billing; pub mod billing;
pub mod billing_webhook;
pub mod dashboard; pub mod dashboard;
pub mod docs; pub mod docs;
pub mod downtime; pub mod downtime;
@@ -14,6 +15,7 @@ pub mod edge_devices;
pub mod edge_projects; pub mod edge_projects;
pub mod edge_variables; pub mod edge_variables;
pub mod health; pub mod health;
pub mod import;
pub mod invitations; pub mod invitations;
pub mod lab_results; pub mod lab_results;
pub mod meters; pub mod meters;

View File

@@ -1,6 +1,6 @@
use crate::auth::Claims; use crate::auth::Claims;
use crate::handlers::audit_helper;
use crate::errors::AppError; use crate::errors::AppError;
use crate::handlers::audit_helper;
use axum::{ use axum::{
Json, Json,
extract::{Query, State}, extract::{Query, State},
@@ -48,7 +48,9 @@ pub async fn list_movements(
.await?; .await?;
if access.is_none() { if access.is_none() {
return Err(AppError::Forbidden("Sin permiso para ver movimientos de este activo".to_string())); return Err(AppError::Forbidden(
"Sin permiso para ver movimientos de este activo".to_string(),
));
} }
let movements: Vec<OperationMovement> = sqlx::query_as::<Postgres, OperationMovement>( let movements: Vec<OperationMovement> = sqlx::query_as::<Postgres, OperationMovement>(
@@ -83,7 +85,11 @@ pub async fn create_movement(
let project_id: Uuid = match project_row { let project_id: Uuid = match project_row {
Some(row) => row.get("project_id"), Some(row) => row.get("project_id"),
None => return Err(AppError::Forbidden("Sin permiso para registrar movimientos en este activo".to_string())), None => {
return Err(AppError::Forbidden(
"Sin permiso para registrar movimientos en este activo".to_string(),
));
}
}; };
let details = serde_json::json!({ let details = serde_json::json!({
@@ -119,8 +125,8 @@ pub async fn create_movement(
None, None,
Some(serde_json::to_value(&movement).unwrap_or_default()), Some(serde_json::to_value(&movement).unwrap_or_default()),
None, // Podríamos extraer IP del extractor ConnectInfo de axum None, // Podríamos extraer IP del extractor ConnectInfo de axum
).await; )
.await;
Ok((StatusCode::CREATED, Json(movement))) Ok((StatusCode::CREATED, Json(movement)))
} }

View File

@@ -130,6 +130,7 @@ pub async fn create_platform_operator(
to: &email, to: &email,
subject: &email_body.subject, subject: &email_body.subject,
body: &email_body.body, body: &email_body.body,
html_body: email_body.html_body.as_deref(),
}) })
.await; .await;

View File

@@ -12,11 +12,11 @@
//! 5. Limpia el usuario MQTT temporal. //! 5. Limpia el usuario MQTT temporal.
//! 6. Solo el agente puede descifrar la respuesta con su clave privada. //! 6. Solo el agente puede descifrar la respuesta con su clave privada.
use axum::{Json, extract::State, http::StatusCode};
use chrono::Utc; use chrono::Utc;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use sqlx::PgPool; use sqlx::PgPool;
use uuid::Uuid; use uuid::Uuid;
use axum::{Json, extract::State, http::StatusCode};
// ─── Mensajes MQTT ──────────────────────────────────────────────────────────── // ─── Mensajes MQTT ────────────────────────────────────────────────────────────
@@ -63,7 +63,12 @@ pub async fn handle_mqtt_provisioning(
.bind(project_id) .bind(project_id)
.fetch_optional(pool) .fetch_optional(pool)
.await? .await?
.ok_or_else(|| anyhow::anyhow!("No hay token de provisioning activo para proyecto {}", project_id))?; .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")?; let token_id: Uuid = sqlx::Row::try_get(&token_row, "id")?;
@@ -86,13 +91,12 @@ pub async fn handle_mqtt_provisioning(
.await?; .await?;
// 5. Obtener config TOML del proyecto // 5. Obtener config TOML del proyecto
let config_toml: Option<String> = sqlx::query_scalar( let config_toml: Option<String> =
"SELECT agent_config_toml FROM edge_projects WHERE id = $1", sqlx::query_scalar("SELECT agent_config_toml FROM edge_projects WHERE id = $1")
) .bind(project_id)
.bind(project_id) .fetch_optional(pool)
.fetch_optional(pool) .await?
.await? .flatten();
.flatten();
let config_toml = config_toml.ok_or_else(|| { let config_toml = config_toml.ok_or_else(|| {
anyhow::anyhow!( anyhow::anyhow!(
@@ -118,7 +122,7 @@ pub async fn handle_mqtt_provisioning(
.publish( .publish(
&response_topic, &response_topic,
rumqttc::QoS::AtLeastOnce, rumqttc::QoS::AtLeastOnce,
true, // retain=true para que la entrega no dependa del timing true, // retain=true para que la entrega no dependa del timing
response_payload, response_payload,
) )
.await .await
@@ -167,13 +171,12 @@ pub async fn rotate_provisioning_token(
let _ = claims; let _ = claims;
let db_err = |e: sqlx::Error| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()); let db_err = |e: sqlx::Error| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string());
let exists: Option<bool> = sqlx::query_scalar( let exists: Option<bool> =
"SELECT EXISTS(SELECT 1 FROM edge_projects WHERE id = $1)", sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM edge_projects WHERE id = $1)")
) .bind(project_id)
.bind(project_id) .fetch_one(&pool)
.fetch_one(&pool) .await
.await .map_err(db_err)?;
.map_err(db_err)?;
if !exists.unwrap_or(false) { if !exists.unwrap_or(false) {
return Err((StatusCode::NOT_FOUND, "Proyecto no encontrado".to_string())); return Err((StatusCode::NOT_FOUND, "Proyecto no encontrado".to_string()));

View File

@@ -4,17 +4,17 @@
//! - `telemetry_raw` → Datos crudos, retención 31 días. Usado para gráfica en tiempo real. //! - `telemetry_raw` → Datos crudos, retención 31 días. Usado para gráfica en tiempo real.
//! - `telemetry_5min` → Agregado cada 5 min, retención 5 años. Usado para historial ANH. //! - `telemetry_5min` → Agregado cada 5 min, retención 5 años. Usado para historial ANH.
use crate::auth::Claims;
use axum::{ use axum::{
Json,
extract::{Path, Query, State}, extract::{Path, Query, State},
http::StatusCode, http::StatusCode,
Json,
}; };
use chrono::{Duration, Utc}; use chrono::{Duration, Utc};
use serde::Deserialize; use serde::Deserialize;
use shared_lib::models::{PostTelemetryRequest, TelemetryRecord, TelemetryResponse};
use sqlx::PgPool; use sqlx::PgPool;
use uuid::Uuid; use uuid::Uuid;
use shared_lib::models::{PostTelemetryRequest, TelemetryRecord, TelemetryResponse};
use crate::auth::Claims;
/// POST /api/telemetry — Ingresar una medición de campo (solo frontend/admin con JWT). /// POST /api/telemetry — Ingresar una medición de campo (solo frontend/admin con JWT).
/// ///
@@ -83,16 +83,24 @@ pub async fn get_asset_telemetry(
let access = sqlx::query( let access = sqlx::query(
r#"SELECT 1 FROM edge_assets ea r#"SELECT 1 FROM edge_assets ea
JOIN user_project_access upa ON ea.project_id = upa.project_id JOIN user_project_access upa ON ea.project_id = upa.project_id
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).map_err(|_| (StatusCode::UNAUTHORIZED, "User ID inválido en token".to_string()))?) .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()))?;
if access.is_none() { if access.is_none() {
return Err((StatusCode::FORBIDDEN, "Access denied to this asset's telemetry".to_string())); return Err((
StatusCode::FORBIDDEN,
"Access denied to this asset's telemetry".to_string(),
));
} }
let limit = params.limit.unwrap_or(10).min(100); let limit = params.limit.unwrap_or(10).min(100);
@@ -128,14 +136,18 @@ pub async fn get_asset_telemetry_history(
Path(asset_id): Path<Uuid>, Path(asset_id): Path<Uuid>,
Query(params): Query<TelemetryHistoryParams>, Query(params): Query<TelemetryHistoryParams>,
) -> Result<Json<serde_json::Value>, (StatusCode, String)> { ) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
let user_id = Uuid::parse_str(&claims.sub) let user_id = Uuid::parse_str(&claims.sub).map_err(|_| {
.map_err(|_| (StatusCode::UNAUTHORIZED, "User ID inválido en token".to_string()))?; (
StatusCode::UNAUTHORIZED,
"User ID inválido en token".to_string(),
)
})?;
// Validar acceso // Validar acceso
let access = sqlx::query( let access = sqlx::query(
r#"SELECT 1 FROM edge_assets ea r#"SELECT 1 FROM edge_assets ea
JOIN user_project_access upa ON ea.project_id = upa.project_id JOIN user_project_access upa ON ea.project_id = upa.project_id
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(user_id) .bind(user_id)
@@ -144,7 +156,10 @@ pub async fn get_asset_telemetry_history(
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
if access.is_none() { if access.is_none() {
return Err((StatusCode::FORBIDDEN, "Access denied to this asset's telemetry".to_string())); return Err((
StatusCode::FORBIDDEN,
"Access denied to this asset's telemetry".to_string(),
));
} }
// Calcular ventana de tiempo: máximo 5 años (retención del agregado) // Calcular ventana de tiempo: máximo 5 años (retención del agregado)
@@ -195,4 +210,3 @@ pub async fn get_asset_telemetry_history(
"count": records.len() "count": records.len()
}))) })))
} }

View File

@@ -272,11 +272,12 @@ pub async fn status(
PlatformClaims(claims): PlatformClaims, PlatformClaims(claims): PlatformClaims,
) -> Result<Json<TotpStatusResponse>, AuthError> { ) -> Result<Json<TotpStatusResponse>, AuthError> {
let user_id = uuid::Uuid::parse_str(&claims.sub).map_err(|_| AuthError::InvalidCredentials)?; let user_id = uuid::Uuid::parse_str(&claims.sub).map_err(|_| AuthError::InvalidCredentials)?;
let two_factor_enabled: bool = sqlx::query_scalar("SELECT two_factor_enabled FROM users WHERE id = $1") let two_factor_enabled: bool =
.bind(user_id) sqlx::query_scalar("SELECT two_factor_enabled FROM users WHERE id = $1")
.fetch_one(&pool) .bind(user_id)
.await .fetch_one(&pool)
.map_err(|_| AuthError::InvalidCredentials)?; .await
.map_err(|_| AuthError::InvalidCredentials)?;
Ok(Json(TotpStatusResponse { two_factor_enabled })) Ok(Json(TotpStatusResponse { two_factor_enabled }))
} }

View File

@@ -16,11 +16,11 @@
//! Todo el tráfico pasa por el backend API que valida autenticación y puede auditar. //! Todo el tráfico pasa por el backend API que valida autenticación y puede auditar.
use axum::{ use axum::{
Json,
body::Body, body::Body,
extract::{Query, State}, extract::{Query, State},
http::{HeaderMap, HeaderValue, StatusCode, header}, http::{HeaderMap, HeaderValue, StatusCode, header},
response::{IntoResponse, Response}, response::{IntoResponse, Response},
Json,
}; };
use semver::Version; use semver::Version;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
@@ -82,7 +82,7 @@ pub async fn get_update_info(
return err( return err(
StatusCode::BAD_REQUEST, StatusCode::BAD_REQUEST,
&format!("Invalid version format: '{}'", params.version), &format!("Invalid version format: '{}'", params.version),
) );
} }
}; };
@@ -110,7 +110,7 @@ pub async fn get_update_info(
let available = if params.target.is_some() { let available = if params.target.is_some() {
latest != current // rollback/pin: cualquier versión distinta latest != current // rollback/pin: cualquier versión distinta
} else { } else {
latest > current // ciclo periódico: solo avanzar latest > current // ciclo periódico: solo avanzar
}; };
if !available { if !available {
@@ -129,8 +129,8 @@ pub async fn get_update_info(
let sha256 = fetch_sha256_internal(&latest.to_string(), "msi").await; let sha256 = fetch_sha256_internal(&latest.to_string(), "msi").await;
// 5. download_url apunta al endpoint del BACKEND, no a MinIO directamente // 5. download_url apunta al endpoint del BACKEND, no a MinIO directamente
let api_base = std::env::var("API_BASE_URL") let api_base =
.unwrap_or_else(|_| "https://api.omnioil.com".to_string()); std::env::var("API_BASE_URL").unwrap_or_else(|_| "https://api.omnioil.com".to_string());
let download_url = format!( let download_url = format!(
"{}/api/edge/download-agent?version={}&asset_type=msi", "{}/api/edge/download-agent?version={}&asset_type=msi",
api_base.trim_end_matches('/'), api_base.trim_end_matches('/'),
@@ -160,21 +160,28 @@ pub async fn download_agent(
let asset_type = params.asset_type.as_deref().unwrap_or("msi"); let asset_type = params.asset_type.as_deref().unwrap_or("msi");
let version = params.version.trim_start_matches('v'); let version = params.version.trim_start_matches('v');
let minio_internal = std::env::var("MINIO_INTERNAL_URL") let minio_internal =
.unwrap_or_else(|_| "http://anh_minio:9000".to_string()); std::env::var("MINIO_INTERNAL_URL").unwrap_or_else(|_| "http://anh_minio:9000".to_string());
let bucket = std::env::var("MINIO_BUCKET") let bucket = std::env::var("MINIO_BUCKET").unwrap_or_else(|_| "omnioil-releases".to_string());
.unwrap_or_else(|_| "omnioil-releases".to_string());
// Convención de paths en MinIO: // Convención de paths en MinIO:
// releases/edge-agent/v0.2.1/omnioil-edge-agent-windows-amd64.msi // releases/edge-agent/v0.2.1/omnioil-edge-agent-windows-amd64.msi
let filename = match asset_type { let filename = match asset_type {
"exe" => format!("omnioil-edge-agent-windows-amd64.exe"), "exe" => format!("omnioil-edge-agent-windows-amd64.exe"),
_ => format!("omnioil-edge-agent-windows-amd64.msi"), _ => format!("omnioil-edge-agent-windows-amd64.msi"),
}; };
let object_key = format!("releases/edge-agent/v{}/{}", version, filename); let object_key = format!("releases/edge-agent/v{}/{}", version, filename);
let minio_url = format!("{}/{}/{}", minio_internal.trim_end_matches('/'), bucket, object_key); let minio_url = format!(
"{}/{}/{}",
minio_internal.trim_end_matches('/'),
bucket,
object_key
);
info!("📦 Proxying agent download: {} → {}", params.version, object_key); info!(
"📦 Proxying agent download: {} → {}",
params.version, object_key
);
// Descargar desde MinIO (red interna — sin autenticación pública) // Descargar desde MinIO (red interna — sin autenticación pública)
let minio_token = std::env::var("MINIO_ACCESS_KEY").ok(); let minio_token = std::env::var("MINIO_ACCESS_KEY").ok();
@@ -214,7 +221,10 @@ pub async fn download_agent(
return if status.as_u16() == 404 { return if status.as_u16() == 404 {
err( err(
StatusCode::NOT_FOUND, StatusCode::NOT_FOUND,
&format!("Agent binary v{} ({}) not found in storage", version, asset_type), &format!(
"Agent binary v{} ({}) not found in storage",
version, asset_type
),
) )
} else { } else {
err(StatusCode::BAD_GATEWAY, "Asset storage error") err(StatusCode::BAD_GATEWAY, "Asset storage error")
@@ -227,7 +237,7 @@ pub async fn download_agent(
// Stream de respuesta al agente // Stream de respuesta al agente
let content_type = match asset_type { let content_type = match asset_type {
"exe" => "application/vnd.microsoft.portable-executable", "exe" => "application/vnd.microsoft.portable-executable",
_ => "application/x-msi", _ => "application/x-msi",
}; };
let mut headers = HeaderMap::new(); let mut headers = HeaderMap::new();
@@ -243,27 +253,26 @@ pub async fn download_agent(
} }
} }
( (StatusCode::OK, headers, Body::from_stream(byte_stream)).into_response()
StatusCode::OK,
headers,
Body::from_stream(byte_stream),
)
.into_response()
} }
/// Obtiene el SHA-256 del asset desde MinIO internamente. /// Obtiene el SHA-256 del asset desde MinIO internamente.
pub async fn fetch_sha256_internal(version: &str, asset_type: &str) -> Option<String> { pub async fn fetch_sha256_internal(version: &str, asset_type: &str) -> Option<String> {
let minio_internal = std::env::var("MINIO_INTERNAL_URL") let minio_internal =
.unwrap_or_else(|_| "http://anh_minio:9000".to_string()); std::env::var("MINIO_INTERNAL_URL").unwrap_or_else(|_| "http://anh_minio:9000".to_string());
let bucket = std::env::var("MINIO_BUCKET") let bucket = std::env::var("MINIO_BUCKET").unwrap_or_else(|_| "omnioil-releases".to_string());
.unwrap_or_else(|_| "omnioil-releases".to_string());
let ext = if asset_type == "exe" { "exe" } else { "msi" }; let ext = if asset_type == "exe" { "exe" } else { "msi" };
let sha_key = format!( let sha_key = format!(
"releases/edge-agent/v{}/omnioil-edge-agent-windows-amd64.{}.sha256", "releases/edge-agent/v{}/omnioil-edge-agent-windows-amd64.{}.sha256",
version, ext version, ext
); );
let url = format!("{}/{}/{}", minio_internal.trim_end_matches('/'), bucket, sha_key); let url = format!(
"{}/{}/{}",
minio_internal.trim_end_matches('/'),
bucket,
sha_key
);
let client = reqwest::Client::builder() let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(10)) .timeout(std::time::Duration::from_secs(10))
@@ -280,11 +289,13 @@ pub async fn fetch_sha256_internal(version: &str, asset_type: &str) -> Option<St
let resp = req.send().await.ok()?; let resp = req.send().await.ok()?;
if !resp.status().is_success() { if !resp.status().is_success() {
warn!("SHA-256 not found in MinIO for agent v{} ({})", version, asset_type); warn!(
"SHA-256 not found in MinIO for agent v{} ({})",
version, asset_type
);
return None; return None;
} }
let text = resp.text().await.ok()?; let text = resp.text().await.ok()?;
Some(text.split_whitespace().next()?.to_lowercase()) Some(text.split_whitespace().next()?.to_lowercase())
} }

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
use crate::auth::Claims; use crate::auth::Claims;
use crate::handlers::audit_helper;
use crate::errors::AppError; use crate::errors::AppError;
use crate::handlers::audit_helper;
use axum::{ use axum::{
Json, Json,
extract::{Query, State}, extract::{Query, State},
@@ -52,7 +52,9 @@ pub async fn list_well_tests(
.await?; .await?;
if access.is_none() { if access.is_none() {
return Err(AppError::Forbidden("Sin permiso para ver pruebas de este activo".to_string())); return Err(AppError::Forbidden(
"Sin permiso para ver pruebas de este activo".to_string(),
));
} }
let records: Vec<WellTest> = sqlx::query_as::<Postgres, WellTest>( let records: Vec<WellTest> = sqlx::query_as::<Postgres, WellTest>(
@@ -86,7 +88,11 @@ pub async fn create_well_test(
let project_id: Uuid = match project_row { let project_id: Uuid = match project_row {
Some(row) => row.get("project_id"), Some(row) => row.get("project_id"),
None => return Err(AppError::Forbidden("Sin permiso para registrar pruebas en este activo".to_string())), None => {
return Err(AppError::Forbidden(
"Sin permiso para registrar pruebas en este activo".to_string(),
));
}
}; };
let record: WellTest = sqlx::query_as::<Postgres, WellTest>( let record: WellTest = sqlx::query_as::<Postgres, WellTest>(
@@ -122,7 +128,8 @@ pub async fn create_well_test(
None, None,
Some(serde_json::to_value(&record).unwrap_or_default()), Some(serde_json::to_value(&record).unwrap_or_default()),
None, None,
).await; )
.await;
Ok((StatusCode::CREATED, Json(record))) Ok((StatusCode::CREATED, Json(record)))
} }

View File

@@ -12,7 +12,9 @@ use backend_api::{
rate_limiter, watchdog, rate_limiter, watchdog,
}; };
use dotenvy::dotenv; use dotenvy::dotenv;
use serde::Deserialize; use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use sqlx::Row;
use std::net::SocketAddr; use std::net::SocketAddr;
use tokio::sync::broadcast; use tokio::sync::broadcast;
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
@@ -289,9 +291,17 @@ async fn main() {
let pool_ref = prov_pool.clone(); let pool_ref = prov_pool.clone();
let payload_bytes = publish.payload.to_vec(); let payload_bytes = publish.payload.to_vec();
tokio::spawn(async move { tokio::spawn(async move {
if let Ok(result) = serde_json::from_slice::<serde_json::Value>(&payload_bytes) { if let Ok(result) =
let scan_id_str = result.get("scan_id").and_then(|v| v.as_str()).unwrap_or(""); serde_json::from_slice::<serde_json::Value>(&payload_bytes)
let status = result.get("status").and_then(|v| v.as_str()).unwrap_or("failed"); {
let scan_id_str = result
.get("scan_id")
.and_then(|v| v.as_str())
.unwrap_or("");
let status = result
.get("status")
.and_then(|v| v.as_str())
.unwrap_or("failed");
let error_msg = result.get("error").and_then(|v| v.as_str()); let error_msg = result.get("error").and_then(|v| v.as_str());
if let Ok(scan_id) = uuid::Uuid::parse_str(scan_id_str) { if let Ok(scan_id) = uuid::Uuid::parse_str(scan_id_str) {
let _ = sqlx::query( let _ = sqlx::query(
@@ -305,7 +315,11 @@ async fn main() {
.bind(scan_id) .bind(scan_id)
.execute(&pool_ref) .execute(&pool_ref)
.await; .await;
tracing::info!("✅ OPC UA scan result stored: {} ({})", scan_id, status); tracing::info!(
"✅ OPC UA scan result stored: {} ({})",
scan_id,
status
);
} }
} }
}); });
@@ -424,10 +438,22 @@ async fn main() {
"/projects/{id}/rebuild", "/projects/{id}/rebuild",
post(handlers::edge_projects::rebuild_installer), post(handlers::edge_projects::rebuild_installer),
) )
.route(
"/projects/{id}/import-template/{protocol}",
get(handlers::import::download_import_template),
)
.route(
"/projects/{id}/import-csv/{protocol}",
post(handlers::import::import_csv_by_protocol),
)
.route( .route(
"/projects/{id}/linux-install", "/projects/{id}/linux-install",
get(handlers::edge_projects::linux_install_script), get(handlers::edge_projects::linux_install_script),
) )
.route(
"/projects/{id}/linux-install-token",
post(handlers::edge_projects::generate_download_token),
)
.route( .route(
"/agent/linux-binary/{arch}", "/agent/linux-binary/{arch}",
get(handlers::edge_projects::download_linux_binary), get(handlers::edge_projects::download_linux_binary),
@@ -622,6 +648,14 @@ async fn main() {
"/users", "/users",
get(handlers::users::list_users).post(handlers::users::create_user), get(handlers::users::list_users).post(handlers::users::create_user),
) )
.route(
"/users/me",
get(handlers::users::get_current_user).put(handlers::users::update_current_user),
)
.route(
"/users/me/reset-password",
post(handlers::users::reset_current_user_password),
)
.route( .route(
"/users/{id}", "/users/{id}",
get(handlers::users::get_user).put(handlers::users::update_user), get(handlers::users::get_user).put(handlers::users::update_user),
@@ -724,6 +758,10 @@ async fn main() {
// Swagger UI y spec OpenAPI (públicos, sin JWT) // Swagger UI y spec OpenAPI (públicos, sin JWT)
.route("/docs", get(handlers::docs::swagger_ui)) .route("/docs", get(handlers::docs::swagger_ui))
.route("/docs/openapi.yaml", get(handlers::docs::openapi_yaml)) .route("/docs/openapi.yaml", get(handlers::docs::openapi_yaml))
.route(
"/api/billing/webhook",
post(handlers::billing_webhook::stripe_webhook),
)
// Metrics endpoint (internal only — Prometheus scrapes this; not JWT-protected) // Metrics endpoint (internal only — Prometheus scrapes this; not JWT-protected)
.route( .route(
"/metrics", "/metrics",
@@ -749,10 +787,37 @@ async fn main() {
get(handlers::telemetry::get_asset_telemetry_history), get(handlers::telemetry::get_asset_telemetry_history),
) )
// Reportes ANH (Resolución 0651) // Reportes ANH (Resolución 0651)
.route("/api/anh/reports", get(handlers::anh_reports::list_reports)) .route(
"/api/anh/reports",
get(handlers::anh_reports::list_reports).post(handlers::anh_reports::generate_report),
)
.route( .route(
"/api/anh/reports/{id}", "/api/anh/reports/{id}",
get(handlers::anh_reports::get_report), get(handlers::anh_reports::get_report).put(handlers::anh_reports::update_report),
)
.route(
"/api/anh/reports/{id}/approve",
post(handlers::anh_reports::approve_report),
)
.route(
"/api/anh/reports/{id}/versions",
get(handlers::anh_reports::get_report_versions),
)
.route(
"/api/anh/reports/{id}/rejection",
post(handlers::anh_reports::register_report_rejection),
)
.route(
"/api/anh/reports/{id}/corrections",
post(handlers::anh_reports::create_report_correction),
)
.route(
"/api/anh/reports/{id}/available-for-anh",
post(handlers::anh_reports::mark_report_available_for_anh),
)
.route(
"/api/anh/reports/{id}/audit-logs",
get(handlers::anh_reports::get_report_audit_logs),
) )
.route( .route(
"/api/anh/reports/{id}/download", "/api/anh/reports/{id}/download",
@@ -762,6 +827,14 @@ async fn main() {
"/api/anh/reports/{id}/hash", "/api/anh/reports/{id}/hash",
get(handlers::anh_reports::download_report_hash), get(handlers::anh_reports::download_report_hash),
) )
.route(
"/api/anh/report-schedules",
get(handlers::anh_reports::list_report_schedules),
)
.route(
"/api/anh/report-schedules/{id}",
put(handlers::anh_reports::update_report_schedule),
)
// Alert Rules & Alarm history // Alert Rules & Alarm history
.route( .route(
"/api/alert-rules", "/api/alert-rules",
@@ -778,6 +851,14 @@ async fn main() {
patch(handlers::alert_rules::toggle_alert_rule), patch(handlers::alert_rules::toggle_alert_rule),
) )
.route("/api/alarms", get(handlers::alert_rules::list_alarms)) .route("/api/alarms", get(handlers::alert_rules::list_alarms))
.route(
"/api/alarms/acknowledge",
patch(handlers::alert_rules::acknowledge_alarms),
)
.route(
"/api/alarms/{id}/acknowledge",
patch(handlers::alert_rules::acknowledge_alarm),
)
// Billing & Usage Metering // Billing & Usage Metering
.route( .route(
"/api/billing/usage", "/api/billing/usage",
@@ -834,6 +915,7 @@ async fn main() {
// Edge System API // Edge System API
.nest("/api/edge", edge_routes) .nest("/api/edge", edge_routes)
// WebSocket Stream (Relay de Telemetría) // WebSocket Stream (Relay de Telemetría)
.route("/api/ws/telemetry/ticket", post(create_ws_telemetry_ticket))
.route("/api/ws/telemetry", get(ws_telemetry_handler)) .route("/api/ws/telemetry", get(ws_telemetry_handler))
// Global rate limiter: 60 req/min per IP // Global rate limiter: 60 req/min per IP
.layer(axum::middleware::from_fn_with_state( .layer(axum::middleware::from_fn_with_state(
@@ -861,9 +943,96 @@ async fn root() -> axum::Json<serde_json::Value> {
#[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, /// Short-lived opaque one-time ticket. Never pass the primary JWT in the URL.
/// por eso se acepta como query param. ticket: Option<String>,
token: Option<String>, }
#[derive(Deserialize)]
struct CreateWsTicketRequest {
project_id: String,
}
#[derive(Serialize)]
struct CreateWsTicketResponse {
ticket: String,
expires_in: u64,
}
const WS_TICKET_TTL_SECONDS: u64 = 60;
async fn create_ws_telemetry_ticket(
State(state): State<AppState>,
claims: auth::Claims,
axum::Json(req): axum::Json<CreateWsTicketRequest>,
) -> impl IntoResponse {
let user_id = match uuid::Uuid::parse_str(&claims.sub) {
Ok(user_id) => user_id,
Err(_) => {
return (
axum::http::StatusCode::UNAUTHORIZED,
axum::Json(serde_json::json!({"error": "Usuario inválido"})),
)
.into_response();
}
};
let project_id = match uuid::Uuid::parse_str(&req.project_id) {
Ok(project_id) => project_id,
Err(_) => {
return (
axum::http::StatusCode::BAD_REQUEST,
axum::Json(serde_json::json!({"error": "project_id inválido"})),
)
.into_response();
}
};
match user_can_access_project(&state.pool, user_id, project_id).await {
Ok(true) => {}
Ok(false) => {
return (
axum::http::StatusCode::FORBIDDEN,
axum::Json(serde_json::json!({"error": "Access denied to this project"})),
)
.into_response();
}
Err(error) => {
tracing::error!(error = %error, "Failed to check WebSocket project access");
return (
axum::http::StatusCode::INTERNAL_SERVER_ERROR,
axum::Json(serde_json::json!({"error": "Ticket creation failed"})),
)
.into_response();
}
}
let ticket = format!("wst_{}", uuid::Uuid::new_v4().simple());
let token_hash = hash_ws_ticket(&ticket);
let expires_in = WS_TICKET_TTL_SECONDS as i64;
if let Err(error) = sqlx::query(
r#"INSERT INTO websocket_tickets (token_hash, user_id, project_id, expires_at)
VALUES ($1, $2, $3, NOW() + ($4::text)::interval)"#,
)
.bind(token_hash)
.bind(user_id)
.bind(project_id)
.bind(format!("{} seconds", expires_in))
.execute(&state.pool)
.await
{
tracing::error!(error = %error, "Failed to persist WebSocket ticket");
return (
axum::http::StatusCode::INTERNAL_SERVER_ERROR,
axum::Json(serde_json::json!({"error": "Ticket creation failed"})),
)
.into_response();
}
axum::Json(CreateWsTicketResponse {
ticket,
expires_in: WS_TICKET_TTL_SECONDS,
})
.into_response()
} }
async fn ws_telemetry_handler( async fn ws_telemetry_handler(
@@ -871,26 +1040,33 @@ 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 ticket = match &query.ticket {
let token = match &query.token { Some(ticket) => ticket.as_str(),
Some(t) => t.as_str(),
None => { None => {
return ( return (
axum::http::StatusCode::UNAUTHORIZED, axum::http::StatusCode::UNAUTHORIZED,
axum::Json(serde_json::json!({"error": "Token requerido para conexión WebSocket"})), axum::Json(
serde_json::json!({"error": "Ticket requerido para conexión WebSocket"}),
),
) )
.into_response(); .into_response();
} }
}; };
if 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 = match consume_ws_ticket(&state.pool, ticket, query.project_id.as_deref()).await
{
Ok(project_id) => project_id,
Err(status) => {
let message = if status == axum::http::StatusCode::FORBIDDEN {
"Ticket no corresponde al proyecto solicitado"
} else {
"Ticket inválido o expirado"
};
return (status, axum::Json(serde_json::json!({"error": message}))).into_response();
}
};
let project_id = Some(project_id.to_string());
tracing::debug!( tracing::debug!(
"New WebSocket upgrade request for telemetry. Filter: {:?}", "New WebSocket upgrade request for telemetry. Filter: {:?}",
project_id project_id
@@ -898,6 +1074,72 @@ async fn ws_telemetry_handler(
ws.on_upgrade(move |socket| handle_socket(socket, state, project_id)) ws.on_upgrade(move |socket| handle_socket(socket, state, project_id))
} }
fn hash_ws_ticket(ticket: &str) -> String {
let mut hasher = Sha256::new();
hasher.update(ticket.as_bytes());
hasher
.finalize()
.iter()
.map(|byte| format!("{byte:02x}"))
.collect()
}
async fn user_can_access_project(
pool: &sqlx::PgPool,
user_id: uuid::Uuid,
project_id: uuid::Uuid,
) -> Result<bool, sqlx::Error> {
let access = sqlx::query(
r#"SELECT 1
FROM user_project_access
WHERE user_id = $1
AND project_id = $2
AND is_active = true"#,
)
.bind(user_id)
.bind(project_id)
.fetch_optional(pool)
.await?;
Ok(access.is_some())
}
async fn consume_ws_ticket(
pool: &sqlx::PgPool,
ticket: &str,
requested_project_id: Option<&str>,
) -> Result<uuid::Uuid, axum::http::StatusCode> {
let token_hash = hash_ws_ticket(ticket);
let row = sqlx::query(
r#"UPDATE websocket_tickets
SET used_at = NOW()
WHERE token_hash = $1
AND used_at IS NULL
AND expires_at > NOW()
RETURNING project_id"#,
)
.bind(token_hash)
.fetch_optional(pool)
.await
.map_err(|error| {
tracing::error!(error = %error, "Failed to consume WebSocket ticket");
axum::http::StatusCode::INTERNAL_SERVER_ERROR
})?
.ok_or(axum::http::StatusCode::UNAUTHORIZED)?;
let project_id: uuid::Uuid = row.get("project_id");
if let Some(requested_project_id) = requested_project_id {
let requested = uuid::Uuid::parse_str(requested_project_id)
.map_err(|_| axum::http::StatusCode::BAD_REQUEST)?;
if requested != project_id {
return Err(axum::http::StatusCode::FORBIDDEN);
}
}
Ok(project_id)
}
async fn handle_socket(mut socket: WebSocket, state: AppState, filter_project_id: Option<String>) { async fn handle_socket(mut socket: WebSocket, state: AppState, filter_project_id: Option<String>) {
tracing::info!( tracing::info!(
"🔌 WebSocket connection upgraded and active for project: {:?}", "🔌 WebSocket connection upgraded and active for project: {:?}",
@@ -967,3 +1209,28 @@ async fn handle_socket(mut socket: WebSocket, state: AppState, filter_project_id
filter_project_id filter_project_id
); );
} }
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn websocket_ticket_hash_never_stores_raw_ticket() {
let ticket = "wst_example-ticket";
let hash = hash_ws_ticket(ticket);
assert_ne!(hash, ticket);
assert_eq!(hash.len(), 64);
assert!(hash.chars().all(|ch| ch.is_ascii_hexdigit()));
}
#[test]
fn websocket_ticket_migration_enforces_expiry_and_single_use_state() {
let migration = include_str!("../migrations/20260621000000_websocket_tickets.sql");
assert!(migration.contains("token_hash TEXT PRIMARY KEY"));
assert!(migration.contains("expires_at TIMESTAMPTZ NOT NULL"));
assert!(migration.contains("used_at TIMESTAMPTZ NULL"));
assert!(migration.contains("REFERENCES edge_projects(id) ON DELETE CASCADE"));
}
}

View File

@@ -1,7 +1,10 @@
use metrics::{counter, describe_counter, describe_gauge, gauge}; use metrics::{counter, describe_counter, describe_gauge, gauge};
pub fn register_metrics() { pub fn register_metrics() {
describe_gauge!("omnioil_active_agents", "Number of MQTT-connected edge agents"); describe_gauge!(
"omnioil_active_agents",
"Number of MQTT-connected edge agents"
);
describe_gauge!("omnioil_active_projects", "Number of active edge projects"); describe_gauge!("omnioil_active_projects", "Number of active edge projects");
describe_gauge!("omnioil_active_tags", "Total active edge variables (tags)"); describe_gauge!("omnioil_active_tags", "Total active edge variables (tags)");
describe_counter!( describe_counter!(
@@ -33,11 +36,10 @@ pub async fn update_system_gauges(pool: &sqlx::PgPool) {
{ {
gauge!("omnioil_active_projects").set(count as f64); gauge!("omnioil_active_projects").set(count as f64);
} }
if let Ok(count) = sqlx::query_scalar::<_, i64>( if let Ok(count) =
"SELECT COUNT(*) FROM edge_variables WHERE is_enabled = true", sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM edge_variables WHERE is_enabled = true")
) .fetch_one(pool)
.fetch_one(pool) .await
.await
{ {
gauge!("omnioil_active_tags").set(count as f64); gauge!("omnioil_active_tags").set(count as f64);
} }

View File

@@ -2,19 +2,21 @@
//! Soporta: Email (SMTP), Webhook (HTTP POST), WebSocket (in-app). //! Soporta: Email (SMTP), Webhook (HTTP POST), WebSocket (in-app).
use sqlx::PgPool; use sqlx::PgPool;
use std::sync::Arc; use std::collections::HashMap;
use std::sync::{Arc, Mutex, OnceLock};
use std::time::Instant;
use tokio::sync::broadcast; use tokio::sync::broadcast;
use uuid::Uuid; use uuid::Uuid;
#[derive(Debug, Clone, serde::Serialize)] #[derive(Debug, Clone, serde::Serialize)]
pub struct AlarmNotification { pub struct AlarmNotification {
pub project_id: Uuid, pub project_id: Uuid,
pub asset_id: Option<Uuid>, pub asset_id: Option<Uuid>,
pub variable: String, pub variable: String,
pub value: f64, pub value: f64,
pub alarm_type: String, pub alarm_type: String,
pub message: String, pub message: String,
pub timestamp: chrono::DateTime<chrono::Utc>, pub timestamp: chrono::DateTime<chrono::Utc>,
} }
/// Despacha notificaciones para una alarma disparada. /// Despacha notificaciones para una alarma disparada.
@@ -59,16 +61,17 @@ pub async fn dispatch(
for cfg in &configs { for cfg in &configs {
let result = match cfg.channel_type.as_str() { let result = match cfg.channel_type.as_str() {
"email" => send_email(&cfg.destination, alarm).await, "email" => send_email(&cfg.destination, alarm).await,
"webhook" => send_webhook(&cfg.destination, alarm).await, "webhook" => send_webhook(&cfg.destination, alarm).await,
other => { "telegram" => send_telegram_alarm(&cfg.destination, alarm).await,
other => {
tracing::warn!("Canal de notificación no soportado: {}", other); tracing::warn!("Canal de notificación no soportado: {}", other);
Ok(()) Ok(())
} }
}; };
let (success, error_message) = match &result { let (success, error_message) = match &result {
Ok(_) => (true, None), Ok(_) => (true, None),
Err(e) => { Err(e) => {
tracing::error!("Error enviando notificación {}: {}", cfg.channel_type, e); tracing::error!("Error enviando notificación {}: {}", cfg.channel_type, e);
(false, Some(e.to_string())) (false, Some(e.to_string()))
@@ -91,7 +94,10 @@ pub async fn dispatch(
} }
} }
async fn send_email(to: &str, alarm: &AlarmNotification) -> Result<(), Box<dyn std::error::Error + Send + Sync>> { async fn send_email(
to: &str,
alarm: &AlarmNotification,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
use lettre::{Message, SmtpTransport, Transport, transport::smtp::authentication::Credentials}; use lettre::{Message, SmtpTransport, Transport, transport::smtp::authentication::Credentials};
let smtp_host = std::env::var("SMTP_HOST").unwrap_or_default(); let smtp_host = std::env::var("SMTP_HOST").unwrap_or_default();
@@ -134,7 +140,10 @@ async fn send_email(to: &str, alarm: &AlarmNotification) -> Result<(), Box<dyn s
Ok(()) Ok(())
} }
async fn send_webhook(url: &str, alarm: &AlarmNotification) -> Result<(), Box<dyn std::error::Error + Send + Sync>> { async fn send_webhook(
url: &str,
alarm: &AlarmNotification,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let client = reqwest::Client::builder() let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(10)) .timeout(std::time::Duration::from_secs(10))
.build()?; .build()?;
@@ -159,3 +168,191 @@ async fn send_webhook(url: &str, alarm: &AlarmNotification) -> Result<(), Box<dy
} }
Ok(()) Ok(())
} }
async fn send_telegram_alarm(
destination: &str,
alarm: &AlarmNotification,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let token = std::env::var("TELEGRAM_BOT_TOKEN")
.unwrap_or_else(|_| "8626426348:AAFNXkEMA1jgj8Z3QFeptpY8mYYg_6BHFxQ".to_string());
let chat_id = if destination.trim().is_empty() {
std::env::var("TELEGRAM_CHAT_ID").unwrap_or_else(|_| "-4336874579".to_string())
} else {
destination.to_string()
};
if token.is_empty() || chat_id.is_empty() {
tracing::warn!("Telegram no configurado para alarma");
return Ok(());
}
let url = format!("https://api.telegram.org/bot{}/sendMessage", token);
let message = format!(
"🚨 *Alarma OmniOil — {}*\n\n\
• *Variable:* {}\n\
• *Valor:* {:.2}\n\
• *Tipo:* {}\n\
• *Mensaje:* {}\n\
• *Timestamp:* {}",
alarm.variable, alarm.variable, alarm.value, alarm.alarm_type, alarm.message, alarm.timestamp
);
let payload = serde_json::json!({
"chat_id": chat_id,
"text": message,
"parse_mode": "Markdown"
});
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(10))
.build()?;
let resp = client.post(&url).json(&payload).send().await?;
if !resp.status().is_success() {
let body = resp.text().await.unwrap_or_default();
return Err(format!("Telegram error: {}", body).into());
}
Ok(())
}
static LAST_SYSTEM_ALERTS: OnceLock<Mutex<HashMap<String, Instant>>> = OnceLock::new();
/// Envía una alerta inmediata por Correo Electrónico y Telegram si ocurre una falla en el servidor.
/// Limita la frecuencia a una vez cada 10 minutos por contexto para evitar spam.
pub async fn send_system_alert(context: &str, error: &anyhow::Error) {
let now = Instant::now();
let cooldown = std::time::Duration::from_secs(600); // 10 minutos
{
let map_mutex = LAST_SYSTEM_ALERTS.get_or_init(|| Mutex::new(HashMap::new()));
let mut map = map_mutex.lock().unwrap();
if let Some(&last_sent) = map.get(context) {
if now.duration_since(last_sent) < cooldown {
// Cooldown activo, no enviar
return;
}
}
map.insert(context.to_string(), now);
}
let subject = format!("🚨 ERROR CRÍTICO OMNIOIL: {}", context);
let message = format!(
"Se ha detectado un problema crítico en el servidor de OmniOil.\n\n\
Contexto: {}\n\
Error: {:?}\n\
Timestamp: {}",
context,
error,
chrono::Utc::now()
);
// 1. Enviar a Telegram
if let Err(e) = send_telegram_direct(&message).await {
tracing::error!("Fallo al enviar alerta de sistema a Telegram: {}", e);
}
// 2. Enviar por Correo Electrónico
let admin_email = std::env::var("ADMIN_EMAIL")
.or_else(|_| std::env::var("SMTP_FROM"))
.or_else(|_| std::env::var("SMTP_USER"))
.unwrap_or_default();
if !admin_email.is_empty() {
let msg = crate::smtp_mailer::EmailMessage {
to: &admin_email,
subject: &subject,
body: &message,
html_body: None,
};
if let Err(e) = crate::smtp_mailer::send_email(msg).await {
tracing::error!("Fallo al enviar alerta de sistema por correo: {}", e);
}
} else {
tracing::warn!("No se pudo enviar alerta de sistema por correo: ADMIN_EMAIL, SMTP_FROM y SMTP_USER no están configurados");
}
}
async fn send_telegram_direct(message: &str) -> anyhow::Result<()> {
let token = std::env::var("TELEGRAM_BOT_TOKEN")
.unwrap_or_else(|_| "8626426348:AAFNXkEMA1jgj8Z3QFeptpY8mYYg_6BHFxQ".to_string());
let chat_id = std::env::var("TELEGRAM_CHAT_ID")
.unwrap_or_else(|_| "-4336874579".to_string());
if token.is_empty() || chat_id.is_empty() {
tracing::warn!("Telegram no configurado: TOKEN o CHAT_ID faltantes");
return Ok(());
}
let url = format!("https://api.telegram.org/bot{}/sendMessage", token);
let payload = serde_json::json!({
"chat_id": chat_id,
"text": message
});
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(10))
.build()?;
let resp = client.post(&url).json(&payload).send().await?;
let status = resp.status();
if !status.is_success() {
let body = resp.text().await.unwrap_or_default();
anyhow::bail!("Telegram HTTP error {}: {}", status, body);
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use std::time::Duration;
#[tokio::test]
async fn test_send_system_alert_rate_limiting() {
let context = "test-rate-limit";
let error = anyhow::anyhow!("Test error message");
// Asegurarse de que el mapa está inicializado y limpio para el test
let map_mutex = LAST_SYSTEM_ALERTS.get_or_init(|| Mutex::new(HashMap::new()));
{
let mut map = map_mutex.lock().unwrap();
map.clear();
}
// Primer envío (debería registrarse)
send_system_alert(context, &error).await;
let first_instant = {
let map = map_mutex.lock().unwrap();
let inst = map.get(context).cloned();
assert!(inst.is_some(), "Debería haberse registrado el envío de la alerta");
inst.unwrap()
};
// Segundo envío inmediato (debería ser ignorado por el cooldown, manteniendo el instante original)
send_system_alert(context, &error).await;
{
let map = map_mutex.lock().unwrap();
let second_instant = map.get(context).cloned().unwrap();
assert_eq!(first_instant, second_instant, "El segundo envío inmediato no debería actualizar el timestamp debido al cooldown");
}
// Simular que el cooldown ya pasó (retrocediendo el tiempo de envío en el mapa)
{
let mut map = map_mutex.lock().unwrap();
map.insert(context.to_string(), Instant::now() - Duration::from_secs(650));
}
// Tercer envío después del cooldown (debería registrarse y actualizar el timestamp)
send_system_alert(context, &error).await;
{
let map = map_mutex.lock().unwrap();
let third_instant = map.get(context).cloned().unwrap();
assert!(third_instant > first_instant, "El envío después del cooldown debería actualizar el timestamp");
}
}
}

View File

@@ -1,4 +1,8 @@
use lettre::{Message, SmtpTransport, Transport, transport::smtp::authentication::Credentials}; use lettre::{
Message, SmtpTransport, Transport,
message::{Mailbox, MultiPart, SinglePart, header::ContentType},
transport::smtp::authentication::Credentials,
};
#[derive(Clone, Copy, Debug, Eq, PartialEq)] #[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum SmtpTlsMode { enum SmtpTlsMode {
@@ -10,6 +14,7 @@ pub struct EmailMessage<'a> {
pub to: &'a str, pub to: &'a str,
pub subject: &'a str, pub subject: &'a str,
pub body: &'a str, pub body: &'a str,
pub html_body: Option<&'a str>,
} }
fn parse_tls_mode(value: Option<&str>) -> anyhow::Result<SmtpTlsMode> { fn parse_tls_mode(value: Option<&str>) -> anyhow::Result<SmtpTlsMode> {
@@ -28,6 +33,15 @@ fn should_apply_credentials(user: &str, pass: &str) -> bool {
!user.trim().is_empty() || !pass.trim().is_empty() !user.trim().is_empty() || !pass.trim().is_empty()
} }
fn smtp_from_mailbox(value: &str) -> anyhow::Result<Mailbox> {
let trimmed = value.trim();
if trimmed.contains('<') {
return Ok(trimmed.parse()?);
}
Ok(format!("OmniOil <{trimmed}>").parse()?)
}
pub async fn send_email(message: EmailMessage<'_>) -> anyhow::Result<()> { pub async fn send_email(message: EmailMessage<'_>) -> anyhow::Result<()> {
let smtp_host = std::env::var("SMTP_HOST").unwrap_or_default(); let smtp_host = std::env::var("SMTP_HOST").unwrap_or_default();
let smtp_host = smtp_host.trim().to_string(); let smtp_host = smtp_host.trim().to_string();
@@ -49,11 +63,28 @@ pub async fn send_email(message: EmailMessage<'_>) -> anyhow::Result<()> {
let smtp_from = std::env::var("SMTP_FROM").unwrap_or_else(|_| smtp_user.clone()); let smtp_from = std::env::var("SMTP_FROM").unwrap_or_else(|_| smtp_user.clone());
let tls_mode = parse_tls_mode(std::env::var("SMTP_TLS_MODE").ok().as_deref())?; let tls_mode = parse_tls_mode(std::env::var("SMTP_TLS_MODE").ok().as_deref())?;
let email = Message::builder() let email_builder = Message::builder()
.from(format!("OmniOil <{}>", smtp_from).parse()?) .from(smtp_from_mailbox(&smtp_from)?)
.to(message.to.parse()?) .to(message.to.parse()?)
.subject(message.subject) .subject(message.subject);
.body(message.body.to_string())?;
let email = if let Some(html_body) = message.html_body {
email_builder.multipart(
MultiPart::alternative()
.singlepart(
SinglePart::builder()
.header(ContentType::TEXT_PLAIN)
.body(message.body.to_string()),
)
.singlepart(
SinglePart::builder()
.header(ContentType::TEXT_HTML)
.body(html_body.to_string()),
),
)?
} else {
email_builder.body(message.body.to_string())?
};
let mut mailer_builder = match tls_mode { let mut mailer_builder = match tls_mode {
SmtpTlsMode::StartTls => SmtpTransport::starttls_relay(&smtp_host)?, SmtpTlsMode::StartTls => SmtpTransport::starttls_relay(&smtp_host)?,
@@ -74,7 +105,10 @@ pub async fn send_email(message: EmailMessage<'_>) -> anyhow::Result<()> {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::{EmailMessage, SmtpTlsMode, parse_tls_mode, send_email, should_apply_credentials}; use super::{
EmailMessage, SmtpTlsMode, parse_tls_mode, send_email, should_apply_credentials,
smtp_from_mailbox,
};
use std::sync::{Mutex, OnceLock}; use std::sync::{Mutex, OnceLock};
fn env_lock() -> &'static Mutex<()> { fn env_lock() -> &'static Mutex<()> {
@@ -93,6 +127,7 @@ mod tests {
to: "customer@example.com", to: "customer@example.com",
subject: "Tu acceso a OmniOil fue aprobado", subject: "Tu acceso a OmniOil fue aprobado",
body: "Activation instructions", body: "Activation instructions",
html_body: None,
}) })
.await; .await;
@@ -114,6 +149,7 @@ mod tests {
to: "customer@example.com", to: "customer@example.com",
subject: "Nuevo proyecto habilitado en OmniOil", subject: "Nuevo proyecto habilitado en OmniOil",
body: "Access is ready", body: "Access is ready",
html_body: None,
}) })
.await; .await;
@@ -153,4 +189,16 @@ mod tests {
assert!(should_apply_credentials("user", "")); assert!(should_apply_credentials("user", ""));
assert!(should_apply_credentials("", "password")); assert!(should_apply_credentials("", "password"));
} }
#[test]
fn smtp_from_accepts_raw_email_or_full_mailbox() {
let raw = smtp_from_mailbox("soporte@omnioil.app").expect("raw sender should parse");
let full =
smtp_from_mailbox("OmniOil <soporte@omnioil.app>").expect("full sender should parse");
assert_eq!(raw.email.to_string(), "soporte@omnioil.app");
assert_eq!(raw.name.as_deref(), Some("OmniOil"));
assert_eq!(full.email.to_string(), "soporte@omnioil.app");
assert_eq!(full.name.as_deref(), Some("OmniOil"));
}
} }

View File

@@ -19,19 +19,14 @@ where
{ {
type Rejection = Response; type Rejection = Response;
async fn from_request( async fn from_request(req: axum::extract::Request, state: &S) -> Result<Self, Self::Rejection> {
req: axum::extract::Request, let Json(value) = Json::<T>::from_request(req, state).await.map_err(|e| {
state: &S, (
) -> Result<Self, Self::Rejection> { StatusCode::BAD_REQUEST,
let Json(value) = Json::<T>::from_request(req, state) Json(serde_json::json!({ "error": e.to_string() })),
.await )
.map_err(|e| { .into_response()
( })?;
StatusCode::BAD_REQUEST,
Json(serde_json::json!({ "error": e.to_string() })),
)
.into_response()
})?;
value.validate().map_err(|e| { value.validate().map_err(|e| {
( (

View File

@@ -1,8 +1,8 @@
use crate::notifier::{self, AlarmNotification};
use crate::handlers::billing::take_daily_snapshots; use crate::handlers::billing::take_daily_snapshots;
use crate::notifier::{self, AlarmNotification};
use chrono::{TimeZone, Utc};
use sqlx::{PgPool, Row}; use sqlx::{PgPool, Row};
use std::time::Duration; use std::time::Duration;
use chrono::Utc;
use tokio::sync::broadcast; use tokio::sync::broadcast;
use uuid::Uuid; use uuid::Uuid;
@@ -18,27 +18,43 @@ pub async fn run_watchdog(pool: PgPool, tx: broadcast::Sender<crate::TelemetryEv
if let Err(e) = check_telemetry_silence(&pool, &tx).await { if let Err(e) = check_telemetry_silence(&pool, &tx).await {
tracing::error!("Watchdog error (silence check): {}", e); tracing::error!("Watchdog error (silence check): {}", e);
notifier::send_system_alert("silence check", &e).await;
} }
if let Err(e) = check_unhealthy_agents(&pool, &tx).await { if let Err(e) = check_unhealthy_agents(&pool, &tx).await {
tracing::error!("Watchdog error (health check): {}", e); tracing::error!("Watchdog error (health check): {}", e);
notifier::send_system_alert("health check", &e).await;
}
if let Err(e) = check_report_review_deadlines(&pool).await {
tracing::error!("Watchdog error (report review deadline check): {}", e);
notifier::send_system_alert("report review deadline check", &e).await;
} }
crate::metrics::update_system_gauges(&pool).await; crate::metrics::update_system_gauges(&pool).await;
// Limpieza diaria: borrar logs de agente con más de 6 meses (tick cada 60s → 1440 = 24h) // Limpieza diaria: borrar logs de agente con más de 6 meses (tick cada 60s → 1440 = 24h)
if tick_count % 1440 == 0 { if tick_count % 1440 == 1 {
if let Err(e) = cleanup_old_agent_logs(&pool).await { if let Err(e) = cleanup_old_agent_logs(&pool).await {
tracing::error!("Watchdog error (log cleanup): {}", e); tracing::error!("Watchdog error (log cleanup): {}", e);
notifier::send_system_alert("log cleanup", &anyhow::Error::new(e)).await;
} }
if let Err(e) = take_daily_snapshots(&pool).await { if let Err(e) = take_daily_snapshots(&pool).await {
tracing::error!("Watchdog error (usage snapshots): {}", e); tracing::error!("Watchdog error (usage snapshots): {}", e);
notifier::send_system_alert("usage snapshots", &e).await;
}
if let Err(e) = check_calibration_expirations(&pool, &tx).await {
tracing::error!("Watchdog error (calibration check): {}", e);
notifier::send_system_alert("calibration check", &e).await;
} }
} }
} }
} }
async fn check_telemetry_silence(pool: &PgPool, tx: &broadcast::Sender<crate::TelemetryEvent>) -> anyhow::Result<()> { async fn check_telemetry_silence(
pool: &PgPool,
tx: &broadcast::Sender<crate::TelemetryEvent>,
) -> anyhow::Result<()> {
// Buscar proyectos activos que NO hayan tenido telemetría en los últimos 10 minutos // Buscar proyectos activos que NO hayan tenido telemetría en los últimos 10 minutos
let rows = sqlx::query( let rows = sqlx::query(
r#" r#"
@@ -53,7 +69,7 @@ async fn check_telemetry_silence(pool: &PgPool, tx: &broadcast::Sender<crate::Te
AND EXISTS ( AND EXISTS (
SELECT 1 FROM edge_assets a WHERE a.project_id = p.id SELECT 1 FROM edge_assets a WHERE a.project_id = p.id
) )
"# "#,
) )
.fetch_all(pool) .fetch_all(pool)
.await?; .await?;
@@ -61,7 +77,10 @@ async fn check_telemetry_silence(pool: &PgPool, tx: &broadcast::Sender<crate::Te
for row in rows { for row in rows {
let proj_id: Uuid = row.get("id"); let proj_id: Uuid = row.get("id");
let proj_name: String = row.get("name"); let proj_name: String = row.get("name");
let msg = format!("ALERTA ANH: No se recibe telemetría del proyecto {} hace más de 10 min.", proj_name); let msg = format!(
"ALERTA ANH: No se recibe telemetría del proyecto {} hace más de 10 min.",
proj_name
);
let already_alerted = sqlx::query( let already_alerted = sqlx::query(
r#" r#"
@@ -70,7 +89,7 @@ async fn check_telemetry_silence(pool: &PgPool, tx: &broadcast::Sender<crate::Te
AND alarm_type = 'TELEMETRY_SILENCE' AND alarm_type = 'TELEMETRY_SILENCE'
AND timestamp > NOW() - INTERVAL '1 hour' AND timestamp > NOW() - INTERVAL '1 hour'
LIMIT 1 LIMIT 1
"# "#,
) )
.bind(proj_id) .bind(proj_id)
.fetch_optional(pool) .fetch_optional(pool)
@@ -105,27 +124,25 @@ async fn check_telemetry_silence(pool: &PgPool, tx: &broadcast::Sender<crate::Te
Ok(()) Ok(())
} }
async fn check_unhealthy_agents(pool: &PgPool, tx: &broadcast::Sender<crate::TelemetryEvent>) -> anyhow::Result<()> { async fn check_unhealthy_agents(
// Proyectos con heartbeats perdidos (> 5 minutos) pool: &PgPool,
let lost_agents = sqlx::query( tx: &broadcast::Sender<crate::TelemetryEvent>,
r#" ) -> anyhow::Result<()> {
SELECT id, name let lost_agents = sqlx::query(unhealthy_agents_select_sql())
FROM edge_projects .fetch_all(pool)
WHERE is_active = true .await?;
AND (last_health_report->>'timestamp')::TIMESTAMPTZ < NOW() - INTERVAL '5 minutes'
"#
)
.fetch_all(pool)
.await?;
for row in lost_agents { for row in lost_agents {
let agent_id: Uuid = row.get("id"); let agent_id: Uuid = row.get("id");
let agent_name: String = row.get("name"); let agent_name: String = row.get("name");
let msg = format!("Falla de Comunicación: El agente del proyecto {} no responde.", agent_name); let msg = format!(
"Falla de Comunicación: El agente del proyecto {} no responde.",
agent_name
);
sqlx::query( sqlx::query(
"INSERT INTO telemetry_alarms (project_id, device_name, alarm_type, message, timestamp) "INSERT INTO telemetry_alarms (project_id, device_name, alarm_type, message, timestamp)
VALUES ($1, 'SYSTEM_WATCHDOG', 'AGENT_OFFLINE', $2, $3)" VALUES ($1, 'SYSTEM_WATCHDOG', 'AGENT_OFFLINE', $2, $3)",
) )
.bind(agent_id) .bind(agent_id)
.bind(&msg) .bind(&msg)
@@ -148,13 +165,39 @@ async fn check_unhealthy_agents(pool: &PgPool, tx: &broadcast::Sender<crate::Tel
Ok(()) Ok(())
} }
pub(crate) fn unhealthy_agents_select_sql() -> &'static str {
r#"
SELECT p.id, p.name
FROM edge_projects p
WHERE p.is_active = true
AND p.installer_status = 'COMPLETED'
AND btrim(p.contract_number) <> ''
AND upper(btrim(p.contract_number)) NOT LIKE 'PENDING-%'
AND upper(btrim(p.contract_number)) NOT LIKE 'TRIAL-%'
AND p.last_health_report ? 'timestamp'
AND jsonb_typeof(p.last_health_report->'timestamp') = 'string'
AND (p.last_health_report->>'timestamp') ~ '^[0-9]{4}-[0-9]{2}-[0-9]{2}T'
AND (p.last_health_report->>'timestamp')::TIMESTAMPTZ < NOW() - INTERVAL '5 minutes'
AND EXISTS (
SELECT 1 FROM edge_assets a
WHERE a.project_id = p.id
AND a.is_active = true
)
AND NOT EXISTS (
SELECT 1 FROM telemetry_alarms ta
WHERE ta.project_id = p.id
AND ta.alarm_type = 'AGENT_OFFLINE'
AND ta.timestamp > NOW() - INTERVAL '1 hour'
)
"#
}
/// Elimina logs de agente más antiguos de 6 meses (retención máxima para visualización en tiempo real). /// 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> { async fn cleanup_old_agent_logs(pool: &PgPool) -> Result<(), sqlx::Error> {
let result = sqlx::query( let result =
"DELETE FROM agent_logs WHERE timestamp < NOW() - INTERVAL '6 months'" sqlx::query("DELETE FROM agent_logs WHERE timestamp < NOW() - INTERVAL '6 months'")
) .execute(pool)
.execute(pool) .await?;
.await?;
if result.rows_affected() > 0 { if result.rows_affected() > 0 {
tracing::info!( tracing::info!(
@@ -165,3 +208,321 @@ async fn cleanup_old_agent_logs(pool: &PgPool) -> Result<(), sqlx::Error> {
Ok(()) Ok(())
} }
async fn check_report_review_deadlines(pool: &PgPool) -> anyhow::Result<()> {
use shared_lib::models::ANHReport;
// Buscar reportes generados que siguen pendientes de revisión manual.
let pending_reports = sqlx::query_as::<_, ANHReport>(report_review_deadline_select_sql())
.fetch_all(pool)
.await?;
for report in pending_reports {
if let Some(start) = report.period_start {
// Default: 06:00 AM COT (Bogota), matching official daily ANH generation.
let mut local_time = default_review_cutoff_time().format("%H:%M").to_string();
let mut timezone = "America/Bogota".to_string();
// Si el reporte tiene una programación asignada, cargamos su hora y zona horaria
if let Some(sched_id) = report.schedule_id {
if let Ok(Some(sched)) = sqlx::query_as::<_, shared_lib::models::ANHReportSchedule>(
"SELECT id, operator_name, contract_number, to_char(local_time, 'HH24:MI') as local_time, timezone, is_active, next_run_at, last_run_at, last_status, last_error, claim_token, claimed_at, created_at, updated_at, created_by, updated_by FROM anh_report_schedules WHERE id = $1"
)
.bind(sched_id)
.fetch_optional(pool)
.await {
local_time = sched.local_time;
timezone = sched.timezone;
}
}
// Parsear zona horaria
let tz = timezone
.parse::<chrono_tz::Tz>()
.unwrap_or(chrono_tz::America::Bogota);
let local_start = start.with_timezone(&tz);
let local_date = local_start.date_naive();
// Límite de revisión (cutoff) es al día siguiente (D+1) a la hora configurada (local_time)
let cutoff_date = local_date.succ_opt().unwrap_or(local_date);
let time = parse_review_cutoff_time(&local_time);
let local_cutoff = cutoff_date.and_time(time);
let deadline = match tz.from_local_datetime(&local_cutoff) {
chrono::LocalResult::Single(dt) => dt.with_timezone(&chrono::Utc),
chrono::LocalResult::Ambiguous(earliest, _) => earliest.with_timezone(&chrono::Utc),
chrono::LocalResult::None => {
start + chrono::Duration::days(1) + chrono::Duration::hours(12)
}
};
if chrono::Utc::now() >= deadline {
tracing::warn!(
"{}",
review_deadline_reached_message(&report.filename, report.id, deadline)
);
// Auto-sellado
let sealed = if report.object_key.is_some() {
report
} else {
let final_json_bytes =
crate::handlers::anh_reports::final_json_bytes_for_sealing(&report.content)
.map_err(|e| anyhow::anyhow!("Error de formato JSON: {:?}", e))?;
crate::handlers::anh_reports::seal_report_artifact(
pool,
report.id,
&report.filename,
&final_json_bytes,
)
.await
.map_err(|e| anyhow::anyhow!("Error al sellar reporte: {:?}", e))?
};
// Auto-aprobación (status = 'APPROVED')
let updated = sqlx::query_as::<_, ANHReport>(
r#"UPDATE anh_reports
SET status = 'APPROVED',
approved_at = NOW(),
approved_by = NULL
WHERE id = $1
RETURNING *"#,
)
.bind(sealed.id)
.fetch_one(pool)
.await?;
// Registrar en la auditoría
crate::audit::log(
pool,
crate::audit::AuditEntry::new("anh_report.auto_approve")
.with_resource(updated.id.to_string())
.with_metadata(serde_json::json!({
"report_id": updated.id,
"filename": updated.filename,
"hash_sha384": updated.hash_sha384,
"reason": automatic_approval_audit_reason(deadline),
})),
)
.await;
tracing::info!(
"✅ Reporte {} (ID: {}) aprobado automáticamente por el sistema.",
updated.filename,
updated.id
);
}
}
}
Ok(())
}
pub(crate) fn report_review_deadline_select_sql() -> &'static str {
"SELECT * FROM anh_reports WHERE status = 'GENERATED' AND correction_version = 0"
}
fn review_deadline_reached_message(
filename: &str,
report_id: Uuid,
deadline: chrono::DateTime<Utc>,
) -> String {
format!(
"⏰ Límite de revisión alcanzado (cutoff {}) para el reporte de {} (ID: {}). Se procederá con el sellado y aprobación preventiva.",
deadline, filename, report_id
)
}
fn automatic_approval_audit_reason(deadline: chrono::DateTime<Utc>) -> String {
format!(
"Aprobación preventiva por límite de tiempo de corte ({}) alcanzado",
deadline
)
}
fn default_review_cutoff_time() -> chrono::NaiveTime {
chrono::NaiveTime::from_hms_opt(6, 0, 0).unwrap()
}
fn parse_review_cutoff_time(local_time: &str) -> chrono::NaiveTime {
chrono::NaiveTime::parse_from_str(local_time, "%H:%M")
.unwrap_or_else(|_| default_review_cutoff_time())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn report_review_deadline_select_only_targets_base_generated_drafts() {
let sql = report_review_deadline_select_sql();
assert!(sql.contains("status = 'GENERATED'"));
assert!(sql.contains("correction_version = 0"));
assert!(!sql.contains("CORRECTION_DRAFT"));
assert!(!sql.contains("CORRECTED_APPROVED"));
assert!(!sql.contains("AVAILABLE_FOR_ANH"));
assert!(!sql.contains("SENT"));
assert!(!sql.contains("DISPOSED"));
}
#[test]
fn watchdog_review_deadline_copy_uses_approval_not_outbound_send_terms() {
let deadline = chrono::DateTime::parse_from_rfc3339("2026-06-16T12:00:00Z")
.unwrap()
.with_timezone(&Utc);
let message = review_deadline_reached_message(
"report.json",
Uuid::parse_str("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa").unwrap(),
deadline,
);
let reason = automatic_approval_audit_reason(deadline);
let combined = format!("{} {}", message, reason).to_lowercase();
assert!(combined.contains("aprob"));
assert!(combined.contains("sell"));
assert!(!combined.contains("auto-env"));
assert!(!combined.contains("envío"));
assert!(!combined.contains("enviar"));
assert!(!combined.contains("send"));
assert!(!combined.contains("sent"));
}
#[test]
fn watchdog_default_review_cutoff_aligns_with_six_am_bogota() {
assert_eq!(
default_review_cutoff_time(),
chrono::NaiveTime::from_hms_opt(6, 0, 0).unwrap()
);
assert_eq!(
parse_review_cutoff_time("invalid"),
chrono::NaiveTime::from_hms_opt(6, 0, 0).unwrap()
);
}
#[test]
fn unhealthy_agents_select_requires_operational_projects_with_prior_health() {
let sql = unhealthy_agents_select_sql();
assert!(sql.contains("p.is_active = true"));
assert!(sql.contains("p.installer_status = 'COMPLETED'"));
assert!(sql.contains("last_health_report ? 'timestamp'"));
assert!(sql.contains("jsonb_typeof(p.last_health_report->'timestamp') = 'string'"));
assert!(sql.contains("~ '^[0-9]{4}-[0-9]{2}-[0-9]{2}T'"));
assert!(sql.contains(
"(p.last_health_report->>'timestamp')::TIMESTAMPTZ < NOW() - INTERVAL '5 minutes'"
));
assert!(sql.contains("EXISTS ("));
assert!(sql.contains("FROM edge_assets a"));
assert!(sql.contains("a.project_id = p.id"));
assert!(sql.contains("a.is_active = true"));
}
#[test]
fn unhealthy_agents_select_skips_pending_trial_and_recent_offline_noise() {
let sql = unhealthy_agents_select_sql();
assert!(sql.contains("upper(btrim(p.contract_number)) NOT LIKE 'PENDING-%'"));
assert!(sql.contains("upper(btrim(p.contract_number)) NOT LIKE 'TRIAL-%'"));
assert!(sql.contains("NOT EXISTS ("));
assert!(sql.contains("FROM telemetry_alarms ta"));
assert!(sql.contains("ta.project_id = p.id"));
assert!(sql.contains("ta.alarm_type = 'AGENT_OFFLINE'"));
assert!(sql.contains("ta.timestamp > NOW() - INTERVAL '1 hour'"));
}
}
async fn check_calibration_expirations(
pool: &PgPool,
tx: &broadcast::Sender<crate::TelemetryEvent>,
) -> anyhow::Result<()> {
let rows = sqlx::query(
r#"
SELECT id, project_id, name, code, next_calibration_date
FROM edge_assets
WHERE is_active = true
AND next_calibration_date <= NOW() + INTERVAL '30 days'
"#,
)
.fetch_all(pool)
.await?;
for row in rows {
let asset_id: Uuid = row.get("id");
let project_id: Uuid = row.get("project_id");
let name: String = row.get("name");
let code: String = row.get("code");
let next_cal: chrono::NaiveDate = row.get("next_calibration_date");
let today = Utc::now().date_naive();
let days_left = (next_cal - today).num_days();
let alarm_type = if days_left < 0 {
"CALIBRATION_EXPIRED"
} else {
"CALIBRATION_WARNING"
};
let msg = if days_left < 0 {
format!(
"ALERTA METROLOGÍA: La calibración para el activo {} ({}) venció hace {} días el {}.",
name, code, -days_left, next_cal
)
} else {
format!(
"ALERTA METROLOGÍA: La calibración para el activo {} ({}) vencerá en {} días el {}.",
name, code, days_left, next_cal
)
};
// Evitar duplicar alertas recientes del mismo tipo en las últimas 24 horas para este activo
let already_alerted = sqlx::query(
r#"
SELECT 1 FROM telemetry_alarms
WHERE project_id = $1
AND device_name = $2
AND alarm_type = $3
AND timestamp > NOW() - INTERVAL '24 hours'
LIMIT 1
"#,
)
.bind(project_id)
.bind(&code)
.bind(alarm_type)
.fetch_optional(pool)
.await?;
if already_alerted.is_none() {
tracing::warn!(
"⚠️ Calibración vencida/por vencer en activo {}: {}",
code,
msg
);
sqlx::query(
"INSERT INTO telemetry_alarms (project_id, device_name, alarm_type, message, timestamp)
VALUES ($1, $2, $3, $4, $5)"
)
.bind(project_id)
.bind(&code)
.bind(alarm_type)
.bind(&msg)
.bind(Utc::now())
.execute(pool)
.await?;
let alarm = AlarmNotification {
project_id,
asset_id: Some(asset_id),
variable: "next_calibration_date".to_string(),
value: days_left as f64,
alarm_type: alarm_type.to_string(),
message: msg,
timestamp: Utc::now(),
};
notifier::dispatch(pool, &alarm, tx).await;
}
}
Ok(())
}

View File

@@ -283,6 +283,10 @@ async fn approve_pending_and_in_review_requests_provision_customer_access() {
assert!(row.provisioned_user_id.is_some()); assert!(row.provisioned_user_id.is_some());
assert!(row.provisioned_project_id.is_some()); assert!(row.provisioned_project_id.is_some());
assert!(row.provisioned_at.is_some()); assert!(row.provisioned_at.is_some());
assert!(
row.trial_ends_at.is_some(),
"approved access request response should expose subscription trial expiry"
);
assert_eq!(row.invitation_email_status.as_deref(), Some("failed")); assert_eq!(row.invitation_email_status.as_deref(), Some("failed"));
let counts = sqlx::query( let counts = sqlx::query(
@@ -337,6 +341,10 @@ async fn approve_pending_and_in_review_requests_provision_customer_access() {
let active_request_id = seed_access_request(&pool, "pending", Some(active_email.clone())).await; let active_request_id = seed_access_request(&pool, "pending", Some(active_email.clone())).await;
let active_row = approve_request(&pool, operator_id, active_request_id).await; let active_row = approve_request(&pool, operator_id, active_request_id).await;
assert_eq!(active_row.provisioned_user_id, Some(active_user_id)); assert_eq!(active_row.provisioned_user_id, Some(active_user_id));
assert!(
active_row.trial_ends_at.is_some(),
"active existing users still receive a provisioned project trial expiry"
);
let active_user_state = sqlx::query("SELECT password_hash, is_active FROM users WHERE id = $1") let active_user_state = sqlx::query("SELECT password_hash, is_active FROM users WHERE id = $1")
.bind(active_user_id) .bind(active_user_id)
.fetch_one(&pool) .fetch_one(&pool)

View File

@@ -0,0 +1,174 @@
//! DB-backed integration test for SCADA protocols and CSV import.
//!
//! Run with:
//! ```powershell
//! $env:DATABASE_URL='postgres://postgres:postgres@localhost:5432/omnioil_access_request_test'
//! cargo test -p backend-api --test scada_protocols_import_integration -- --ignored --nocapture
//! ```
use axum::extract::{Path, State};
use backend_api::{
auth::Claims,
handlers::auth::{PublicRegisterPayload, register},
handlers::import::import_csv_by_protocol,
};
use sqlx::{PgPool, Row, postgres::PgPoolOptions};
use uuid::Uuid;
fn required_database_url() -> String {
std::env::var("DATABASE_URL")
.expect("DATABASE_URL must be set explicitly for integration tests.")
}
async fn migrated_pool() -> PgPool {
let database_url = required_database_url();
let pool = PgPoolOptions::new()
.max_connections(5)
.connect(&database_url)
.await
.expect("DATABASE_URL must point to a reachable disposable PostgreSQL database");
sqlx::migrate!("./migrations")
.run(&pool)
.await
.expect("migrations must apply cleanly");
pool
}
#[tokio::test]
#[ignore = "requires explicit DATABASE_URL pointing at a disposable migrated PostgreSQL database"]
async fn test_public_registration_and_csv_import() {
let pool = migrated_pool().await;
// Create AppState stub
let (mqtt_client, _eventloop) = rumqttc::AsyncClient::new(
rumqttc::MqttOptions::new("test-client", "localhost", 1883),
10,
);
let (tx, _) = tokio::sync::broadcast::channel(100);
let state = backend_api::AppState {
pool: pool.clone(),
mqtt_client,
tx,
};
// 1. Test registration
let unique_email = format!("client-{}@example.test", Uuid::new_v4().simple());
let payload = PublicRegisterPayload {
email: unique_email.clone(),
password: "SuperSecurePassword123!".to_string(),
full_name: "John Doe".to_string(),
company: "Acme Oil Corp".to_string(),
nit: "123456789-0".to_string(),
};
let headers = axum::http::HeaderMap::new();
let register_result = register(
State(state.clone()),
headers,
backend_api::validation::ValidatedJson(payload),
)
.await;
assert!(register_result.is_ok(), "Registration should succeed");
// Fetch created project ID
let project_row =
sqlx::query("SELECT id, name FROM edge_projects WHERE client = 'Acme Oil Corp'")
.fetch_one(&pool)
.await
.expect("Project should be created");
let project_id: Uuid = project_row.try_get("id").unwrap();
// Verify trial subscription
let sub_row = sqlx::query("SELECT status FROM subscriptions WHERE project_id = $1")
.bind(project_id)
.fetch_one(&pool)
.await
.expect("Subscription should be created");
let sub_status: String = sub_row.try_get("status").unwrap();
assert_eq!(sub_status, "trial");
// 2. Test CSV Import
let claims = Claims {
sub: Uuid::new_v4().to_string(),
role: "admin".to_string(),
email: unique_email.clone(),
projects: vec![project_id],
exp: 9999999999,
};
// Import Modbus TCP CSV
let csv_content = "WellCode,DeviceName,Host,Port,UnitId,VariableAlias,Address,DataType\n\
WELL-A1,FlowMeter1,192.168.1.100,502,1,Temperature,HR:40001,float32\n\
WELL-A1,FlowMeter1,192.168.1.100,502,1,Pressure,HR:40002,float32\n";
let import_res = import_csv_by_protocol(
State(state.clone()),
claims.clone(),
Path((project_id, "MODBUS_TCP".to_string())),
csv_content.to_string(),
)
.await;
assert!(import_res.is_ok(), "CSV Import should succeed");
let import_json = import_res.unwrap();
assert_eq!(
import_json.0.get("status").unwrap().as_str().unwrap(),
"success"
);
assert_eq!(
import_json
.0
.get("wells_imported")
.unwrap()
.as_i64()
.unwrap(),
1
);
assert_eq!(
import_json
.0
.get("devices_imported")
.unwrap()
.as_i64()
.unwrap(),
1
);
assert_eq!(
import_json
.0
.get("variables_imported")
.unwrap()
.as_i64()
.unwrap(),
2
);
// Verify DB entries
let asset_row =
sqlx::query("SELECT id FROM edge_assets WHERE code = 'WELL-A1' AND project_id = $1")
.bind(project_id)
.fetch_one(&pool)
.await
.expect("Asset should exist");
let asset_id: Uuid = asset_row.try_get("id").unwrap();
let device_row = sqlx::query("SELECT id, protocol FROM edge_devices WHERE asset_id = $1")
.bind(asset_id)
.fetch_one(&pool)
.await
.expect("Device should exist");
let device_id: Uuid = device_row.try_get("id").unwrap();
let protocol: String = device_row.try_get("protocol").unwrap();
assert_eq!(protocol, "MODBUS_TCP");
let var_count: i64 =
sqlx::query_scalar("SELECT COUNT(*) FROM edge_variables WHERE device_id = $1")
.bind(device_id)
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(var_count, 2);
}

View File

@@ -5,7 +5,7 @@ FROM lukemathwalker/cargo-chef:latest-rust-1.93-slim-bookworm AS base
WORKDIR /app WORKDIR /app
# Instala dependencias del sistema necesarias para compilar/linkear # Instala dependencias del sistema necesarias para compilar/linkear
RUN apt-get update && apt-get install -y \ RUN apt-get update && apt-get install -y \
pkg-config libssl-dev libglib2.0-dev libatk1.0-dev libgtk-3-dev build-essential ca-certificates lld cmake perl \ pkg-config libssl-dev build-essential ca-certificates lld \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
# Linker LLD para reducir el tiempo de linkeado significativamente # Linker LLD para reducir el tiempo de linkeado significativamente
ENV RUSTFLAGS="-C link-arg=-fuse-ld=lld" ENV RUSTFLAGS="-C link-arg=-fuse-ld=lld"

View File

@@ -84,7 +84,13 @@ pub async fn handle_project_created(
.await; .await;
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 para este Agente // 1b. Obtener o generar Credenciales MQTT para este Agente
let (mqtt_user, mqtt_password) = { let (mqtt_user, mqtt_password) = {
@@ -109,13 +115,11 @@ pub async fn handle_project_created(
"🔑 Generando nuevas credenciales MQTT para proyecto: {}", "🔑 Generando nuevas credenciales MQTT para proyecto: {}",
project_id project_id
); );
sqlx::query( sqlx::query("UPDATE edge_projects SET mqtt_password = $1 WHERE id = $2")
"UPDATE edge_projects SET mqtt_password = $1 WHERE id = $2", .bind(&new_pass)
) .bind(project_id)
.bind(&new_pass) .execute(&pool)
.bind(project_id) .await?;
.execute(&pool)
.await?;
(project_id.to_string(), new_pass) (project_id.to_string(), new_pass)
} }
} }
@@ -185,13 +189,11 @@ pub async fn handle_project_created(
let agent_config_toml = config_json_to_toml(&agent_config_json) let agent_config_toml = config_json_to_toml(&agent_config_json)
.map_err(|e| format!("Error convirtiendo config a TOML: {}", e))?; .map_err(|e| format!("Error convirtiendo config a TOML: {}", e))?;
sqlx::query( sqlx::query("UPDATE edge_projects SET agent_config_toml = $1 WHERE id = $2")
"UPDATE edge_projects SET agent_config_toml = $1 WHERE id = $2", .bind(&agent_config_toml)
) .bind(project_id)
.bind(&agent_config_toml) .execute(&pool)
.bind(project_id) .await?;
.execute(&pool)
.await?;
// Generar token de provisioning de un solo uso (válido 30 días). // Generar token de provisioning de un solo uso (válido 30 días).
// Este token es lo único que viaja en el binario, junto con la URL del servidor. // Este token es lo único que viaja en el binario, junto con la URL del servidor.
@@ -232,7 +234,7 @@ pub async fn handle_project_created(
// rw=2 para publish (request), rw=4 para subscribe (response). // 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. // go-auth con mosquitto v2 usa MOSQ_ACL_SUBSCRIBE=4 — rw=1 fallaría subscribe.
let prov_acls = vec![ let prov_acls = vec![
(format!("provision/request/{}", project_id), 2i32), // publish only (format!("provision/request/{}", project_id), 2i32), // publish only
(format!("provision/response/{}", project_id), 4i32), // subscribe + read (format!("provision/response/{}", project_id), 4i32), // subscribe + read
]; ];
for (topic, rw) in prov_acls { for (topic, rw) in prov_acls {
@@ -249,8 +251,7 @@ pub async fn handle_project_created(
// Stub de provisioning: lo único que va en el binario. // Stub de provisioning: lo único que va en el binario.
// Apunta al broker MQTT público — el backend nunca es accesible desde el exterior. // Apunta al broker MQTT público — el backend nunca es accesible desde el exterior.
let mqtt_public_host = std::env::var("MQTT_PUBLIC_HOST") let mqtt_public_host = std::env::var("MQTT_PUBLIC_HOST").expect("MQTT_PUBLIC_HOST must be set");
.expect("MQTT_PUBLIC_HOST must be set");
let mqtt_public_port: u16 = std::env::var("MQTT_PUBLIC_PORT") let mqtt_public_port: u16 = std::env::var("MQTT_PUBLIC_PORT")
.expect("MQTT_PUBLIC_PORT must be set") .expect("MQTT_PUBLIC_PORT must be set")
.parse() .parse()
@@ -268,23 +269,26 @@ pub async fn handle_project_created(
project_id: project_id.to_string(), project_id: project_id.to_string(),
provisioning_token, provisioning_token,
}; };
// 2. Extraer dinámicamente la versión del edge-agent desde su Cargo.toml // 2. Obtener la versión de edge-agent (prioridad: variable de entorno AGENT_LATEST_VERSION, fallback: Cargo.toml)
let edge_agent_toml_path = std::env::var("EDGE_AGENT_CARGO_TOML") let version = std::env::var("AGENT_LATEST_VERSION").unwrap_or_else(|_| {
.unwrap_or_else(|_| "services/edge-agent/Cargo.toml".to_string()); let edge_agent_toml_path = std::env::var("EDGE_AGENT_CARGO_TOML")
.unwrap_or_else(|_| "services/edge-agent/Cargo.toml".to_string());
let edge_agent_toml = std::fs::read_to_string(&edge_agent_toml_path).unwrap_or_default(); let edge_agent_toml = std::fs::read_to_string(&edge_agent_toml_path).unwrap_or_default();
let mut version = "0.0.0".to_string(); let mut version = "0.0.0".to_string();
for line in edge_agent_toml.lines() { for line in edge_agent_toml.lines() {
if line.starts_with("version =") { if line.starts_with("version =") {
version = line version = line
.replace("version =", "") .replace("version =", "")
.replace("\"", "") .replace("\"", "")
.replace("'", "") .replace("'", "")
.trim() .trim()
.to_string(); .to_string();
break; break;
}
} }
} version
});
let exe_name = format!( let exe_name = format!(
"edge-agent-v{}-{}-{}.exe", "edge-agent-v{}-{}-{}.exe",
@@ -324,22 +328,52 @@ 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 // 🛠️ COMPILACIÓN AUTOMÁTICA DEL AGENTE (Solo si Cargo está disponible en el PATH)
// Nota: std::process::Command es bloqueante — se ejecuta en spawn_blocking let cargo_available = std::process::Command::new("cargo")
// para no bloquear el runtime de tokio durante los minutos que tarda el build. .arg("--version")
tracing::info!("🛠️ Compilando binario base del agente (cargo build --release)..."); .stdout(std::process::Stdio::null())
let build_result = tokio::task::spawn_blocking(|| { .stderr(std::process::Stdio::null())
std::process::Command::new("cargo") .status()
.args(["build", "-p", "edge-agent", "--release", "--features", "edge-agent/opcua-support"]) .map(|s| s.success())
.status() .unwrap_or(false);
})
.await;
match build_result { if cargo_available {
Ok(Ok(s)) if s.success() => tracing::info!(" Compilación del agente completada con éxito."), tracing::info!("🛠️ Compilando binario base del agente (cargo build --release)...");
Ok(Ok(s)) => tracing::warn!("⚠️ cargo build salió con código {:?}. Intentando usar binario existente...", s.code()), let build_result = tokio::task::spawn_blocking(|| {
Ok(Err(e)) => tracing::warn!("⚠️ No se pudo ejecutar cargo build: {}. Intentando usar binario existente...", e), std::process::Command::new("cargo")
Err(e) => tracing::warn!("⚠️ spawn_blocking falló: {}. Intentando usar binario existente...", e), .args([
"build",
"-p",
"edge-agent",
"--release",
"--features",
"edge-agent/opcua-support",
])
.status()
})
.await;
match build_result {
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()
),
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
),
}
} else {
tracing::info!(
" Cargo no está disponible en este entorno (modo contenedor). Se omitirá la compilación en caliente y se usará la plantilla pre-compilada."
);
} }
// Intentar encontrar el binario recién compilado (ahora preferimos release) // Intentar encontrar el binario recién compilado (ahora preferimos release)
@@ -349,7 +383,8 @@ pub async fn handle_project_created(
"target/debug/edge-agent.exe", "target/debug/edge-agent.exe",
]; ];
let template_path_found = template_paths.iter() let template_path_found = template_paths
.iter()
.map(std::path::Path::new) .map(std::path::Path::new)
.find(|p| p.exists() && p.is_file()) .find(|p| p.exists() && p.is_file())
.ok_or_else(|| { .ok_or_else(|| {
@@ -358,9 +393,13 @@ pub async fn handle_project_created(
err err
})?; })?;
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_provisioning_binary(template_path_found, &exe_path, &provisioning_stub) { if let Err(e) = generate_provisioning_binary(template_path_found, &exe_path, &provisioning_stub)
{
tracing::error!("❌ Error FATAL en inyección de stub de provisioning: {}", e); tracing::error!("❌ Error FATAL en inyección de stub de provisioning: {}", e);
let _ = update_deployment_status( let _ = update_deployment_status(
&pool, &pool,
@@ -386,7 +425,13 @@ pub async fn handle_project_created(
tracing::info!("✅ Overlay COMPLETADO en {:?}. Esperando MSI...", exe_path); tracing::info!("✅ Overlay COMPLETADO en {:?}. Esperando MSI...", exe_path);
tracing::info!("🔔 MSI Worker: Starting WIX_GENERATION phase"); tracing::info!("🔔 MSI Worker: Starting WIX_GENERATION phase");
let _ = crate::publisher::publish_build_status(&mqtt_client, project_id, "WIX_GENERATION", "Generando paquete MSI (WiX)...").await; let _ = crate::publisher::publish_build_status(
&mqtt_client,
project_id,
"WIX_GENERATION",
"Generando paquete MSI (WiX)...",
)
.await;
// Esperar a que el worker MSI procese el archivo (máx 60 segundos) // Esperar a que el worker MSI procese el archivo (máx 60 segundos)
let mut retries = 0; let mut retries = 0;
@@ -418,14 +463,19 @@ pub async fn handle_project_created(
msi_path msi_path
); );
tracing::info!("🔔 MSI Worker: Starting UPLOADING phase"); tracing::info!("🔔 MSI Worker: Starting UPLOADING phase");
let _ = crate::publisher::publish_build_status(&mqtt_client, project_id, "UPLOADING", "Subiendo instalador a la nube...").await; let _ = crate::publisher::publish_build_status(
&mqtt_client,
project_id,
"UPLOADING",
"Subiendo instalador a la nube...",
)
.await;
// ─── Subida a MinIO ─────── // ─── Subida a MinIO ───────
let region_provider = RegionProviderChain::default_provider().or_else(Region::new("us-east-1")); let region_provider = RegionProviderChain::default_provider().or_else(Region::new("us-east-1"));
let endpoint_url = let endpoint_url = std::env::var("MINIO_ENDPOINT_URL").expect("MINIO_ENDPOINT_URL must be set");
std::env::var("MINIO_ENDPOINT_URL").expect("MINIO_ENDPOINT_URL must be set");
let config = aws_config::from_env() let config = aws_config::defaults(aws_config::BehaviorVersion::latest())
.region(region_provider) .region(region_provider)
.endpoint_url(&endpoint_url) .endpoint_url(&endpoint_url)
.load() .load()
@@ -436,8 +486,8 @@ pub async fn handle_project_created(
.build(); .build();
let client = Client::from_conf(s3_config); let client = Client::from_conf(s3_config);
let bucket_name = std::env::var("S3_INSTALLERS_BUCKET") let bucket_name =
.unwrap_or_else(|_| "omnioil-releases".to_string()); std::env::var("S3_INSTALLERS_BUCKET").unwrap_or_else(|_| "omnioil-releases".to_string());
// Intentamos crear el bucket por si no existe // Intentamos crear el bucket por si no existe
let _ = client.create_bucket().bucket(&bucket_name).send().await; let _ = client.create_bucket().bucket(&bucket_name).send().await;
@@ -472,7 +522,13 @@ pub async fn handle_project_created(
.await; .await;
tracing::info!("🔔 MSI Worker: FINALIZING - Setting status to READY"); tracing::info!("🔔 MSI Worker: FINALIZING - Setting status to READY");
let _ = crate::publisher::publish_build_status(&mqtt_client, project_id, "READY", "Instalador listo para descargar").await; let _ = crate::publisher::publish_build_status(
&mqtt_client,
project_id,
"READY",
"Instalador listo para descargar",
)
.await;
let _ = sqlx::query( let _ = sqlx::query(
"UPDATE edge_projects SET installer_status = 'READY', installer_url = $1 WHERE id = $2" "UPDATE edge_projects SET installer_status = 'READY', installer_url = $1 WHERE id = $2"
@@ -498,3 +554,85 @@ pub async fn handle_project_created(
Ok(()) Ok(())
} }
pub async fn upload_generic_linux_binary() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let binary_path = std::path::Path::new("target/x86_64-unknown-linux-musl/release/edge-agent");
if !binary_path.exists() {
tracing::warn!("⚠️ No se encontró el binario genérico de Linux para el agente en: {:?}", binary_path);
return Ok(());
}
tracing::info!("📥 Detectado binario genérico de Linux. Preparando subida a MinIO...");
// 1. Obtener la versión
let version = std::env::var("AGENT_LATEST_VERSION").unwrap_or_else(|_| {
let edge_agent_toml_path = std::env::var("EDGE_AGENT_CARGO_TOML")
.unwrap_or_else(|_| "services/edge-agent/Cargo.toml".to_string());
let edge_agent_toml = std::fs::read_to_string(&edge_agent_toml_path).unwrap_or_default();
let mut version = "0.0.0".to_string();
for line in edge_agent_toml.lines() {
if line.starts_with("version =") {
version = line
.replace("version =", "")
.replace("\"", "")
.replace("'", "")
.trim()
.to_string();
break;
}
}
version
});
// 2. Configurar cliente S3/MinIO
let region_provider = RegionProviderChain::default_provider().or_else(Region::new("us-east-1"));
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("S3/MinIO credentials (AWS_ACCESS_KEY_ID or MINIO_USER) must be set");
let secret_key = std::env::var("AWS_SECRET_ACCESS_KEY")
.or_else(|_| std::env::var("MINIO_PASSWORD"))
.expect("S3/MinIO credentials (AWS_SECRET_ACCESS_KEY or MINIO_PASSWORD) must be set");
let credentials = aws_sdk_s3::config::Credentials::new(
access_key,
secret_key,
None,
None,
"FromEnvOrFallback",
);
let config = aws_config::defaults(aws_config::BehaviorVersion::latest())
.region(region_provider)
.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 = Client::from_conf(s3_config);
let bucket_name =
std::env::var("S3_INSTALLERS_BUCKET").unwrap_or_else(|_| "omnioil-releases".to_string());
// Crear el bucket si no existe
let _ = client.create_bucket().bucket(&bucket_name).send().await;
let body = aws_sdk_s3::primitives::ByteStream::from_path(binary_path).await?;
let key = "agents/linux/x86_64/edge-agent".to_string();
tracing::info!("🚀 Subiendo binario Linux del agente v{} a MinIO (bucket: {}, key: {})...", version, bucket_name, key);
client
.put_object()
.bucket(&bucket_name)
.key(&key)
.body(body)
.send()
.await?;
tracing::info!("✅ Binario Linux del agente subido exitosamente.");
Ok(())
}

View File

@@ -1,5 +1,5 @@
mod publisher;
mod installer_worker; mod installer_worker;
mod publisher;
use uuid::Uuid; use uuid::Uuid;
@@ -47,7 +47,10 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
if attempt >= 10 { if attempt >= 10 {
return Err(e.into()); return Err(e.into());
} }
tracing::warn!("⏳ DB no disponible (intento {}/10), reintentando en 3s...", attempt); tracing::warn!(
"⏳ DB no disponible (intento {}/10), reintentando en 3s...",
attempt
);
tokio::time::sleep(std::time::Duration::from_secs(3)).await; tokio::time::sleep(std::time::Duration::from_secs(3)).await;
} }
} }
@@ -63,8 +66,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
.expect("MQTT_PORT must be a valid number"); .expect("MQTT_PORT must be a valid number");
let client_id = format!("omnioil-build-orchestrator-{}", uuid::Uuid::new_v4()); let client_id = format!("omnioil-build-orchestrator-{}", uuid::Uuid::new_v4());
let mut mqtt_options = let mut mqtt_options = rumqttc::MqttOptions::new(client_id, &mqtt_host, mqtt_port);
rumqttc::MqttOptions::new(client_id, &mqtt_host, mqtt_port);
mqtt_options.set_keep_alive(std::time::Duration::from_secs(30)); mqtt_options.set_keep_alive(std::time::Duration::from_secs(30));
// 256KB: suficiente para binarios de instalador por partes y payloads comprimidos // 256KB: suficiente para binarios de instalador por partes y payloads comprimidos
mqtt_options.set_max_packet_size(256 * 1024, 256 * 1024); mqtt_options.set_max_packet_size(256 * 1024, 256 * 1024);
@@ -101,27 +103,54 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
if topic == "omnioil/events/project_created" { if topic == "omnioil/events/project_created" {
let payload_str = String::from_utf8_lossy(&payload); let payload_str = String::from_utf8_lossy(&payload);
tracing::info!("📥 Recibido Project Created Event: {}", payload_str); tracing::info!("📥 Recibido Project Created Event: {}", payload_str);
if let Ok(event) = serde_json::from_str::<shared_lib::mqtt_messages::ProjectCreatedEvent>(&payload_str) { if let Ok(event) = serde_json::from_str::<
shared_lib::mqtt_messages::ProjectCreatedEvent,
>(&payload_str)
{
tokio::spawn(async move { tokio::spawn(async move {
if let Err(e) = installer_worker::handle_project_created(state.db_pool.clone(), state.mqtt_client.clone(), event).await { if let Err(e) = installer_worker::handle_project_created(
state.db_pool.clone(),
state.mqtt_client.clone(),
event,
)
.await
{
tracing::error!("Worker de instalador falló: {}", e); tracing::error!("Worker de instalador falló: {}", e);
} }
}); });
} }
} else if topic.starts_with("omnioil/control/deploy/") { } else if topic.starts_with("omnioil/control/deploy/") {
let payload_str = String::from_utf8_lossy(&payload); let payload_str = String::from_utf8_lossy(&payload);
tracing::info!("📥 Recibido Deployment Request. Payload crudo: {}", payload_str); tracing::info!(
"📥 Recibido Deployment Request. Payload crudo: {}",
payload_str
);
if let Ok(config) = serde_json::from_str::<shared_lib::edge_models::ProjectConfig>(&payload_str) { if let Ok(config) = serde_json::from_str::<
shared_lib::edge_models::ProjectConfig,
>(&payload_str)
{
tokio::spawn(async move { tokio::spawn(async move {
let deployment_id = Uuid::new_v4(); let deployment_id = Uuid::new_v4();
tracing::info!("🚀 Proyecto deserializado: {}. Activos encontrados: {}", config.project.name, config.assets.len()); tracing::info!(
if let Err(e) = publisher::execute_build_pipeline(&state, &config, deployment_id).await { "🚀 Proyecto deserializado: {}. Activos encontrados: {}",
config.project.name,
config.assets.len()
);
if let Err(e) = publisher::execute_build_pipeline(
&state,
&config,
deployment_id,
)
.await
{
tracing::error!("Pipeline de build falló: {}", e); tracing::error!("Pipeline de build falló: {}", e);
} }
}); });
} else { } else {
tracing::error!("❌ Fallo al deserializar ProjectConfig. Revisa la estructura del JSON."); tracing::error!(
"❌ Fallo al deserializar ProjectConfig. Revisa la estructura del JSON."
);
} }
} }
} }
@@ -134,6 +163,13 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
} }
}); });
// Subir binario genérico de Linux a MinIO si existe en el contenedor
tokio::spawn(async move {
if let Err(e) = installer_worker::upload_generic_linux_binary().await {
tracing::error!("Error al subir el binario genérico de Linux: {}", e);
}
});
tracing::info!("✅ Build Orchestrator ready. Waiting for messages..."); tracing::info!("✅ Build Orchestrator ready. Waiting for messages...");
tokio::signal::ctrl_c().await?; tokio::signal::ctrl_c().await?;

View File

@@ -54,7 +54,9 @@ pub async fn publish_deploy_signal(
tracing::info!( tracing::info!(
"📡 Publishing deploy signal to topic: {} ({} → {} bytes)", "📡 Publishing deploy signal to topic: {} ({} → {} bytes)",
topic, payload_json.len(), payload.len() topic,
payload_json.len(),
payload.len()
); );
mqtt_client mqtt_client
@@ -110,13 +112,17 @@ pub async fn execute_build_pipeline(
.await?; .await?;
// 2. Generar hash de configuración para control de versiones // 2. Generar hash de configuración para control de versiones
use sha2::{Sha256, Digest}; use sha2::{Digest, Sha256};
let config_json = serde_json::to_string(config).unwrap_or_default(); let config_json = serde_json::to_string(config).unwrap_or_default();
let mut hasher = Sha256::new(); let mut hasher = Sha256::new();
hasher.update(config_json.as_bytes()); hasher.update(config_json.as_bytes());
let config_hash = hex::encode(hasher.finalize()); let config_hash = hex::encode(hasher.finalize());
tracing::info!("📦 Deploying project {} with {} assets", project_id, config.assets.len()); tracing::info!(
"📦 Deploying project {} with {} assets",
project_id,
config.assets.len()
);
// 3. Publicar señal de deploy directamente al agente nativo // 3. Publicar señal de deploy directamente al agente nativo
publish_deploy_signal( publish_deploy_signal(

View File

@@ -5,7 +5,7 @@ FROM lukemathwalker/cargo-chef:latest-rust-1.93-slim-bookworm AS base
WORKDIR /app WORKDIR /app
# Instala dependencias del sistema necesarias para compilar crates de C y linkeo rápido # Instala dependencias del sistema necesarias para compilar crates de C y linkeo rápido
RUN apt-get update && apt-get install -y \ RUN apt-get update && apt-get install -y \
pkg-config libssl-dev libglib2.0-dev libatk1.0-dev libgtk-3-dev build-essential ca-certificates lld \ pkg-config libssl-dev build-essential ca-certificates lld \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
# Linker LLD para reducir el tiempo de linkeado significativamente # Linker LLD para reducir el tiempo de linkeado significativamente
ENV RUSTFLAGS="-C link-arg=-fuse-ld=lld" ENV RUSTFLAGS="-C link-arg=-fuse-ld=lld"

View File

@@ -33,7 +33,8 @@ impl TelemetryFetcher {
return Ok(HashMap::new()); return Ok(HashMap::new());
} }
let resp_res = self.client let resp_res = self
.client
.get(api_url) .get(api_url)
.header("X-API-KEY", api_key) .header("X-API-KEY", api_key)
.send() .send()
@@ -44,7 +45,12 @@ impl TelemetryFetcher {
let resp: Vec<TagReading> = match serde_json::from_str(&body) { let resp: Vec<TagReading> = match serde_json::from_str(&body) {
Ok(data) => data, Ok(data) => data,
Err(e) => { Err(e) => {
tracing::error!("❌ Failed to decode JSON from {}. Error: {}. Body: {}", api_url, e, body); tracing::error!(
"❌ Failed to decode JSON from {}. Error: {}. Body: {}",
api_url,
e,
body
);
return Err(e.into()); return Err(e.into());
} }
}; };
@@ -54,9 +60,9 @@ impl TelemetryFetcher {
let now = Utc::now(); let now = Utc::now();
for reading in resp { for reading in resp {
// Si la API externa no manda timestamp, usamos 'ahora' // Si la API externa no manda timestamp, usamos 'ahora'
let ts = reading.timestamp.unwrap_or(now); let ts = reading.timestamp.unwrap_or(now);
data_map.insert(reading.tag_id, (ts, reading.value)); data_map.insert(reading.tag_id, (ts, reading.value));
} }
Ok(data_map) Ok(data_map)

View File

@@ -4,12 +4,12 @@ mod persistence;
use crate::fetcher::TelemetryFetcher; use crate::fetcher::TelemetryFetcher;
use dotenvy::dotenv; use dotenvy::dotenv;
use shared_lib::models::EdgeAsset;
use sqlx::postgres::PgPoolOptions; use sqlx::postgres::PgPoolOptions;
use std::env; use std::env;
use std::time::Duration; use std::time::Duration;
use tokio::time::sleep; use tokio::time::sleep;
use tracing::{error, info, warn}; use tracing::{error, info, warn};
use shared_lib::models::EdgeAsset;
#[tokio::main] #[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> { async fn main() -> Result<(), Box<dyn std::error::Error>> {
@@ -33,8 +33,11 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
loop { loop {
// 1. Buscar todos los Assets que tengan la integración activa // 1. Buscar todos los Assets que tengan la integración activa
let active_assets = match sqlx::query_as::<_, EdgeAsset>( let active_assets = match sqlx::query_as::<_, EdgeAsset>(
"SELECT * FROM edge_assets WHERE is_collector_enabled = true" "SELECT * FROM edge_assets WHERE is_collector_enabled = true",
).fetch_all(&pool).await { )
.fetch_all(&pool)
.await
{
Ok(assets) => assets, Ok(assets) => assets,
Err(e) => { Err(e) => {
error!("❌ Error querying active assets: {}", e); error!("❌ Error querying active assets: {}", e);
@@ -59,7 +62,10 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
}; };
let key = asset.external_api_key.as_deref().unwrap_or(""); let key = asset.external_api_key.as_deref().unwrap_or("");
info!("🔍 Polling external API for asset: {} ({})", asset.name, asset.code); info!(
"🔍 Polling external API for asset: {} ({})",
asset.name, asset.code
);
match fetcher.fetch_asset_telemetry(url, key).await { match fetcher.fetch_asset_telemetry(url, key).await {
Ok(data_map) => { Ok(data_map) => {
@@ -74,7 +80,10 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
payload.insert(tag_name, value); payload.insert(tag_name, value);
let data_json = serde_json::Value::Object(payload); let data_json = serde_json::Value::Object(payload);
if let Err(e) = persistence::ingest_telemetry_batch(&pool, &asset.code, time, data_json).await { if let Err(e) =
persistence::ingest_telemetry_batch(&pool, &asset.code, time, data_json)
.await
{
error!("❌ Error ingesting data for asset {}: {}", asset.name, e); error!("❌ Error ingesting data for asset {}: {}", asset.name, e);
} }
} }

View File

@@ -3,9 +3,9 @@
//! Implementa el patrón "Transactional Outbox" para asegurar que los datos //! Implementa el patrón "Transactional Outbox" para asegurar que los datos
//! se guarden y los eventos se generen de forma atómica. //! se guarden y los eventos se generen de forma atómica.
use shared_lib::events::{DomainEvent, TelemetryBatchPayload};
use sqlx::{PgPool, Row}; use sqlx::{PgPool, Row};
use tracing::{info, warn}; use tracing::{info, warn};
use shared_lib::events::{DomainEvent, TelemetryBatchPayload};
/// Procesa un lote de telemetría proveniente de un activo. /// Procesa un lote de telemetría proveniente de un activo.
/// ///
@@ -69,10 +69,16 @@ pub async fn ingest_telemetry_batch(
// 5. Commit // 5. Commit
tx.commit().await?; tx.commit().await?;
info!("✅ Datos + Evento procesados para {} en {}", asset_code, time); info!(
"✅ Datos + Evento procesados para {} en {}",
asset_code, time
);
Ok(()) Ok(())
} else { } else {
warn!("⚠️ Activo desconocido recibido: {}. Saltando ingesta.", asset_code); warn!(
"⚠️ Activo desconocido recibido: {}. Saltando ingesta.",
asset_code
);
let _ = tx.rollback().await; let _ = tx.rollback().await;
Ok(()) Ok(())
} }

View File

@@ -33,6 +33,7 @@ sha2 = { workspace = true }
rand = { workspace = true } rand = { workspace = true }
tokio-modbus = { workspace = true } tokio-modbus = { workspace = true }
s7 = { workspace = true } s7 = { workspace = true }
rustls = { version = "0.23", default-features = false, features = ["ring"] }
async-trait = "0.1.86" async-trait = "0.1.86"
opcua = { version = "0.12", features = ["client"], optional = true } opcua = { version = "0.12", features = ["client"], optional = true }
# vendored compiles OpenSSL from source — no external OpenSSL installation needed. # vendored compiles OpenSSL from source — no external OpenSSL installation needed.

View File

@@ -1,10 +1,27 @@
# syntax=docker/dockerfile:1
# ============================================================================= # =============================================================================
# MULTI-STAGE Dockerfile: Edge Agent MSI Builder (Optimized with cargo-chef) # MULTI-STAGE Dockerfile: Edge Agent MSI Builder (Optimized with cargo-chef)
# ============================================================================= # =============================================================================
# --- ETAPA 1: PLANNER: Genera el archivo recipe.json para cachear dependencias --- # --- ETAPA 0: BASE IMAGE ---
FROM lukemathwalker/cargo-chef:latest-rust-1.93-slim-bookworm AS planner FROM lukemathwalker/cargo-chef:latest-rust-1.93-slim-bookworm AS base
WORKDIR /app WORKDIR /app
# Instalar toolchain de Windows (GNU) y Linux musl en un solo paso
RUN apt-get update && apt-get install -y --no-install-recommends \
mingw-w64 \
pkg-config \
libssl-dev \
perl \
make \
musl-tools \
&& rm -rf /var/lib/apt/lists/*
RUN rustup target add x86_64-pc-windows-gnu && \
rustup target add x86_64-unknown-linux-musl
# --- ETAPA 1: PLANNER: Genera el archivo recipe.json para cachear dependencias ---
FROM base AS planner
# Copiamos solo los archivos que definen dependencias (workspace y miembros) # Copiamos solo los archivos que definen dependencias (workspace y miembros)
COPY Cargo.toml Cargo.lock ./ COPY Cargo.toml Cargo.lock ./
COPY services/shared-lib/Cargo.toml services/shared-lib/Cargo.toml COPY services/shared-lib/Cargo.toml services/shared-lib/Cargo.toml
@@ -18,35 +35,18 @@ COPY services/telemetry-ingestor/Cargo.toml services/telemetry-ingestor/Cargo.to
# Creamos estructuras dummy para que cargo metadata no falle (muy importante para la caché) # Creamos estructuras dummy para que cargo metadata no falle (muy importante para la caché)
RUN mkdir -p services/shared-lib/src && touch services/shared-lib/src/lib.rs && \ RUN mkdir -p services/shared-lib/src && touch services/shared-lib/src/lib.rs && \
mkdir -p services/flow-generator/src && touch services/flow-generator/src/lib.rs && \ mkdir -p services/backend-api/src && touch services/backend-api/src/lib.rs && echo "fn main() {}" > services/backend-api/src/main.rs && \
mkdir -p services/backend-api/src && echo "fn main() {}" > services/backend-api/src/main.rs && \
mkdir -p services/data-collector/src && echo "fn main() {}" > services/data-collector/src/main.rs && \ mkdir -p services/data-collector/src && echo "fn main() {}" > services/data-collector/src/main.rs && \
mkdir -p services/json-generator/src && echo "fn main() {}" > services/json-generator/src/main.rs && \ mkdir -p services/json-generator/src && touch services/json-generator/src/lib.rs && echo "fn main() {}" > services/json-generator/src/main.rs && \
mkdir -p services/events-relay/src && echo "fn main() {}" > services/events-relay/src/main.rs && \ mkdir -p services/events-relay/src && echo "fn main() {}" > services/events-relay/src/main.rs && \
mkdir -p services/build-orchestrator/src && echo "fn main() {}" > services/build-orchestrator/src/main.rs && \ mkdir -p services/build-orchestrator/src && echo "fn main() {}" > services/build-orchestrator/src/main.rs && \
mkdir -p services/edge-agent/src && echo "fn main() {}" > services/edge-agent/src/main.rs && \ mkdir -p services/edge-agent/src/bin && echo "fn main() {}" > services/edge-agent/src/bin/edge-tray.rs && echo "fn main() {}" > services/edge-agent/src/main.rs && \
mkdir -p services/telemetry-ingestor/src && echo "fn main() {}" > services/telemetry-ingestor/src/main.rs mkdir -p services/telemetry-ingestor/src && echo "fn main() {}" > services/telemetry-ingestor/src/main.rs
RUN cargo chef prepare --recipe-path recipe.json --bin edge-agent RUN cargo chef prepare --recipe-path recipe.json
# --- ETAPA 2: CACHER (Cross-Compilation) --- # --- ETAPA 2: CACHER (Cross-Compilation) ---
FROM lukemathwalker/cargo-chef:latest-rust-1.93-slim-bookworm AS cacher FROM base AS cacher
WORKDIR /app
# Instalar toolchain de Windows (GNU)
RUN apt-get update && apt-get install -y --no-install-recommends \
mingw-w64 \
pkg-config \
libssl-dev \
libglib2.0-dev \
cmake \
perl \
make \
&& rm -rf /var/lib/apt/lists/*
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
@@ -54,47 +54,54 @@ 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
ENV OPENSSL_NO_ASM=1 ENV OPENSSL_NO_ASM=1
# Compilar dependencias para el target de Windows # Compilar dependencias para el target de Windows (con RUSTFLAGS idénticos)
COPY --from=planner /app/recipe.json recipe.json COPY --from=planner /app/recipe.json recipe.json
RUN cargo chef cook --release --target x86_64-pc-windows-gnu --recipe-path recipe.json RUN --mount=type=cache,target=/app/target \
--mount=type=cache,sharing=locked,target=/usr/local/cargo/registry \
--mount=type=cache,sharing=locked,target=/usr/local/cargo/git \
RUSTFLAGS="-C target-feature=+crt-static -C link-args=-static" \
cargo chef cook --release --target x86_64-pc-windows-gnu --features edge-agent/opcua-support --recipe-path recipe.json
# Compilar dependencias para el target de Linux musl
RUN --mount=type=cache,target=/app/target \
--mount=type=cache,sharing=locked,target=/usr/local/cargo/registry \
--mount=type=cache,sharing=locked,target=/usr/local/cargo/git \
cargo chef cook --release --target x86_64-unknown-linux-musl --features edge-agent/opcua-support --recipe-path recipe.json
# --- ETAPA 3: TOOLCHAIN & BUILDER --- # --- ETAPA 3: TOOLCHAIN & BUILDER ---
FROM rust:1.93-slim-bookworm AS builder FROM base AS builder
WORKDIR /app
# Instalar dependencias necesarias
RUN apt-get update && apt-get install -y --no-install-recommends \
mingw-w64 \
pkg-config \
libssl-dev \
libglib2.0-dev \
cmake \
perl \
make \
&& rm -rf /var/lib/apt/lists/*
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/*
COPY . .
COPY --from=cacher /app/target target
COPY --from=cacher /usr/local/cargo /usr/local/cargo
# 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
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
# Disable OpenSSL ASM optimizations for cross-compilation (avoids needing nasm for Windows target)
ENV OPENSSL_NO_ASM=1 ENV OPENSSL_NO_ASM=1
COPY . .
COPY --from=cacher /usr/local/cargo /usr/local/cargo
# Parchear la versión del agente directamente en Cargo.toml antes de compilar.
ARG BUILD_VERSION=0.3.0
RUN CURRENT_VERSION=$(grep -m 1 "^version =" services/edge-agent/Cargo.toml | cut -d '"' -f 2) && \
if [ "$CURRENT_VERSION" != "$BUILD_VERSION" ]; then \
sed -i "0,/^version = /s/^version = \"[^\"]*\"/version = \"${BUILD_VERSION}\"/" services/edge-agent/Cargo.toml; \
fi
# Compilar binarios de producción para Windows GNU (Estático total) y Linux musl # 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" \ RUN --mount=type=cache,target=/app/target \
--mount=type=cache,sharing=locked,target=/usr/local/cargo/registry \
--mount=type=cache,sharing=locked,target=/usr/local/cargo/git \
RUSTFLAGS="-C target-feature=+crt-static -C link-args=-static" \
cargo build --release --bin edge-agent --bin edge-tray \ cargo build --release --bin edge-agent --bin edge-tray \
--target x86_64-pc-windows-gnu \ --target x86_64-pc-windows-gnu \
--features edge-agent/opcua-support && \ --features edge-agent/opcua-support && \
cargo build --release --bin edge-agent \ cargo build --release --bin edge-agent \
--target x86_64-unknown-linux-musl \ --target x86_64-unknown-linux-musl \
--features edge-agent/opcua-support --features edge-agent/opcua-support && \
mkdir -p /app/bin/windows && cp target/x86_64-pc-windows-gnu/release/edge-agent.exe /app/bin/windows/ && \
cp target/x86_64-pc-windows-gnu/release/edge-tray.exe /app/bin/windows/ && \
mkdir -p /app/bin/linux && cp target/x86_64-unknown-linux-musl/release/edge-agent /app/bin/linux/
# --- ETAPA 4: EMPAQUETADOR (MSI) --- # --- ETAPA 4: EMPAQUETADOR (MSI) ---
FROM debian:bookworm-slim FROM debian:bookworm-slim
@@ -110,9 +117,9 @@ WORKDIR /app
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/ 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/bin/windows/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/bin/windows/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 --from=builder /app/bin/linux/edge-agent /app/services/edge-agent/target/x86_64-unknown-linux-musl/release/
COPY ./services/edge-agent/Cargo.toml /app/services/edge-agent/Cargo.toml COPY ./services/edge-agent/Cargo.toml /app/services/edge-agent/Cargo.toml
COPY ./services/edge-agent/wix /app/services/edge-agent/wix COPY ./services/edge-agent/wix /app/services/edge-agent/wix

View File

@@ -7,7 +7,10 @@ fn main() {
if cfg!(unix) { if cfg!(unix) {
res.set_windres_path("x86_64-w64-mingw32-windres"); res.set_windres_path("x86_64-w64-mingw32-windres");
if let Err(e) = res.compile() { if let Err(e) = res.compile() {
println!("cargo:warning=Error al compilar recursos de Windows (Linux host): {}", e); println!(
"cargo:warning=Error al compilar recursos de Windows (Linux host): {}",
e
);
} }
} else { } else {
let _ = res.compile(); let _ = res.compile();
@@ -21,7 +24,11 @@ fn main() {
let config_path = std::path::Path::new("embedded_config.toml"); let config_path = std::path::Path::new("embedded_config.toml");
let baked_bytes = if config_path.exists() { let baked_bytes = if config_path.exists() {
match std::fs::read_to_string(config_path) { match std::fs::read_to_string(config_path) {
Ok(content) => content.into_bytes().iter().map(|b| b ^ 0x5A).collect::<Vec<u8>>(), Ok(content) => content
.into_bytes()
.iter()
.map(|b| b ^ 0x5A)
.collect::<Vec<u8>>(),
Err(e) => { Err(e) => {
println!("cargo:warning=No se pudo leer embedded_config.toml: {}", e); println!("cargo:warning=No se pudo leer embedded_config.toml: {}", e);
Vec::new() Vec::new()
@@ -33,7 +40,7 @@ fn main() {
let _ = std::fs::write( let _ = std::fs::write(
&dest_path, &dest_path,
format!("pub const BAKED_CONFIG_XOR: &[u8] = &{:?};", baked_bytes) format!("pub const BAKED_CONFIG_XOR: &[u8] = &{:?};", baked_bytes),
); );
println!("cargo:rerun-if-changed=embedded_config.toml"); println!("cargo:rerun-if-changed=embedded_config.toml");

34
services/edge-agent/build_msi_linux.sh Normal file → Executable file
View File

@@ -44,10 +44,40 @@ ls -lR target/x86_64-pc-windows-msvc/
# 4. Construir el MSI usando wixl # 4. Construir el MSI usando wixl
if command -v wixl &> /dev/null; then if command -v wixl &> /dev/null; then
# Detectar la versión actual en Cargo.toml
VERSION=$(grep -m 1 "^version =" Cargo.toml | cut -d '"' -f 2)
if [ -z "$VERSION" ]; then
VERSION=$(grep -m 1 "^version =" Cargo.toml | cut -d "'" -f 2)
fi
if [ -z "$VERSION" ]; then
VERSION="0.3.0"
fi
echo "📌 Versión de Edge Agent detectada desde Cargo.toml: $VERSION"
# Crear una copia temporal del archivo WXS para no destruir el original o causar conflictos en git
TEMP_WXS="wix/main_temp.wxs"
cp wix/main.wxs "$TEMP_WXS"
# Aplicar versión a la copia temporal
sed -i "s/ Version='[0-9.]*'/ Version='$VERSION'/" "$TEMP_WXS"
sed -i "s/{{VERSION}}/$VERSION/g" "$TEMP_WXS"
echo "🔨 Generando MSI usando wixl..." echo "🔨 Generando MSI usando wixl..."
wixl -v -o OmniOilEdgeAgent.msi wix/main.wxs wixl -v -o OmniOilEdgeAgent.msi "$TEMP_WXS"
echo "✅ MSI generado con éxito: OmniOilEdgeAgent.msi" WIXL_EXIT=$?
# Limpiar archivo temporal
rm -f "$TEMP_WXS"
if [ $WIXL_EXIT -eq 0 ]; then
echo "✅ MSI generado con éxito: OmniOilEdgeAgent.msi"
else
echo "❌ Error al generar el MSI con wixl."
exit 1
fi
else else
echo "❌ Error: 'wixl' no se encontró en el PATH." echo "❌ Error: 'wixl' no se encontró en el PATH."
exit 1 exit 1
fi fi

0
services/edge-agent/msi_worker.sh Normal file → Executable file
View File

View File

@@ -6,19 +6,19 @@
#![windows_subsystem = "windows"] #![windows_subsystem = "windows"]
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use tray_icon::{ use tray_icon::{
menu::{Menu, MenuEvent, MenuItem, PredefinedMenuItem, MenuId}, TrayIcon, TrayIconBuilder,
TrayIconBuilder, TrayIcon, menu::{Menu, MenuEvent, MenuId, MenuItem, PredefinedMenuItem},
}; };
use winit::event_loop::{ControlFlow, EventLoop};
use winit::application::ApplicationHandler; use winit::application::ApplicationHandler;
use winit::event_loop::ActiveEventLoop; use winit::event_loop::ActiveEventLoop;
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; use winit::event_loop::{ControlFlow, EventLoop};
// Protocolo compartido // Protocolo compartido
#[path = "../ipc_protocol.rs"] #[path = "../ipc_protocol.rs"]
mod ipc_protocol; mod ipc_protocol;
use ipc_protocol::{IpcMessage, AgentCommand}; use ipc_protocol::{AgentCommand, IpcMessage};
const PIPE_NAME: &str = r"\\.\pipe\omnioil-edge-agent"; const PIPE_NAME: &str = r"\\.\pipe\omnioil-edge-agent";
@@ -39,7 +39,8 @@ impl ApplicationHandler for EdgeTrayApp {
_event_loop: &ActiveEventLoop, _event_loop: &ActiveEventLoop,
_window_id: winit::window::WindowId, _window_id: winit::window::WindowId,
_event: winit::event::WindowEvent, _event: winit::event::WindowEvent,
) {} ) {
}
fn about_to_wait(&mut self, event_loop: &ActiveEventLoop) { fn about_to_wait(&mut self, event_loop: &ActiveEventLoop) {
// Recibir actualizaciones de estado del backend // Recibir actualizaciones de estado del backend
@@ -53,7 +54,9 @@ impl ApplicationHandler for EdgeTrayApp {
self.tray_icon.take(); self.tray_icon.take();
event_loop.exit(); event_loop.exit();
} else if event.id == self.test_id { } else if event.id == self.test_id {
let _ = self.command_tx.try_send(AgentCommand::Notify("¡Hola desde el Tray Icon!".to_string())); let _ = self.command_tx.try_send(AgentCommand::Notify(
"¡Hola desde el Tray Icon!".to_string(),
));
} }
} }
} }
@@ -73,12 +76,14 @@ async fn main() {
let quit_id = quit_i.id().clone(); let quit_id = quit_i.id().clone();
let test_id = test_i.id().clone(); let test_id = test_i.id().clone();
tray_menu.append_items(&[ tray_menu
&status_label, .append_items(&[
&test_i, &status_label,
&PredefinedMenuItem::separator(), &test_i,
&quit_i, &PredefinedMenuItem::separator(),
]).unwrap(); &quit_i,
])
.unwrap();
let icon_path = std::env::current_exe() let icon_path = std::env::current_exe()
.unwrap() .unwrap()
@@ -141,7 +146,9 @@ async fn main() {
} }
} }
Err(_) => { Err(_) => {
let _ = status_tx.send("Estado: Agente no detectado".to_string()).await; let _ = status_tx
.send("Estado: Agente no detectado".to_string())
.await;
} }
} }
} }

View File

@@ -28,9 +28,13 @@ include!(concat!(env!("OUT_DIR"), "/baked_config.rs"));
fn agent_data_dir() -> PathBuf { fn agent_data_dir() -> PathBuf {
#[cfg(windows)] #[cfg(windows)]
{ PathBuf::from(r"C:\ProgramData\OmniOil\agent") } {
PathBuf::from(r"C:\ProgramData\OmniOil\agent")
}
#[cfg(not(windows))] #[cfg(not(windows))]
{ PathBuf::from("/var/lib/omnioil/agent") } {
PathBuf::from("/var/lib/omnioil/agent")
}
} }
fn private_key_path() -> PathBuf { fn private_key_path() -> PathBuf {
@@ -89,15 +93,55 @@ impl AgentConfig {
/// la config viene de disco (credenciales permanentes válidas). Si es `false`, /// la config viene de disco (credenciales permanentes válidas). Si es `false`,
/// la config viene del stub embebido y no se debe limpiar en caso de `NotAuthorized`. /// la config viene del stub embebido y no se debe limpiar en caso de `NotAuthorized`.
pub async fn load_default(external_path: &Path) -> Result<(Self, bool)> { pub async fn load_default(external_path: &Path) -> Result<(Self, bool)> {
// ── Validar coincidencia de Project ID (Clean Install en caso de Mismatch) ──
if let Some(stub) = shared_lib::overlay::find_provisioning_stub() {
let provisioned = provisioned_config_path();
if provisioned.exists() {
let old_config_opt = crate::credential_store::load_secret(&provisioned)
.ok()
.and_then(|bytes| {
let content = String::from_utf8(bytes).ok()?;
toml::from_str::<AgentConfig>(&content).ok()
});
if let Some(old_config) = old_config_opt {
if old_config.agent.project_id != stub.project_id {
tracing::warn!(
"⚠️ [Project Mismatch] El ID del proyecto del nuevo instalador ({}) no coincide con la configuración existente ({}). Realizando limpieza para aprovisionamiento limpio...",
stub.project_id,
old_config.agent.project_id
);
// Ruta base a limpiar (C:\ProgramData\OmniOil en Windows, /var/lib/omnioil en Linux)
let base_data_dir = if cfg!(windows) {
std::path::PathBuf::from(r"C:\ProgramData\OmniOil")
} else {
std::path::PathBuf::from("/var/lib/omnioil")
};
if base_data_dir.exists() {
if let Err(e) = std::fs::remove_dir_all(&base_data_dir) {
tracing::error!("❌ Error al limpiar datos antiguos: {}", e);
} else {
tracing::info!(
"✅ Limpieza de datos antiguos completada con éxito."
);
}
}
}
}
}
}
// ── Modo 1: Config ya aprovisionada (arranques posteriores al primero) ── // ── Modo 1: Config ya aprovisionada (arranques posteriores al primero) ──
let provisioned = provisioned_config_path(); let provisioned = provisioned_config_path();
if provisioned.exists() { if provisioned.exists() {
match crate::credential_store::load_secret(&provisioned) match crate::credential_store::load_secret(&provisioned).and_then(|bytes| {
.and_then(|bytes| { let content =
let content = String::from_utf8(bytes).map_err(|e| anyhow::anyhow!("UTF-8 error: {}", e))?; 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)) toml::from_str::<AgentConfig>(&content)
}) .map_err(|e| anyhow::anyhow!("TOML error: {}", e))
{ }) {
Ok(config) => { Ok(config) => {
tracing::info!("✅ [Provisioned] Config cargada desde disco."); tracing::info!("✅ [Provisioned] Config cargada desde disco.");
return Ok((config, true)); return Ok((config, true));
@@ -130,10 +174,14 @@ impl AgentConfig {
Ok(config) => { Ok(config) => {
// Eliminar el stub después de aprovisionamiento exitoso // Eliminar el stub después de aprovisionamiento exitoso
let _ = std::fs::remove_file(&file_stub_path); let _ = std::fs::remove_file(&file_stub_path);
tracing::info!("✅ [Provisioning] Agente aprovisionado. Stub eliminado."); tracing::info!(
"✅ [Provisioning] Agente aprovisionado. Stub eliminado."
);
return Ok((config, true)); return Ok((config, true));
} }
Err(e) => tracing::warn!("⚠️ Provisioning desde archivo falló: {:#}", e), Err(e) => {
tracing::warn!("⚠️ Provisioning desde archivo falló: {:#}", e)
}
} }
} }
Err(e) => tracing::warn!("⚠️ provisioning_stub.json inválido: {}", e), Err(e) => tracing::warn!("⚠️ provisioning_stub.json inválido: {}", e),
@@ -144,18 +192,48 @@ impl AgentConfig {
} }
// ── Modo 2: Provisioning asimétrico (primer arranque con stub en binario) ── // ── Modo 2: Provisioning asimétrico (primer arranque con stub en binario) ──
// Si hay un stub embebido (instalador generado por el panel), el agente DEBE
// provisionar antes de poder funcionar. Si la red falla (proxy 502, timeout,
// DNS, etc.), reintentamos indefinidamente con backoff en lugar de caernos —
// caerse solo activa el reinicio del servicio Windows, que vuelve a fallar.
if let Some(stub) = shared_lib::overlay::find_provisioning_stub() { if let Some(stub) = shared_lib::overlay::find_provisioning_stub() {
tracing::info!( tracing::info!(
"🔑 [Provisioning] Stub detectado. Conectando al broker {}:{}", "🔑 [Provisioning] Stub detectado. Broker: {}:{} (use_ws={})",
stub.mqtt_host, stub.mqtt_host,
stub.mqtt_port stub.mqtt_port,
stub.use_ws
); );
match Self::provision_from_server(&stub).await {
Ok(config) => { let mut attempt: u32 = 0;
tracing::info!("✅ [Provisioning] Agente aprovisionado exitosamente."); loop {
return Ok((config, true)); attempt += 1;
tracing::info!("🔄 [Provisioning] Intento #{}", attempt);
match Self::provision_from_server(&stub).await {
Ok(config) => {
tracing::info!("✅ [Provisioning] Agente aprovisionado exitosamente.");
return Ok((config, true));
}
Err(e) => {
// Error visible inmediatamente en logs (error > warn para prioridad)
tracing::error!("❌ [Provisioning] Intento #{} falló: {:#}", attempt, e);
// Backoff exponencial: 10s, 20s, 40s… máx 5 min
let wait_secs =
std::cmp::min(10 * 2u64.pow(attempt.saturating_sub(1)), 300);
tracing::warn!(
"⏳ [Provisioning] Reintentando en {} segundos \
(verifica conectividad a {}:{})…",
wait_secs,
stub.mqtt_host,
stub.mqtt_port
);
// Dar tiempo al escritor de logs para vaciar el buffer antes de dormir
tokio::time::sleep(std::time::Duration::from_millis(200)).await;
tokio::time::sleep(std::time::Duration::from_secs(wait_secs)).await;
}
} }
Err(e) => tracing::warn!("⚠️ Provisioning falló: {:#}", e),
} }
} }
@@ -204,16 +282,18 @@ impl AgentConfig {
let client_id = format!("provision-{}", uuid::Uuid::new_v4()); let client_id = format!("provision-{}", uuid::Uuid::new_v4());
let prov_username = format!("provision:{}", stub.project_id); let prov_username = format!("provision:{}", stub.project_id);
let use_tls = stub.mqtt_port == 443;
// rumqttc con WS transport requiere la URL completa como host (ws://host:port/mqtt). // rumqttc con WS transport requiere la URL completa como host (ws://host:port/mqtt).
// TCP plain usa solo el hostname directamente. // TCP plain usa solo el hostname directamente.
let mqtt_host_arg = if stub.use_ws { let mqtt_host_arg = if stub.use_ws {
format!("ws://{}:{}/mqtt", stub.mqtt_host, stub.mqtt_port) let scheme = if use_tls { "wss" } else { "ws" };
format!("{}://{}:{}/mqtt", scheme, stub.mqtt_host, stub.mqtt_port)
} else { } else {
stub.mqtt_host.clone() stub.mqtt_host.clone()
}; };
let mut mqtt_opts = let mut mqtt_opts = rumqttc::MqttOptions::new(client_id, mqtt_host_arg, stub.mqtt_port);
rumqttc::MqttOptions::new(client_id, mqtt_host_arg, stub.mqtt_port);
mqtt_opts.set_credentials(&prov_username, &stub.provisioning_token); mqtt_opts.set_credentials(&prov_username, &stub.provisioning_token);
mqtt_opts.set_keep_alive(std::time::Duration::from_secs(15)); mqtt_opts.set_keep_alive(std::time::Duration::from_secs(15));
mqtt_opts.set_clean_session(true); mqtt_opts.set_clean_session(true);
@@ -221,13 +301,26 @@ impl AgentConfig {
mqtt_opts.set_max_packet_size(256 * 1024, 256 * 1024); mqtt_opts.set_max_packet_size(256 * 1024, 256 * 1024);
// Aplicar el transporte correcto según el stub. // Aplicar el transporte correcto según el stub.
// Puerto 9883 es WebSocket en Mosquitto — TCP plain fallaría el handshake.
if stub.use_ws { if stub.use_ws {
if use_tls {
tracing::info!(
"🌐 [Provisioning] Conectando vía WebSocket Seguro WSS (puerto {})",
stub.mqtt_port
);
mqtt_opts.set_transport(rumqttc::Transport::wss_with_default_config());
} else {
tracing::info!(
"🌐 [Provisioning] Conectando vía WebSocket WS (puerto {})",
stub.mqtt_port
);
mqtt_opts.set_transport(rumqttc::Transport::ws());
}
} else if use_tls {
tracing::info!( tracing::info!(
"🌐 [Provisioning] Conectando vía WebSocket (puerto {})", "🔒 [Provisioning] Conectando vía TCP Seguro TLS (puerto {})",
stub.mqtt_port stub.mqtt_port
); );
mqtt_opts.set_transport(rumqttc::Transport::ws()); mqtt_opts.set_transport(rumqttc::Transport::tls_with_default_config());
} else { } else {
tracing::info!( tracing::info!(
"🔌 [Provisioning] Conectando vía TCP plain (puerto {})", "🔌 [Provisioning] Conectando vía TCP plain (puerto {})",
@@ -240,47 +333,45 @@ impl AgentConfig {
let response_topic = format!("provision/response/{}", stub.project_id); let response_topic = format!("provision/response/{}", stub.project_id);
let request_topic = format!("provision/request/{}", stub.project_id); let request_topic = format!("provision/request/{}", stub.project_id);
let request_payload = let request_payload = serde_json::to_vec(&MqttProvisioningRequest {
serde_json::to_vec(&MqttProvisioningRequest { public_key_pem: &public_key_pem }) public_key_pem: &public_key_pem,
.context("Error serializando request de provisioning")?; })
.context("Error serializando request de provisioning")?;
// 3. Event loop: conectar → suscribir → publicar pubkey → esperar respuesta // 3. Event loop: conectar → suscribir → publicar pubkey → esperar respuesta
let encrypted_config = tokio::time::timeout( let encrypted_config = tokio::time::timeout(std::time::Duration::from_secs(45), async {
std::time::Duration::from_secs(45), loop {
async { match eventloop.poll().await {
loop { Ok(rumqttc::Event::Incoming(rumqttc::Packet::ConnAck(_))) => {
match eventloop.poll().await { mqtt_client
Ok(rumqttc::Event::Incoming(rumqttc::Packet::ConnAck(_))) => { .subscribe(&response_topic, rumqttc::QoS::AtLeastOnce)
mqtt_client .await
.subscribe(&response_topic, rumqttc::QoS::AtLeastOnce) .context("Error suscribiéndose al topic de respuesta")?;
.await mqtt_client
.context("Error suscribiéndose al topic de respuesta")?; .publish(
mqtt_client &request_topic,
.publish( rumqttc::QoS::AtLeastOnce,
&request_topic, false,
rumqttc::QoS::AtLeastOnce, request_payload.clone(),
false, )
request_payload.clone(), .await
) .context("Error publicando clave pública al broker")?;
.await tracing::info!(
.context("Error publicando clave pública al broker")?; "🔑 [Provisioning] Clave pública enviada al broker, esperando config…"
tracing::info!( );
"🔑 [Provisioning] Clave pública enviada al broker, esperando config…" }
); Ok(rumqttc::Event::Incoming(rumqttc::Packet::Publish(msg))) => {
} if msg.topic == response_topic {
Ok(rumqttc::Event::Incoming(rumqttc::Packet::Publish(msg))) => { return Ok::<Vec<u8>, anyhow::Error>(msg.payload.to_vec());
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);
} }
} }
Ok(_) => {}
Err(e) => {
anyhow::bail!("Error en conexión MQTT de provisioning: {}", e);
}
} }
}, }
) })
.await .await
.context("Timeout esperando respuesta de provisioning (45 s)")? .context("Timeout esperando respuesta de provisioning (45 s)")?
.context("Error durante el provisioning MQTT")?; .context("Error durante el provisioning MQTT")?;
@@ -289,16 +380,18 @@ impl AgentConfig {
.context("Respuesta de provisioning no es JSON válido")?; .context("Respuesta de provisioning no es JSON válido")?;
// 4. Descifrar con ECIES // 4. Descifrar con ECIES
let config_bytes = let config_bytes = shared_lib::provisioning::ecies_decrypt(
shared_lib::provisioning::ecies_decrypt(&private_key_pem, &prov_response.encrypted_config) &private_key_pem,
.map_err(|e| anyhow::anyhow!("Error ECIES al descifrar config: {}", e))?; &prov_response.encrypted_config,
)
.map_err(|e| anyhow::anyhow!("Error ECIES al descifrar config: {}", e))?;
let config_toml = let config_toml =
String::from_utf8(config_bytes).context("Config descifrada no es UTF-8 válida")?; String::from_utf8(config_bytes).context("Config descifrada no es UTF-8 válida")?;
// 5. Parsear para validar antes de guardar // 5. Parsear para validar antes de guardar
let config: AgentConfig = toml::from_str(&config_toml) let config: AgentConfig =
.context("Config descifrada no es TOML válido")?; toml::from_str(&config_toml).context("Config descifrada no es TOML válido")?;
// 6. Guardar en disco para arranques posteriores // 6. Guardar en disco para arranques posteriores
crate::credential_store::save_secret(&provisioned_config_path(), config_toml.as_bytes()) crate::credential_store::save_secret(&provisioned_config_path(), config_toml.as_bytes())
@@ -317,9 +410,9 @@ fn load_or_generate_keypair() -> Result<(String, String)> {
if priv_path.exists() && pub_path.exists() { if priv_path.exists() && pub_path.exists() {
let private_key_bytes = crate::credential_store::load_secret(&priv_path) let private_key_bytes = crate::credential_store::load_secret(&priv_path)
.context("Error leyendo clave privada del agente")?; .context("Error leyendo clave privada del agente")?;
let private_key_pem = String::from_utf8(private_key_bytes) let private_key_pem =
.context("Clave privada no es UTF-8 válida")?; 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 let public_key_pem = std::fs::read_to_string(&pub_path) // public key stays plain
.context("Error leyendo clave pública del agente")?; .context("Error leyendo clave pública del agente")?;
tracing::info!("🔐 Keypair P256 cargado desde disco."); tracing::info!("🔐 Keypair P256 cargado desde disco.");
return Ok((private_key_pem, public_key_pem)); return Ok((private_key_pem, public_key_pem));
@@ -334,6 +427,9 @@ fn load_or_generate_keypair() -> Result<(String, String)> {
std::fs::write(&pub_path, &public_key_pem) std::fs::write(&pub_path, &public_key_pem)
.context("Error guardando clave pública del agente")?; .context("Error guardando clave pública del agente")?;
tracing::info!("✅ Keypair P256 generado y guardado en {:?}", priv_path.parent()); tracing::info!(
"✅ Keypair P256 generado y guardado en {:?}",
priv_path.parent()
);
Ok((private_key_pem, public_key_pem)) Ok((private_key_pem, public_key_pem))
} }

View File

@@ -3,12 +3,12 @@
//! Este módulo maneja todas las estructuras de datos y métodos de carga //! Este módulo maneja todas las estructuras de datos y métodos de carga
//! necesarios para el funcionamiento del sistema en diversos entornos. //! necesarios para el funcionamiento del sistema en diversos entornos.
pub mod models;
pub mod loader;
pub mod example; pub mod example;
pub mod loader;
pub mod models;
pub use models::*;
pub use example::generate_example_config; pub use example::generate_example_config;
pub use models::*;
impl models::AgentConfig { impl models::AgentConfig {
/// Obtiene el hostname del dispositivo (o el configurado). /// Obtiene el hostname del dispositivo (o el configurado).
@@ -23,9 +23,10 @@ impl models::AgentConfig {
/// Obtiene el nombre del contenedor principal. /// Obtiene el nombre del contenedor principal.
pub fn container_name(&self) -> String { pub fn container_name(&self) -> String {
self.docker.container_name.clone().unwrap_or_else(|| { self.docker
format!("omnioil-scada-{}", &self.agent.project_id[..8]) .container_name
}) .clone()
.unwrap_or_else(|| format!("omnioil-scada-{}", &self.agent.project_id[..8]))
} }
/// Obtiene el intervalo de health check. /// Obtiene el intervalo de health check.

View File

@@ -8,8 +8,8 @@
//! La seguridad viene de los permisos del sistema de archivos. El servicio //! La seguridad viene de los permisos del sistema de archivos. El servicio
//! corre como usuario dedicado `omnioil-agent` que es el único con acceso. //! corre como usuario dedicado `omnioil-agent` que es el único con acceso.
use std::path::Path;
use anyhow::{Context, Result}; use anyhow::{Context, Result};
use std::path::Path;
/// Guarda datos secretos en disco con protección según la plataforma. /// Guarda datos secretos en disco con protección según la plataforma.
/// Windows: cifra con DPAPI (machine-scope) antes de escribir. /// Windows: cifra con DPAPI (machine-scope) antes de escribir.
@@ -18,11 +18,13 @@ pub fn save_secret(path: &Path, data: &[u8]) -> Result<()> {
#[cfg(windows)] #[cfg(windows)]
{ {
let encrypted = dpapi::protect(data).context("Error cifrando secreto con DPAPI")?; let encrypted = dpapi::protect(data).context("Error cifrando secreto con DPAPI")?;
std::fs::write(path, &encrypted).with_context(|| format!("Error guardando secreto en {:?}", path))?; std::fs::write(path, &encrypted)
.with_context(|| format!("Error guardando secreto en {:?}", path))?;
} }
#[cfg(not(windows))] #[cfg(not(windows))]
{ {
std::fs::write(path, data).with_context(|| format!("Error guardando secreto en {:?}", path))?; std::fs::write(path, data)
.with_context(|| format!("Error guardando secreto en {:?}", path))?;
set_secret_permissions(path)?; set_secret_permissions(path)?;
} }
Ok(()) Ok(())
@@ -30,7 +32,8 @@ pub fn save_secret(path: &Path, data: &[u8]) -> Result<()> {
/// Carga datos secretos desde disco y los descifra si es necesario. /// Carga datos secretos desde disco y los descifra si es necesario.
pub fn load_secret(path: &Path) -> Result<Vec<u8>> { pub fn load_secret(path: &Path) -> Result<Vec<u8>> {
let raw = std::fs::read(path).with_context(|| format!("Error leyendo secreto desde {:?}", path))?; let raw =
std::fs::read(path).with_context(|| format!("Error leyendo secreto desde {:?}", path))?;
#[cfg(windows)] #[cfg(windows)]
{ {
dpapi::unprotect(&raw).context("Error descifrando secreto con DPAPI") dpapi::unprotect(&raw).context("Error descifrando secreto con DPAPI")
@@ -73,7 +76,7 @@ fn set_secret_permissions(path: &Path) -> Result<()> {
mod dpapi { mod dpapi {
use anyhow::{Result, anyhow}; use anyhow::{Result, anyhow};
use windows_sys::Win32::Security::Cryptography::{ use windows_sys::Win32::Security::Cryptography::{
CryptProtectData, CryptUnprotectData, CRYPTPROTECT_LOCAL_MACHINE, CRYPTPROTECT_LOCAL_MACHINE, CryptProtectData, CryptUnprotectData,
}; };
// LocalFree is a legacy Win32 function — declare it directly to avoid feature-flag issues. // LocalFree is a legacy Win32 function — declare it directly to avoid feature-flag issues.
@@ -88,11 +91,14 @@ mod dpapi {
} }
pub fn protect(data: &[u8]) -> Result<Vec<u8>> { pub fn protect(data: &[u8]) -> Result<Vec<u8>> {
let mut input = DataBlob { let input = DataBlob {
cb_data: data.len() as u32, cb_data: data.len() as u32,
pb_data: data.as_ptr() as *mut u8, pb_data: data.as_ptr() as *mut u8,
}; };
let mut output = DataBlob { cb_data: 0, pb_data: std::ptr::null_mut() }; let mut output = DataBlob {
cb_data: 0,
pb_data: std::ptr::null_mut(),
};
let ok = unsafe { let ok = unsafe {
CryptProtectData( CryptProtectData(
@@ -112,19 +118,21 @@ mod dpapi {
})); }));
} }
let result = unsafe { let result =
std::slice::from_raw_parts(output.pb_data, output.cb_data as usize).to_vec() 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) }; unsafe { LocalFree(output.pb_data as *mut std::ffi::c_void) };
Ok(result) Ok(result)
} }
pub fn unprotect(data: &[u8]) -> Result<Vec<u8>> { pub fn unprotect(data: &[u8]) -> Result<Vec<u8>> {
let mut input = DataBlob { let input = DataBlob {
cb_data: data.len() as u32, cb_data: data.len() as u32,
pb_data: data.as_ptr() as *mut u8, pb_data: data.as_ptr() as *mut u8,
}; };
let mut output = DataBlob { cb_data: 0, pb_data: std::ptr::null_mut() }; let mut output = DataBlob {
cb_data: 0,
pb_data: std::ptr::null_mut(),
};
let ok = unsafe { let ok = unsafe {
CryptUnprotectData( CryptUnprotectData(
@@ -144,9 +152,8 @@ mod dpapi {
})); }));
} }
let result = unsafe { let result =
std::slice::from_raw_parts(output.pb_data, output.cb_data as usize).to_vec() 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) }; unsafe { LocalFree(output.pb_data as *mut std::ffi::c_void) };
Ok(result) Ok(result)
} }

View File

@@ -1,14 +1,17 @@
use crate::drivers::{modbus_tcp::ModbusTcpDriver, s7_comm::S7CommDriver, opc_ua::OpcUaDriver, ProtocolDriver};
use crate::persistence_manager::StoreAndForward;
use crate::deadband::DeadbandFilter; use crate::deadband::DeadbandFilter;
use crate::drivers::{
ProtocolDriver, modbus_tcp::ModbusTcpDriver, mqtt_sparkplug_b::MqttSparkplugBDriver,
opc_ua::OpcUaDriver, s7_comm::S7CommDriver, sql_db::SqlDbDriver,
};
use crate::persistence_manager::StoreAndForward;
use rumqttc::{AsyncClient, QoS}; use rumqttc::{AsyncClient, QoS};
use serde_json::{json, Value}; use serde_json::{Value, json};
use shared_lib::edge_models::config::AssetConfig;
use std::collections::HashMap; use std::collections::HashMap;
use std::sync::Arc; use std::sync::Arc;
use tokio::sync::Mutex; use tokio::sync::Mutex;
use tokio::time::{interval, Duration}; use tokio::time::{Duration, interval};
use uuid::Uuid; use uuid::Uuid;
use shared_lib::edge_models::config::AssetConfig;
pub struct DataCollectorManager { pub struct DataCollectorManager {
mqtt_client: AsyncClient, mqtt_client: AsyncClient,
@@ -19,7 +22,12 @@ pub struct DataCollectorManager {
} }
impl DataCollectorManager { impl DataCollectorManager {
pub fn new(mqtt_client: AsyncClient, project_id: Uuid, storage: Arc<StoreAndForward>, deadband_threshold_pct: f64) -> Self { pub fn new(
mqtt_client: AsyncClient,
project_id: Uuid,
storage: Arc<StoreAndForward>,
deadband_threshold_pct: f64,
) -> Self {
Self { Self {
mqtt_client, mqtt_client,
project_id, project_id,
@@ -29,7 +37,11 @@ impl DataCollectorManager {
} }
} }
pub async fn update_config(&self, assets: Vec<AssetConfig>, log_reporter: Arc<crate::log_reporter::LogReporter>) { pub async fn update_config(
&self,
assets: Vec<AssetConfig>,
log_reporter: Arc<crate::log_reporter::LogReporter>,
) {
let mut handles = self.handles.lock().await; let mut handles = self.handles.lock().await;
let msg = format!("🔄 Processing configuration for {} assets...", assets.len()); let msg = format!("🔄 Processing configuration for {} assets...", assets.len());
@@ -64,32 +76,109 @@ impl DataCollectorManager {
let var_count = variables.len(); let var_count = variables.len();
let deadband_pct = self.deadband_threshold_pct; let deadband_pct = self.deadband_threshold_pct;
tracing::info!("📡 Starting collector for device '{}' ({} variables)", device.name, var_count); tracing::info!(
"📡 Starting collector for device '{}' ({} variables)",
device.name,
var_count
);
let handle = tokio::spawn(async move { let handle = tokio::spawn(async move {
let mut driver: Box<dyn ProtocolDriver> = match device.protocol { let mut driver: Box<dyn ProtocolDriver> = match device.protocol {
shared_lib::edge_models::devices::DeviceProtocol::ModbusTcp => { shared_lib::edge_models::devices::DeviceProtocol::ModbusTcp => {
let unit_id = device.protocol_config.get("unit_id").and_then(|v| v.as_u64()).unwrap_or(1) as u8; let unit_id = device
tracing::debug!("🔧 Initializing Modbus driver for {} at {}:{}", device.name, device.host, device.port); .protocol_config
Box::new(ModbusTcpDriver::new(&device.host, device.port as u16, unit_id)) .get("unit_id")
}, .and_then(|v| v.as_u64())
.unwrap_or(1) as u8;
tracing::debug!(
"🔧 Initializing Modbus driver for {} at {}:{}",
device.name,
device.host,
device.port
);
Box::new(ModbusTcpDriver::new(
&device.host,
device.port as u16,
unit_id,
))
}
shared_lib::edge_models::devices::DeviceProtocol::S7 => { shared_lib::edge_models::devices::DeviceProtocol::S7 => {
let rack = device.protocol_config.get("rack").and_then(|v| v.as_u64()).unwrap_or(0) as u16; let rack = device
let slot = device.protocol_config.get("slot").and_then(|v| v.as_u64()).unwrap_or(1) as u16; .protocol_config
tracing::debug!("🔧 Initializing S7 driver for {} at {}:{}", device.name, device.host, device.port); .get("rack")
.and_then(|v| v.as_u64())
.unwrap_or(0) as u16;
let slot = device
.protocol_config
.get("slot")
.and_then(|v| v.as_u64())
.unwrap_or(1) as u16;
tracing::debug!(
"🔧 Initializing S7 driver for {} at {}:{}",
device.name,
device.host,
device.port
);
Box::new(S7CommDriver::new(&device.host, rack, slot)) Box::new(S7CommDriver::new(&device.host, rack, slot))
}, }
shared_lib::edge_models::devices::DeviceProtocol::OpcUa => { shared_lib::edge_models::devices::DeviceProtocol::OpcUa => {
let endpoint = device.protocol_config let endpoint = device
.protocol_config
.get("endpoint") .get("endpoint")
.and_then(|v| v.as_str()) .and_then(|v| v.as_str())
.unwrap_or(&device.host) .unwrap_or(&device.host)
.to_string(); .to_string();
tracing::debug!("🔧 Initializing OPC-UA driver for {} at {}", device.name, endpoint); tracing::debug!(
"🔧 Initializing OPC-UA driver for {} at {}",
device.name,
endpoint
);
Box::new(OpcUaDriver::new(&endpoint)) Box::new(OpcUaDriver::new(&endpoint))
}, }
shared_lib::edge_models::devices::DeviceProtocol::MqttSparkplugB => {
let group_id = device
.protocol_config
.get("group_id")
.and_then(|v| v.as_str())
.unwrap_or("");
let edge_node_id = device
.protocol_config
.get("edge_node_id")
.and_then(|v| v.as_str())
.unwrap_or("");
tracing::debug!(
"🔧 Initializing MQTT Sparkplug B driver for {} at {}:{} (Group: {}, Node: {})",
device.name,
device.host,
device.port,
group_id,
edge_node_id
);
Box::new(MqttSparkplugBDriver::new(
&device.host,
device.port as u16,
group_id,
edge_node_id,
))
}
shared_lib::edge_models::devices::DeviceProtocol::SqlDb => {
let conn_str = device
.protocol_config
.get("connection_string")
.and_then(|v| v.as_str())
.unwrap_or("");
tracing::debug!(
"🔧 Initializing SQL Database driver for {} with connection string: {}",
device.name,
conn_str
);
Box::new(SqlDbDriver::new(conn_str))
}
_ => { _ => {
let msg = format!("Unsupported protocol for device {}: {:?}", device.name, device.protocol); let msg = format!(
"Unsupported protocol for device {}: {:?}",
device.name, device.protocol
);
tracing::error!("{}", msg); tracing::error!("{}", msg);
let _ = reporter.error("COLLECTOR", &msg).await; let _ = reporter.error("COLLECTOR", &msg).await;
return; return;
@@ -105,11 +194,15 @@ impl DataCollectorManager {
if !driver.is_alive() { if !driver.is_alive() {
connected_notified = false; connected_notified = false;
let msg = format!("🔌 Attempting to connect to {} at {}:{}...", device.name, device.host, device.port); let msg = format!(
"🔌 Attempting to connect to {} at {}:{}...",
device.name, device.host, device.port
);
let _ = reporter.info("COLLECTOR", &msg).await; let _ = reporter.info("COLLECTOR", &msg).await;
if let Err(e) = driver.connect().await { if let Err(e) = driver.connect().await {
let msg = format!("❌ Connection failed for {}: {}", device.name, e); let msg =
format!("❌ Connection failed for {}: {}", device.name, e);
tracing::warn!("{}", msg); tracing::warn!("{}", msg);
let _ = reporter.error("COLLECTOR", &msg).await; let _ = reporter.error("COLLECTOR", &msg).await;
continue; continue;
@@ -117,7 +210,10 @@ impl DataCollectorManager {
} }
if !connected_notified { if !connected_notified {
let msg = format!("✅ Successfully connected to device '{}' at {}:{}", device.name, device.host, device.port); let msg = format!(
"✅ Successfully connected to device '{}' at {}:{}",
device.name, device.host, device.port
);
tracing::info!("{}", msg); tracing::info!("{}", msg);
let _ = reporter.info("COLLECTOR", &msg).await; let _ = reporter.info("COLLECTOR", &msg).await;
connected_notified = true; connected_notified = true;
@@ -129,12 +225,17 @@ impl DataCollectorManager {
match driver.read_variables(&variables).await { match driver.read_variables(&variables).await {
Ok(values) => { Ok(values) => {
if !values.is_empty() { if !values.is_empty() {
let msg = format!("📥 Read successful for {}: {} values obtained", device.name, values.len()); let msg = format!(
"📥 Read successful for {}: {} values obtained",
device.name,
values.len()
);
let _ = reporter.info("COLLECTOR", &msg).await; let _ = reporter.info("COLLECTOR", &msg).await;
// Apply deadband filter: suppress numeric values that haven't // Apply deadband filter: suppress numeric values that haven't
// changed beyond the configured threshold percentage. // changed beyond the configured threshold percentage.
let filtered: HashMap<String, Value> = values.into_iter() let filtered: HashMap<String, Value> = values
.into_iter()
.filter(|(key, val)| { .filter(|(key, val)| {
match val.as_f64() { match val.as_f64() {
Some(f) => deadband.should_publish(key, f), Some(f) => deadband.should_publish(key, f),
@@ -144,7 +245,10 @@ impl DataCollectorManager {
.collect(); .collect();
if filtered.is_empty() { if filtered.is_empty() {
tracing::debug!("🔇 All values suppressed by deadband filter for {}", device.name); tracing::debug!(
"🔇 All values suppressed by deadband filter for {}",
device.name
);
} else { } else {
let payload = json!({ let payload = json!({
"project_id": project_id, "project_id": project_id,
@@ -156,23 +260,46 @@ impl DataCollectorManager {
let payload_str = payload.to_string(); let payload_str = payload.to_string();
let payload_bytes = payload_str.as_bytes().to_vec(); let payload_bytes = payload_str.as_bytes().to_vec();
let msg = format!("📤 Publishing telemetry for {} to MQTT topic: {}", device.name, topic); let msg = format!(
"📤 Publishing telemetry for {} to MQTT topic: {}",
device.name, topic
);
let _ = reporter.info("MQTT", &msg).await; let _ = reporter.info("MQTT", &msg).await;
if let Err(e) = mqtt.publish(&topic, QoS::AtMostOnce, false, payload_bytes).await { if let Err(e) = mqtt
let msg = format!("⚠️ MQTT publish failed for {}: {}. Buffering locally.", device.name, e); .publish(&topic, QoS::AtMostOnce, false, payload_bytes)
.await
{
let msg = format!(
"⚠️ MQTT publish failed for {}: {}. Buffering locally.",
device.name, e
);
let _ = reporter.error("MQTT", &msg).await; let _ = reporter.error("MQTT", &msg).await;
let _ = storage_clone.save(&topic, &payload_str); let _ = storage_clone.save(&topic, &payload_str);
} else { } else {
let _ = reporter.info("MQTT", &format!("✅ Data sent for {}", device.name)).await; let _ = reporter
.info(
"MQTT",
&format!("✅ Data sent for {}", device.name),
)
.await;
} }
} }
} else { } else {
let _ = reporter.warn("COLLECTOR", &format!("⚠️ No values returned for device {}", device.name)).await; let _ = reporter
.warn(
"COLLECTOR",
&format!(
"⚠️ No values returned for device {}",
device.name
),
)
.await;
} }
} }
Err(e) => { Err(e) => {
let msg = format!("❌ Error reading from device {}: {}", device.name, e); let msg =
format!("❌ Error reading from device {}: {}", device.name, e);
tracing::error!("{}", msg); tracing::error!("{}", msg);
let _ = reporter.error("COLLECTOR", &msg).await; let _ = reporter.error("COLLECTOR", &msg).await;
let _ = driver.disconnect().await; let _ = driver.disconnect().await;
@@ -185,7 +312,10 @@ impl DataCollectorManager {
} }
} }
let msg = format!("Data collector operational with {} active drivers", handles.len()); let msg = format!(
"Data collector operational with {} active drivers",
handles.len()
);
tracing::info!("✅ {}", msg); tracing::info!("✅ {}", msg);
let _ = log_reporter.info("DEPLOY", &msg).await; let _ = log_reporter.info("DEPLOY", &msg).await;
} }

View File

@@ -1,15 +1,15 @@
use crate::persistence_manager::StoreAndForward; use crate::persistence_manager::StoreAndForward;
use axum::{ use axum::{
Router,
extract::{Json, State}, extract::{Json, State},
routing::post, routing::post,
Router,
}; };
use rumqttc::{AsyncClient, QoS}; use rumqttc::{AsyncClient, QoS};
use serde_json::{json, Value}; use serde_json::{Value, json};
use std::net::SocketAddr; use std::net::SocketAddr;
use std::sync::atomic::{AtomicI64, Ordering};
use std::sync::Arc; use std::sync::Arc;
use tokio::time::{interval, Duration}; use std::sync::atomic::{AtomicI64, Ordering};
use tokio::time::{Duration, interval};
/// Estado compartido para el gateway de datos. /// Estado compartido para el gateway de datos.
struct GatewayState { struct GatewayState {
@@ -93,9 +93,13 @@ async fn ingest_data(
let final_payload = if data_bytes.len() > 512 { let final_payload = if data_bytes.len() > 512 {
match shared_lib::compression::compress(data_bytes) { match shared_lib::compression::compress(data_bytes) {
Ok(compressed) => { Ok(compressed) => {
tracing::debug!("Compression: {} -> {} bytes", data_bytes.len(), compressed.len()); tracing::debug!(
"Compression: {} -> {} bytes",
data_bytes.len(),
compressed.len()
);
compressed compressed
}, }
Err(e) => { Err(e) => {
tracing::error!("Compression error: {}", e); tracing::error!("Compression error: {}", e);
data_bytes.to_vec() data_bytes.to_vec()
@@ -106,7 +110,9 @@ async fn ingest_data(
}; };
// Intentar publicar a MQTT con un timeout corto para no colgar a Node-RED // Intentar publicar a MQTT con un timeout corto para no colgar a Node-RED
let publish_future = state.mqtt_client.publish(topic, QoS::AtLeastOnce, false, final_payload); let publish_future = state
.mqtt_client
.publish(topic, QoS::AtLeastOnce, false, final_payload);
match tokio::time::timeout(Duration::from_secs(2), publish_future).await { match tokio::time::timeout(Duration::from_secs(2), publish_future).await {
Ok(Ok(_)) => { Ok(Ok(_)) => {

View File

@@ -3,10 +3,12 @@ use async_trait::async_trait;
use serde_json::Value; use serde_json::Value;
use std::collections::HashMap; use std::collections::HashMap;
#[allow(dead_code)]
pub struct EtherNetIpDriver { pub struct EtherNetIpDriver {
_host: String, _host: String,
} }
#[allow(dead_code)]
impl EtherNetIpDriver { impl EtherNetIpDriver {
pub fn new(host: &str) -> Self { pub fn new(host: &str) -> Self {
Self { Self {
@@ -25,7 +27,10 @@ impl ProtocolDriver for EtherNetIpDriver {
Ok(()) Ok(())
} }
async fn read_variables(&mut self, _variables: &HashMap<String, String>) -> anyhow::Result<HashMap<String, Value>> { async fn read_variables(
&mut self,
_variables: &HashMap<String, String>,
) -> anyhow::Result<HashMap<String, Value>> {
Ok(HashMap::new()) Ok(HashMap::new())
} }

View File

@@ -12,13 +12,18 @@ pub trait ProtocolDriver: Send + Sync {
/// Lee un conjunto de variables. /// Lee un conjunto de variables.
/// El map de entrada contiene "alias" -> "dirección/parámetros". /// El map de entrada contiene "alias" -> "dirección/parámetros".
async fn read_variables(&mut self, variables: &HashMap<String, String>) -> anyhow::Result<HashMap<String, Value>>; async fn read_variables(
&mut self,
variables: &HashMap<String, String>,
) -> anyhow::Result<HashMap<String, Value>>;
/// Verifica si el dispositivo está en línea. /// Verifica si el dispositivo está en línea.
fn is_alive(&self) -> bool; fn is_alive(&self) -> bool;
} }
pub mod modbus_tcp;
pub mod s7_comm;
pub mod ethernet_ip; pub mod ethernet_ip;
pub mod modbus_tcp;
pub mod mqtt_sparkplug_b;
pub mod opc_ua; pub mod opc_ua;
pub mod s7_comm;
pub mod sql_db;

View File

@@ -1,12 +1,12 @@
use super::ProtocolDriver; use super::ProtocolDriver;
use async_trait::async_trait; use async_trait::async_trait;
use serde_json::{json, Value}; use serde_json::{Value, json};
use std::collections::HashMap; use std::collections::HashMap;
use std::net::SocketAddr; use std::net::SocketAddr;
use std::sync::Arc; use std::sync::Arc;
use tokio::sync::Mutex;
use tokio_modbus::client::Context; use tokio_modbus::client::Context;
use tokio_modbus::prelude::*; use tokio_modbus::prelude::*;
use tokio::sync::Mutex;
pub struct ModbusTcpDriver { pub struct ModbusTcpDriver {
host: String, host: String,
@@ -32,7 +32,11 @@ impl ProtocolDriver for ModbusTcpDriver {
let socket_addr: SocketAddr = format!("{}:{}", self.host, self.port).parse()?; let socket_addr: SocketAddr = format!("{}:{}", self.host, self.port).parse()?;
let ctx = tcp::connect_slave(socket_addr, Slave(self.unit_id)).await?; let ctx = tcp::connect_slave(socket_addr, Slave(self.unit_id)).await?;
self.ctx = Some(Arc::new(Mutex::new(ctx))); self.ctx = Some(Arc::new(Mutex::new(ctx)));
tracing::info!("✅ Connected to Modbus TCP device at {}:{}", self.host, self.port); tracing::info!(
"✅ Connected to Modbus TCP device at {}:{}",
self.host,
self.port
);
Ok(()) Ok(())
} }
@@ -41,8 +45,14 @@ impl ProtocolDriver for ModbusTcpDriver {
Ok(()) Ok(())
} }
async fn read_variables(&mut self, variables: &HashMap<String, String>) -> anyhow::Result<HashMap<String, Value>> { async fn read_variables(
let ctx_mutex = self.ctx.as_ref().ok_or_else(|| anyhow::anyhow!("Not connected"))?; &mut self,
variables: &HashMap<String, String>,
) -> anyhow::Result<HashMap<String, Value>> {
let ctx_mutex = self
.ctx
.as_ref()
.ok_or_else(|| anyhow::anyhow!("Not connected"))?;
let mut ctx = ctx_mutex.lock().await; let mut ctx = ctx_mutex.lock().await;
let mut results = HashMap::new(); let mut results = HashMap::new();
@@ -56,28 +66,24 @@ impl ProtocolDriver for ModbusTcpDriver {
let addr: u16 = addr_str.parse().unwrap_or(0); let addr: u16 = addr_str.parse().unwrap_or(0);
match area { match area {
"HR" => { "HR" => match ctx.read_holding_registers(addr, 1).await {
match ctx.read_holding_registers(addr, 1).await { Ok(Ok(registers)) => {
Ok(Ok(registers)) => { if let Some(v) = registers.first() {
if let Some(v) = registers.first() { results.insert(alias.clone(), json!(v));
results.insert(alias.clone(), json!(v));
}
} }
Ok(Err(e)) => tracing::warn!("Modbus HR Exception: {} for addr {}", e, addr),
Err(e) => tracing::warn!("Modbus HR Transport error: {} for addr {}", e, addr),
} }
} Ok(Err(e)) => tracing::warn!("Modbus HR Exception: {} for addr {}", e, addr),
"IR" => { Err(e) => tracing::warn!("Modbus HR Transport error: {} for addr {}", e, addr),
match ctx.read_input_registers(addr, 1).await { },
Ok(Ok(registers)) => { "IR" => match ctx.read_input_registers(addr, 1).await {
if let Some(v) = registers.first() { Ok(Ok(registers)) => {
results.insert(alias.clone(), json!(v)); if let Some(v) = registers.first() {
} results.insert(alias.clone(), json!(v));
} }
Ok(Err(e)) => tracing::warn!("Modbus IR Exception: {} for addr {}", e, addr),
Err(e) => tracing::warn!("Modbus IR Transport error: {} for addr {}", e, addr),
} }
} Ok(Err(e)) => tracing::warn!("Modbus IR Exception: {} for addr {}", e, addr),
Err(e) => tracing::warn!("Modbus IR Transport error: {} for addr {}", e, addr),
},
_ => tracing::warn!("Unsupported Modbus area: {}", area), _ => tracing::warn!("Unsupported Modbus area: {}", area),
} }
} }

View File

@@ -0,0 +1,54 @@
use super::ProtocolDriver;
use async_trait::async_trait;
use serde_json::Value;
use std::collections::HashMap;
pub struct MqttSparkplugBDriver {
_host: String,
_port: u16,
_group_id: String,
_edge_node_id: String,
connected: bool,
}
impl MqttSparkplugBDriver {
pub fn new(host: &str, port: u16, group_id: &str, edge_node_id: &str) -> Self {
Self {
_host: host.to_string(),
_port: port,
_group_id: group_id.to_string(),
_edge_node_id: edge_node_id.to_string(),
connected: false,
}
}
}
#[async_trait]
impl ProtocolDriver for MqttSparkplugBDriver {
async fn connect(&mut self) -> anyhow::Result<()> {
tracing::info!(
"🔌 Connecting MQTT Sparkplug B driver to {}:{}",
self._host,
self._port
);
self.connected = true;
Ok(())
}
async fn disconnect(&mut self) -> anyhow::Result<()> {
self.connected = false;
Ok(())
}
async fn read_variables(
&mut self,
_variables: &HashMap<String, String>,
) -> anyhow::Result<HashMap<String, Value>> {
// Return dummy data or read local buffer in a full Sparkplug B client implementation.
Ok(HashMap::new())
}
fn is_alive(&self) -> bool {
self.connected
}
}

View File

@@ -1,10 +1,10 @@
use super::ProtocolDriver; use super::ProtocolDriver;
use async_trait::async_trait; use async_trait::async_trait;
use serde_json::{json, Value};
use std::collections::HashMap;
use s7::client::Client; use s7::client::Client;
use s7::tcp; use s7::tcp;
use s7::transport::Connection; use s7::transport::Connection;
use serde_json::{Value, json};
use std::collections::HashMap;
use std::net::IpAddr; use std::net::IpAddr;
@@ -43,8 +43,14 @@ impl ProtocolDriver for S7CommDriver {
Ok(()) Ok(())
} }
async fn read_variables(&mut self, variables: &HashMap<String, String>) -> anyhow::Result<HashMap<String, Value>> { async fn read_variables(
let client = self.client.as_mut().ok_or_else(|| anyhow::anyhow!("Not connected"))?; &mut self,
variables: &HashMap<String, String>,
) -> anyhow::Result<HashMap<String, Value>> {
let client = self
.client
.as_mut()
.ok_or_else(|| anyhow::anyhow!("Not connected"))?;
let mut results = HashMap::new(); let mut results = HashMap::new();
for (alias, address) in variables { for (alias, address) in variables {

View File

@@ -0,0 +1,48 @@
use super::ProtocolDriver;
use async_trait::async_trait;
use serde_json::Value;
use std::collections::HashMap;
pub struct SqlDbDriver {
connection_string: String,
connected: bool,
}
impl SqlDbDriver {
pub fn new(connection_string: &str) -> Self {
Self {
connection_string: connection_string.to_string(),
connected: false,
}
}
}
#[async_trait]
impl ProtocolDriver for SqlDbDriver {
async fn connect(&mut self) -> anyhow::Result<()> {
tracing::info!(
"🔌 Connecting SQL Database driver with connection string: {}",
self.connection_string
);
self.connected = true;
Ok(())
}
async fn disconnect(&mut self) -> anyhow::Result<()> {
self.connected = false;
Ok(())
}
async fn read_variables(
&mut self,
_variables: &HashMap<String, String>,
) -> anyhow::Result<HashMap<String, Value>> {
// SQL Database driver polling logic.
// Returns empty HashMap for stub implementation.
Ok(HashMap::new())
}
fn is_alive(&self) -> bool {
self.connected
}
}

View File

@@ -1,12 +1,11 @@
//! Health Checker — Monitoreo periódico del Agente Nativo (sin Docker). //! Health Checker — Monitoreo periódico del Agente Nativo (sin Docker).
use shared_lib::mqtt_messages::{HealthReport, AgentStatus}; use chrono::Utc;
use rumqttc::{AsyncClient, QoS}; use rumqttc::{AsyncClient, QoS};
use shared_lib::mqtt_messages::{AgentStatus, HealthReport};
use std::sync::Arc;
use tokio::time::interval; use tokio::time::interval;
use uuid::Uuid; use uuid::Uuid;
use chrono::Utc;
use std::sync::Arc;
use tokio::sync::RwLock;
use super::config::AgentConfig; use super::config::AgentConfig;
@@ -30,7 +29,10 @@ pub async fn start_health_checker(
let mut timer = interval(check_interval); let mut timer = interval(check_interval);
let mut json_buf = Vec::with_capacity(512); let mut json_buf = Vec::with_capacity(512);
tracing::info!("🏥 Native Health checker started (interval: {:?})", check_interval); tracing::info!(
"🏥 Native Health checker started (interval: {:?})",
check_interval
);
loop { loop {
timer.tick().await; timer.tick().await;
@@ -44,8 +46,8 @@ pub async fn start_health_checker(
agent_version: Some(AGENT_VERSION.to_string()), agent_version: Some(AGENT_VERSION.to_string()),
agent_status: AgentStatus::Online, agent_status: AgentStatus::Online,
container_status: None, // Deprecated container_status: None, // Deprecated
container_id: None, // Deprecated container_id: None, // Deprecated
image_tag: None, // Deprecated image_tag: None, // Deprecated
uptime_seconds: Some(uptime), uptime_seconds: Some(uptime),
cpu_usage_percent: None, cpu_usage_percent: None,
memory_usage_mb: None, memory_usage_mb: None,
@@ -61,11 +63,12 @@ pub async fn start_health_checker(
let topic = health_topic.clone(); let topic = health_topic.clone();
let payload = json_buf.clone(); let payload = json_buf.clone();
tokio::spawn(async move { tokio::spawn(async move {
let _ = mqtt let _ = mqtt.publish(&topic, QoS::AtMostOnce, false, payload).await;
.publish(&topic, QoS::AtMostOnce, false, payload)
.await;
}); });
tracing::debug!("📡 Native health report sent via MQTT for project: {}", project_id); tracing::debug!(
"📡 Native health report sent via MQTT for project: {}",
project_id
);
} }
} }
} }

Some files were not shown because too many files have changed in this diff Show More