Agregar notificaciones y limite de acciones criticas

main
EMOTIONS-HUNTER 4 weeks ago
parent 8797cb4990
commit 7d8959f164

@ -46,6 +46,8 @@ ventana de procesamiento en vez de bloquear un segundo por sensor.
- `API_AUTH_TOKEN` protege endpoints criticos cuando esta configurado.
- El dashboard envia el token como `X-API-Token` desde almacenamiento local del
navegador.
- La API compara tokens en tiempo constante y emite headers defensivos basicos.
- Los POST criticos usan rate limit configurable en memoria.
- Sin `API_AUTH_TOKEN`, el modo desarrollo permanece sin autenticacion.
## Bitacora de alarmas
@ -70,6 +72,13 @@ ventana de procesamiento en vez de bloquear un segundo por sensor.
- La poda conserva los nombres actuales de archivos para no romper graficas ni
exportaciones.
## Notificaciones
- `config/notifications.json` permite activar webhook y Telegram.
- `GET /api/config/notifications` devuelve configuracion redactada.
- `POST /api/config/notifications` valida y persiste canales con token.
- El recolector despacha notificaciones desde eventos de `logs/alarms.csv`.
## Modos
- `EZO_MODE=demo`: adquisición y comandos simulados.

@ -102,6 +102,10 @@ En produccion configure `API_AUTH_TOKEN` en `/etc/default/photobioreactor` para
proteger comandos EZO, calibracion, cambios de configuracion y borrado de
historicos. El dashboard incluye un campo "Token API" que guarda el valor solo
en el navegador local y lo envia como header `X-API-Token`.
La API aplica comparacion de token en tiempo constante y headers defensivos
basicos (`nosniff`, `DENY`, `same-origin`, `no-store`).
Los POST criticos aplican rate limit en memoria mediante
`API_RATE_LIMIT_WINDOW_MS` y `API_RATE_LIMIT_MAX`.
## Bitacora de alarmas
@ -121,6 +125,13 @@ Los limites se guardan en `config/alarms.json`, se consultan con
`GET /api/config/alarms` y pueden editarse desde el dashboard. Guardar cambios
requiere `API_AUTH_TOKEN` cuando esta configurado.
## Notificaciones
`config/notifications.json` define canales de webhook y Telegram. El recolector
envia notificaciones cuando una alarma persistente alcanza `minSeverity`. La API
expone configuracion redactada en `GET /api/config/notifications`; guardar
requiere token y nunca devuelve secretos sin redaccion.
## Archivos de datos
- Lecturas actuales: `data/EZORTD.json`, `EZOPH.json`, `EZODO.json`,

@ -3,6 +3,7 @@ const fsPromises = require('node:fs/promises');
const path = require('node:path');
const { execFile } = require('node:child_process');
const { promisify } = require('node:util');
const { dispatchAlarmNotifications } = require('./notification-service');
const execFileAsync = promisify(execFile);
@ -495,6 +496,7 @@ async function collectReadings() {
})
);
const alarmEvents = await persistAlarmEvents(results, timestamp);
const notificationResults = await dispatchAlarmNotifications(alarmEvents);
const prunedFiles = await enforceHistoryRetention(
runtimeConfig.historyRetentionDays,
new Date(timestamp)
@ -505,6 +507,7 @@ async function collectReadings() {
timestamp,
results,
alarmEvents,
notificationResults,
prunedFiles,
enabledSensors: runtimeConfig.enabledSensors
};

@ -0,0 +1,245 @@
const fs = require('node:fs/promises');
const path = require('node:path');
const ROOT_DIRECTORY = path.join(__dirname, '..');
const NOTIFICATION_CONFIG_FILE = process.env.NOTIFICATION_CONFIG_FILE
? path.resolve(process.env.NOTIFICATION_CONFIG_FILE)
: path.join(ROOT_DIRECTORY, 'config', 'notifications.json');
const SEVERITY_RANK = {
RECOVERY: 0,
NORMAL: 0,
WARNING: 1,
OFFLINE: 2,
CRITICAL: 3
};
const DEFAULT_NOTIFICATION_CONFIG = {
enabled: false,
minSeverity: 'CRITICAL',
channels: {
webhook: {
enabled: false,
url: '',
headers: {}
},
telegram: {
enabled: false,
botToken: '',
chatId: ''
}
}
};
function mergeNotificationConfig(config = {}) {
return {
enabled: config.enabled === true,
minSeverity: SEVERITY_RANK[config.minSeverity] === undefined
? DEFAULT_NOTIFICATION_CONFIG.minSeverity
: config.minSeverity,
channels: {
webhook: {
...DEFAULT_NOTIFICATION_CONFIG.channels.webhook,
...(config.channels && config.channels.webhook)
},
telegram: {
...DEFAULT_NOTIFICATION_CONFIG.channels.telegram,
...(config.channels && config.channels.telegram)
}
}
};
}
async function readNotificationConfig() {
try {
const rawConfig = await fs.readFile(NOTIFICATION_CONFIG_FILE, 'utf8');
return mergeNotificationConfig(JSON.parse(rawConfig));
} catch {
return mergeNotificationConfig();
}
}
function validateNotificationConfig(rawConfig) {
const config = mergeNotificationConfig(rawConfig);
if (SEVERITY_RANK[config.minSeverity] === undefined) {
throw Object.assign(new Error('Severidad minima no valida.'), { status: 400 });
}
if (config.channels.webhook.enabled && !isValidHttpUrl(config.channels.webhook.url)) {
throw Object.assign(new Error('URL de webhook no valida.'), { status: 400 });
}
if (config.channels.telegram.enabled) {
if (!config.channels.telegram.botToken || !config.channels.telegram.chatId) {
throw Object.assign(
new Error('Telegram requiere botToken y chatId.'),
{ status: 400 }
);
}
}
if (
config.channels.webhook.headers &&
(typeof config.channels.webhook.headers !== 'object' ||
Array.isArray(config.channels.webhook.headers))
) {
throw Object.assign(new Error('Headers de webhook invalidos.'), { status: 400 });
}
return config;
}
async function writeNotificationConfig(rawConfig) {
const config = validateNotificationConfig(rawConfig);
await fs.mkdir(path.dirname(NOTIFICATION_CONFIG_FILE), { recursive: true });
await fs.writeFile(
NOTIFICATION_CONFIG_FILE,
`${JSON.stringify(config, null, 4)}\n`,
'utf8'
);
return config;
}
function redactNotificationConfig(config) {
return {
...config,
channels: {
webhook: {
...config.channels.webhook,
headers: Object.keys(config.channels.webhook.headers || {}).length > 0
? '[configured]'
: {}
},
telegram: {
...config.channels.telegram,
botToken: config.channels.telegram.botToken ? '[configured]' : ''
}
}
};
}
function isValidHttpUrl(value) {
try {
const url = new URL(value);
return ['http:', 'https:'].includes(url.protocol);
} catch {
return false;
}
}
function shouldNotify(event, minSeverity) {
const eventRank = SEVERITY_RANK[event.state] ?? 0;
const minimumRank = SEVERITY_RANK[minSeverity] ?? SEVERITY_RANK.CRITICAL;
return eventRank >= minimumRank;
}
function buildNotificationMessage(event) {
const value = event.value === '' || event.value === null || event.value === undefined
? 'N/A'
: event.value;
return [
`Photobioreactor alarm: ${event.state}`,
`Sensor: ${event.sensorId || event.sensor}`,
`Value: ${value}`,
`Message: ${event.message}`,
`Time: ${event.timestamp}`
].join('\n');
}
async function dispatchAlarmNotifications(events) {
const config = await readNotificationConfig();
if (!config.enabled || !Array.isArray(events) || events.length === 0) {
return [];
}
const eligibleEvents = events.filter((event) =>
shouldNotify(event, config.minSeverity)
);
const results = [];
for (const event of eligibleEvents) {
if (config.channels.webhook.enabled) {
results.push(await sendSafely('webhook', event, () =>
sendWebhookNotification(event, config.channels.webhook)
));
}
if (config.channels.telegram.enabled) {
results.push(await sendSafely('telegram', event, () =>
sendTelegramNotification(event, config.channels.telegram)
));
}
}
return results;
}
async function sendSafely(channel, event, send) {
try {
await send();
return {
channel,
sensorId: event.sensorId || event.sensor,
state: event.state,
success: true
};
} catch (error) {
return {
channel,
sensorId: event.sensorId || event.sensor,
state: event.state,
success: false,
error: error.message
};
}
}
async function sendWebhookNotification(event, channelConfig) {
await postJson(channelConfig.url, {
event,
text: buildNotificationMessage(event)
}, channelConfig.headers || {});
}
async function sendTelegramNotification(event, channelConfig) {
const url = `https://api.telegram.org/bot${channelConfig.botToken}/sendMessage`;
await postJson(url, {
chat_id: channelConfig.chatId,
text: buildNotificationMessage(event)
});
}
async function postJson(url, payload, headers = {}) {
if (typeof fetch !== 'function') {
throw new Error('fetch no esta disponible en este runtime.');
}
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...headers
},
body: JSON.stringify(payload)
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
}
module.exports = {
DEFAULT_NOTIFICATION_CONFIG,
NOTIFICATION_CONFIG_FILE,
dispatchAlarmNotifications,
readNotificationConfig,
redactNotificationConfig,
shouldNotify,
validateNotificationConfig,
writeNotificationConfig
};

@ -1,5 +1,6 @@
const express = require('express');
const cors = require('cors');
const crypto = require('node:crypto');
const fs = require('node:fs/promises');
const path = require('node:path');
const {
@ -11,11 +12,18 @@ const {
readRuntimeConfig,
writeRuntimeConfig
} = require('./acquisition-service');
const {
readNotificationConfig,
redactNotificationConfig,
writeNotificationConfig
} = require('./notification-service');
const app = express();
const PORT = Number(process.env.PORT || 3000);
const HOST = process.env.HOST || '127.0.0.1';
const API_AUTH_TOKEN = process.env.API_AUTH_TOKEN || '';
const API_RATE_LIMIT_WINDOW_MS = Number(process.env.API_RATE_LIMIT_WINDOW_MS || 60000);
const API_RATE_LIMIT_MAX = Number(process.env.API_RATE_LIMIT_MAX || 60);
const LOGS_DIRECTORY = process.env.LOGS_DIRECTORY
? path.resolve(process.env.LOGS_DIRECTORY)
: path.join(__dirname, '..', 'logs');
@ -42,6 +50,13 @@ const HISTORY_SENSOR_IDS = {
ec: 'ec'
};
const ALARM_SENSOR_IDS = ['temperature', 'ph', 'do', 'ec'];
const SECURITY_HEADERS = {
'X-Content-Type-Options': 'nosniff',
'X-Frame-Options': 'DENY',
'Referrer-Policy': 'same-origin',
'Cache-Control': 'no-store'
};
const rateLimitBuckets = new Map();
function parseAlarmCsv(csvText) {
return csvText
@ -133,6 +148,12 @@ async function writeAlarmConfigFile(rawConfig) {
app.use(cors());
app.use(express.json());
app.use((req, res, next) => {
Object.entries(SECURITY_HEADERS).forEach(([name, value]) => {
res.setHeader(name, value);
});
next();
});
app.use('/frontend', express.static(path.join(__dirname, '..', 'frontend')));
app.use('/data', express.static(path.join(__dirname, '..', 'data'), {
etag: false,
@ -178,7 +199,7 @@ function requireApiToken(req, res, next) {
const providedToken = req.get('X-API-Token') || '';
if (providedToken === API_AUTH_TOKEN) {
if (tokensMatch(providedToken, API_AUTH_TOKEN)) {
return next();
}
@ -188,6 +209,51 @@ function requireApiToken(req, res, next) {
});
}
function tokensMatch(providedToken, expectedToken) {
const provided = Buffer.from(String(providedToken));
const expected = Buffer.from(String(expectedToken));
if (provided.length !== expected.length) {
return false;
}
return crypto.timingSafeEqual(provided, expected);
}
function getRateLimitKey(req) {
const token = req.get('X-API-Token') || '';
const identity = token || req.ip || req.socket.remoteAddress || 'unknown';
return `${identity}:${req.method}:${req.path}`;
}
function rateLimitCriticalApi(req, res, next) {
if (API_RATE_LIMIT_MAX <= 0 || API_RATE_LIMIT_WINDOW_MS <= 0) {
return next();
}
const now = Date.now();
const key = getRateLimitKey(req);
const bucket = rateLimitBuckets.get(key) || [];
const recentRequests = bucket.filter((timestamp) =>
now - timestamp < API_RATE_LIMIT_WINDOW_MS
);
if (recentRequests.length >= API_RATE_LIMIT_MAX) {
const retryAfterMs = API_RATE_LIMIT_WINDOW_MS - (now - recentRequests[0]);
res.setHeader('Retry-After', String(Math.max(1, Math.ceil(retryAfterMs / 1000))));
rateLimitBuckets.set(key, recentRequests);
return res.status(429).json({
success: false,
error: 'Demasiadas solicitudes. Intente nuevamente en unos segundos.'
});
}
recentRequests.push(now);
rateLimitBuckets.set(key, recentRequests);
return next();
}
app.get('/api/system/ezo', (req, res) => {
const commandMode = detectMode();
const acquisitionMode = detectAcquisitionMode();
@ -277,7 +343,7 @@ app.get('/api/alarms', async (req, res) => {
}
});
app.post('/api/sensors/:type/command', requireApiToken, async (req, res) => {
app.post('/api/sensors/:type/command', requireApiToken, rateLimitCriticalApi, async (req, res) => {
try {
const result = await executeEzoCommand(
req.params.type.toLowerCase(),
@ -318,7 +384,7 @@ app.get('/api/config/alarms', async (req, res) => {
}
});
app.post('/api/config/alarms', requireApiToken, async (req, res) => {
app.post('/api/config/alarms', requireApiToken, rateLimitCriticalApi, async (req, res) => {
try {
const thresholds = await writeAlarmConfigFile(req.body.thresholds || req.body);
console.log('[CONFIG] Umbrales de alarma actualizados.');
@ -335,7 +401,32 @@ app.post('/api/config/alarms', requireApiToken, async (req, res) => {
}
});
app.post('/api/config/logging', requireApiToken, async (req, res) => {
app.get('/api/config/notifications', async (req, res) => {
const config = await readNotificationConfig();
res.json({
success: true,
config: redactNotificationConfig(config)
});
});
app.post('/api/config/notifications', requireApiToken, rateLimitCriticalApi, async (req, res) => {
try {
const config = await writeNotificationConfig(req.body.config || req.body);
console.log('[CONFIG] Notificaciones actualizadas.');
res.json({
success: true,
config: redactNotificationConfig(config),
message: 'Configuracion de notificaciones actualizada.'
});
} catch (error) {
res.status(error.status || 500).json({
success: false,
error: error.message
});
}
});
app.post('/api/config/logging', requireApiToken, rateLimitCriticalApi, async (req, res) => {
try {
const config = await writeRuntimeConfig({ rate: req.body.rate });
console.log(
@ -365,7 +456,7 @@ app.get('/api/config/runtime', async (req, res) => {
});
});
app.post('/api/config/runtime', requireApiToken, async (req, res) => {
app.post('/api/config/runtime', requireApiToken, rateLimitCriticalApi, async (req, res) => {
try {
const config = await writeRuntimeConfig({
rate: req.body.rate,
@ -390,7 +481,7 @@ app.post('/api/config/runtime', requireApiToken, async (req, res) => {
}
});
app.post('/api/history/clear', requireApiToken, async (req, res) => {
app.post('/api/history/clear', requireApiToken, rateLimitCriticalApi, async (req, res) => {
try {
await fs.mkdir(LOGS_DIRECTORY, { recursive: true });
await Promise.all(
@ -426,5 +517,6 @@ if (require.main === module) {
module.exports = {
app,
HISTORY_FILES,
parseHistoryCsv
parseHistoryCsv,
resetRateLimits: () => rateLimitBuckets.clear()
};

@ -0,0 +1,16 @@
{
"enabled": false,
"minSeverity": "CRITICAL",
"channels": {
"webhook": {
"enabled": false,
"url": "",
"headers": {}
},
"telegram": {
"enabled": false,
"botToken": "",
"chatId": ""
}
}
}

@ -6,3 +6,8 @@ EZO_MODE=hardware
EZO_ENABLED_SENSORS=
# Configure un valor secreto para proteger comandos, calibracion y cambios criticos.
API_AUTH_TOKEN=
# Limite de acciones criticas por ventana; use 0 para desactivar.
API_RATE_LIMIT_WINDOW_MS=60000
API_RATE_LIMIT_MAX=60
# Archivo de canales webhook/Telegram; no exponga secretos en frontend.
NOTIFICATION_CONFIG_FILE=/etc/photobioreactor/notifications.json

@ -8,6 +8,7 @@ fi
PROJECT_SOURCE="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
PROJECT_TARGET="/opt/photobioreactor"
CONFIG_TARGET="/etc/photobioreactor"
SERVICE_USER="photobioreactor"
apt-get update
@ -18,6 +19,7 @@ if ! id "${SERVICE_USER}" >/dev/null 2>&1; then
fi
install -d -o "${SERVICE_USER}" -g "${SERVICE_USER}" "${PROJECT_TARGET}"
install -d -o "${SERVICE_USER}" -g "${SERVICE_USER}" "${CONFIG_TARGET}"
cp -a "${PROJECT_SOURCE}/." "${PROJECT_TARGET}/"
rm -rf "${PROJECT_TARGET}/node_modules"
@ -27,6 +29,8 @@ make -C sensors/EZOCommand
chown -R "${SERVICE_USER}:${SERVICE_USER}" "${PROJECT_TARGET}"
install -m 0644 deployment/photobioreactor.env /etc/default/photobioreactor
install -o "${SERVICE_USER}" -g "${SERVICE_USER}" -m 0600 \
config/notifications.json "${CONFIG_TARGET}/notifications.json"
install -m 0644 deployment/photobioreactor-api.service /etc/systemd/system/
install -m 0644 deployment/photobioreactor-acquisition.service /etc/systemd/system/
install -m 0644 deployment/nginx-photobioreactor.conf \

@ -9,6 +9,7 @@ const temporaryLogs = path.join(temporaryRoot, 'logs');
const temporaryData = path.join(temporaryRoot, 'data');
const temporaryRuntime = path.join(temporaryRoot, 'runtime.json');
const temporaryAlarms = path.join(temporaryRoot, 'alarms.json');
const temporaryNotifications = path.join(temporaryRoot, 'notifications.json');
fs.mkdirSync(temporaryLogs, { recursive: true });
fs.mkdirSync(temporaryData, { recursive: true });
fs.writeFileSync(temporaryAlarms, JSON.stringify({
@ -17,19 +18,34 @@ fs.writeFileSync(temporaryAlarms, JSON.stringify({
do: { min: 4, max: 12 },
ec: { min: 500, max: 2500 }
}));
fs.writeFileSync(temporaryNotifications, JSON.stringify({
enabled: false,
minSeverity: 'CRITICAL',
channels: {
webhook: { enabled: false, url: '', headers: {} },
telegram: { enabled: false, botToken: '', chatId: '' }
}
}));
process.env.LOGS_DIRECTORY = temporaryLogs;
process.env.DATA_DIRECTORY = temporaryData;
process.env.RUNTIME_CONFIG_FILE = temporaryRuntime;
process.env.ALARM_CONFIG_FILE = temporaryAlarms;
process.env.NOTIFICATION_CONFIG_FILE = temporaryNotifications;
process.env.EZO_MODE = 'demo';
process.env.API_AUTH_TOKEN = 'test-token';
process.env.API_RATE_LIMIT_WINDOW_MS = '60000';
process.env.API_RATE_LIMIT_MAX = '1000';
const { app, HISTORY_FILES } = require('../api/server');
const { app, HISTORY_FILES, resetRateLimits } = require('../api/server');
const {
collectReadings,
pruneCsvByRetention,
publishReading
} = require('../api/acquisition-service');
const {
dispatchAlarmNotifications,
writeNotificationConfig
} = require('../api/notification-service');
let server;
let baseUrl;
@ -109,6 +125,8 @@ test('serves the dashboard in local development mode', async () => {
const html = await response.text();
assert.equal(response.status, 200);
assert.equal(response.headers.get('x-content-type-options'), 'nosniff');
assert.equal(response.headers.get('x-frame-options'), 'DENY');
assert.match(html, /Panel del Fotobiorreactor/);
});
@ -125,6 +143,20 @@ test('protects critical API endpoints when token is configured', async () => {
assert.equal(response.status, 401);
assert.equal(data.success, false);
const wrongTokenResponse = await fetch(`${baseUrl}/api/config/runtime`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-API-Token': 'wrong-token'
},
body: JSON.stringify({
rate: 1,
enabledSensors: ['temperature']
})
});
assert.equal(wrongTokenResponse.status, 401);
});
test('validates sensor commands', async () => {
@ -335,6 +367,95 @@ test('rejects invalid alarm thresholds', async () => {
assert.equal(response.status, 400);
});
test('persists notification configuration with redacted secrets', async () => {
const response = await fetch(`${baseUrl}/api/config/notifications`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-API-Token': 'test-token'
},
body: JSON.stringify({
config: {
enabled: true,
minSeverity: 'WARNING',
channels: {
webhook: {
enabled: true,
url: 'https://example.test/alarm',
headers: { Authorization: 'Bearer secret' }
},
telegram: {
enabled: true,
botToken: '123456:secret',
chatId: '42'
}
}
}
})
});
const data = await response.json();
assert.equal(response.status, 200);
assert.equal(data.config.enabled, true);
assert.equal(data.config.channels.webhook.headers, '[configured]');
assert.equal(data.config.channels.telegram.botToken, '[configured]');
const readResponse = await fetch(`${baseUrl}/api/config/notifications`);
const readData = await readResponse.json();
assert.equal(readData.config.channels.telegram.botToken, '[configured]');
});
test('dispatches webhook notifications for eligible alarm events', async () => {
await writeNotificationConfig({
enabled: true,
minSeverity: 'WARNING',
channels: {
webhook: {
enabled: true,
url: 'https://example.test/alarm',
headers: { Authorization: 'Bearer secret' }
},
telegram: {
enabled: false,
botToken: '',
chatId: ''
}
}
});
const originalFetch = global.fetch;
const calls = [];
global.fetch = async (url, options) => {
calls.push({ url, options });
return { ok: true, status: 200 };
};
try {
const result = await dispatchAlarmNotifications([{
timestamp: '2026-06-27T12:00:00.000Z',
sensorId: 'temperature',
state: 'CRITICAL',
value: 31.5,
message: 'Valor fuera de rango'
}]);
assert.equal(result.length, 1);
assert.equal(result[0].success, true);
assert.equal(calls.length, 1);
assert.equal(calls[0].url, 'https://example.test/alarm');
assert.match(calls[0].options.body, /temperature/);
} finally {
global.fetch = originalFetch;
await writeNotificationConfig({
enabled: false,
minSeverity: 'CRITICAL',
channels: {
webhook: { enabled: false, url: '', headers: {} },
telegram: { enabled: false, botToken: '', chatId: '' }
}
});
}
});
test('demo acquisition writes live JSON and CSV files', async () => {
await fetch(`${baseUrl}/api/config/runtime`, {
method: 'POST',

@ -33,6 +33,21 @@ test('dashboard starts live polling and exposes required controls', () => {
);
});
test('api protects critical endpoints with token and rate limiting hooks', () => {
const server = read('api/server.js');
const notifications = read('api/notification-service.js');
assert.match(server, /API_RATE_LIMIT_WINDOW_MS/);
assert.match(server, /API_RATE_LIMIT_MAX/);
assert.match(server, /rateLimitCriticalApi/);
assert.match(server, /Retry-After/);
assert.match(server, /timingSafeEqual/);
assert.match(server, /\/api\/config\/notifications/);
assert.match(notifications, /redactNotificationConfig/);
assert.match(notifications, /sendTelegramNotification/);
assert.match(notifications, /sendWebhookNotification/);
});
test('dashboard HTML has balanced structural containers', () => {
const html = read('frontend/index.html');
const openDivs = (html.match(/<div\b/g) || []).length;

Loading…
Cancel
Save