// ======================================== // 📋 backend/index.js - SERVIDOR PRINCIPAL PARKINGCAN v1.0.9 + Socket.IO + Shares + HLS // Maintainer: ParKingCan® S.L.U // Dev&owner: Tomás Salés Silvestre (Founder & CTO) // Versión 1.0.9 - Arquitectura limpia con systemd + bypass /internal // ======================================== // ⚠️ Cargar .env ANTES de leer process.env require('dotenv').config(); // 🔐 Verificación temprana de configuración de autenticación if (!process.env.JWT_SECRET) { console.error('❌ CRÍTICO: Falta JWT_SECRET en el entorno (.env). No puedo validar tokens.'); console.error(' Verifica tu archivo .env o las variables de entorno en PM2'); process.exit(1); } process.env.JWT_ISSUER = process.env.JWT_ISSUER || 'parkingcan-backend'; console.log('🔐 JWT configurado - Issuer:', process.env.JWT_ISSUER); const express = require('express'); const http = require('http'); const { Server } = require('socket.io'); const cors = require('cors'); const mongoose = require('mongoose'); const { startAutoCleanup } = require('./utils/tokenCleanup'); const cookieParser = require('cookie-parser'); const jwt = require('jsonwebtoken'); const routerRoutes = require('./routes/routers'); const adminRoutes = require('./routes/admin'); const adminAuthRoutes = require('./routes/adminAuth'); // ==== DB LOCK: fuerza 1 sola URI y evita re-conexiones conflictivas ==== const originalMongooseConnect = mongoose.connect.bind(mongoose); // URI preferida: respeta tu MONGODB_URI (p.ej. 127.0.0.1:27018 vía túnel) let FIRST_URI = process.env.MONGODB_URI || 'mongodb://127.0.0.1:27017/visi-on'; let CONNECTED_ONCE = false; // Ignoramos el parámetro 'uri' de quien llame la PRIMERA VEZ. // Así garantizamos que la 1ª conexión será SIEMPRE a FIRST_URI. mongoose.connect = async (_uri, opts) => { if (CONNECTED_ONCE || mongoose.connection.readyState !== 0) { if (_uri && _uri !== FIRST_URI) { console.warn(`⚠️ [DB] Ignorando mongoose.connect(${_uri}) (ya conectado a ${FIRST_URI})`); } // Mantener contrato Promise por si alguien hace await/then() return Promise.resolve(mongoose); } CONNECTED_ONCE = true; return originalMongooseConnect(FIRST_URI, opts); }; // ======================================================================= // Middleware de autenticación HLS (SIEMPRE ACTIVO, sin flags) const { hlsAuth, hlsAccessLog, hlsNoCacheHeaders } = require('./middleware/hlsAuth'); const path = require('path'); const fs = require('fs'); const rateLimit = require('express-rate-limit'); const helmet = require('helmet'); // ✅ IMPORTAR AUTHUTILS PARA FUNCIONALIDADES MEJORADAS const authUtils = require('./utils/authUtils'); // ✅ IMPORTAR MIDDLEWARE DE AUTENTICACIÓN const requireAuth = require('./middleware/requireAuth'); // ✅ IMPORTAR WEBSOCKET INTEGRATION const { initializeSocketIO, router: wsApiRouter, notifyUsers, notifyByRole, notifyCamera, broadcastToAll, getConnectionStats, getUserConnectionCount, disconnectUser } = require('./websocketIntegration'); const app = express(); app.disable('x-powered-by'); app.use("/api/routers", require("./routes/routers")); app.use('/api/deploy-key', require('./routes/deployKey')); app.use('/api/ai', require('./routes/aiBehavior')); app.use((req, res, next) => { if (req.url.includes('logo') || req.url.includes('carousel')) { console.log('🧭 BRANDING REQUEST:', req.method, req.url); } next(); }); // ======================================== // 🛂 VALIDACIÓN GLOBAL DE RUTAS API // ======================================== const validateApiRoute = (req, res, next) => { if (req.path.startsWith('/api/') && !req.path.startsWith('/api/public/')) { const validApiPaths = [ '/api/auth', '/api/cameras', '/api/analytics', '/api/sessions', '/api/carousel', '/api/logo', '/api/plan', '/api/recordings', '/api/users', '/api/notifications', '/api/ws', '/api/ws-api', '/api/health', '/api/debug', '/api/components', '/api/user/components', '/api/purchase', '/api/ztp', '/api/vpn', '/api/stream', '/api/streaming', '/api/access-tokens', '/api/activation', '/api/device', '/api/devices', '/api/ai' ]; const basePath = req.path.split('?')[0]; const isValidPath = validApiPaths.some(validPath => basePath === validPath || basePath.startsWith(validPath + '/') ); if (!isValidPath) { return res.status(404).json({ error: 'API endpoint not found', path: req.originalUrl }); } } next(); }; const xss = require('xss-clean'); const mongoSanitize = require('express-mongo-sanitize'); const hpp = require('hpp'); // 📋 Diagnóstico: listar todas las rutas registradas const listEndpoints = require('express-list-endpoints'); // 1️⃣ ✅ TRUST PROXY SEGURO - Solo confiar en el primer proxy (Nginx) app.set('trust proxy', 1); // 2️⃣ ✅ Cookie Parser - CRÍTICO para shares app.use(cookieParser()); // 3️⃣ ✅ Helmet con CSP mejorado para WebSocket y archivos app.use(helmet({ crossOriginEmbedderPolicy: false, crossOriginResourcePolicy: { policy: "cross-origin" }, contentSecurityPolicy: { directives: { defaultSrc: ["'self'"], connectSrc: process.env.NODE_ENV === 'production' ? [ "'self'", "ws:", "wss:", "https://api.parkingcan.es", "wss://gestion.parkingcan.es" ] : [ "'self'", "http:", "https:", "ws:", "wss:", "capacitor://localhost" ], scriptSrc: [ "'self'", "'unsafe-inline'", "'unsafe-eval'" ], styleSrc: [ "'self'", "'unsafe-inline'", "https://fonts.googleapis.com" ], imgSrc: [ "'self'", "data:", "https:", "blob:" ], mediaSrc: [ "'self'", "blob:", "https:" ], fontSrc: [ "'self'", "data:", "https://fonts.gstatic.com" ], frameAncestors: ["'self'"] } } })); // Headers extra de seguridad HTTP app.use((req, res, next) => { res.setHeader('Referrer-Policy', 'no-referrer'); res.setHeader('Permissions-Policy', 'geolocation=(), microphone=()'); res.setHeader('Strict-Transport-Security', 'max-age=63072000; includeSubDomains; preload'); // Exponer cabeceras de rate limit al cliente res.setHeader( 'Access-Control-Expose-Headers', 'RateLimit-Policy,RateLimit-Limit,RateLimit-Remaining,RateLimit-Reset,Retry-After' ); next(); }); // 4️⃣ ✅ Bloqueo de accesos a archivos sensibles app.use((req, res, next) => { const bloqueos = [ /\.env/i, /\.git/i, /wp-admin/i, /phpmyadmin/i, /\.htaccess/i, /\.htpasswd/i, /config\.php/i, /\.sql$/i, /\.bak$/i, /\.backup$/i ]; if (bloqueos.some(rx => rx.test(req.url))) { console.warn('🚫 Acceso bloqueado a archivo sensible: ' + req.url + ' desde ' + req.ip); return res.status(403).json({ error: 'Access denied' }); } next(); }); // 5️⃣ ✅ Captura de URL mal codificadas app.use((err, req, res, next) => { if (err instanceof URIError) { console.warn('🚨 URI mal codificada desde ' + req.ip + ': ' + req.url); return res.status(400).json({ error: 'Invalid URL encoding' }); } next(err); }); // ======================================== // 🔧 MIDDLEWARE DE EXPRESS CON PAYLOAD SEGURO // ======================================== // ✅ JSON con límite de 50MB y validación de sintaxis // ⚠️ EXCLUYE rutas ZTP que usan parsers tolerantes propios app.use((req, res, next) => { // 🔥 ZTP usa parsers propios if (req.path.startsWith('/api/ztp/')) { return next(); } // 🔐 Resto de API con JSON estricto express.json({ limit: '50mb', verify: (req, res, buf) => { if (!buf || buf.length === 0) return; JSON.parse(buf.toString()); } })(req, res, next); }); // ✅ URL-encoded con límites (también excluir ZTP) app.use((req, res, next) => { if (req.path.startsWith('/api/ztp/')) { return next(); } express.urlencoded({ extended: true, limit: '50mb', parameterLimit: 1000 })(req, res, next); }); // ======================================== // 🛡️ SEGURIDAD CONTRA XSS / MONGO INJECTION / PARAM POLLUTION // ======================================== app.use(xss()); app.use(mongoSanitize()); app.use(hpp()); // === CORS debug-friendly temporal === const allowedOriginsRaw = [ 'capacitor://localhost', 'https://localhost', 'http://localhost', (process.env.FRONTEND_URL || '').toLowerCase(), 'https://www.parkingcan.es', 'https://parkingcan.es', 'http://parkingcan.es', 'https://api.parkingcan.es', 'https://217.154.18.63', 'http://217.154.18.63' ].filter(Boolean); const normalizeBase = (origin) => { try { const url = new URL(origin); return `${url.protocol}//${url.hostname.toLowerCase()}`; } catch { return origin.toLowerCase(); } }; // — Usa el MISMO objeto tanto en app.use() como en OPTIONS — const corsOptions = { origin: (origin, cb) => { if (!origin) return cb(null, true); // server-side/no origin const normalized = normalizeBase(origin); const allowed = allowedOriginsRaw.includes(normalized); if (process.env.NODE_ENV === 'development') { console.log(`[CORS DEBUG] origin="${origin}", normalized="${normalized}", allowed=${allowed}`); } if ( allowed || /^https?:\/\/localhost(?::\d+)?$/.test(origin.toLowerCase()) || /^capacitor:\/\/localhost(?::\d+)?$/.test(origin.toLowerCase()) ) { return cb(null, true); } console.warn('🚫 CORS bloqueado para origen:', origin); return cb(new Error('Not allowed by CORS')); }, methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS', 'PATCH', 'HEAD'], allowedHeaders: ['Content-Type', 'Authorization', 'X-Requested-With', 'X-ZTP-Version', 'X-ZTP-MAC', 'X-Hardware-Hash'], credentials: true }; app.use(cors(corsOptions)); // ✅ Responder a todas las peticiones OPTIONS app.options('*', cors(corsOptions)); // ✅ Captura de errores CORS con respuesta JSON app.use((err, req, res, next) => { if (err.message === 'Not allowed by CORS') { return res.status(403).json({ error: 'forbidden_origin', message: 'Origen no permitido por política CORS', timestamp: new Date().toISOString() }); } next(err); }); // ======================================== // 🔐 MIDDLEWARE GLOBAL DE AUTENTICACIÓN JWT // ======================================== /** * Middleware para poblar req.user desde Bearer token * CRÍTICO: Se aplica ANTES de montar las rutas */ function restoreUserFromJWT(req, res, next) { const authHeader = req.headers.authorization || ''; const match = authHeader.match(/^Bearer\s+(.+)$/i); if (!match) { // No hay token, continuar como anónimo return next(); } try { const token = match[1]; const decoded = jwt.verify(token, process.env.JWT_SECRET, { issuer: process.env.JWT_ISSUER }); // Poblar req.user con la información del token req.user = { userId: decoded.userId || decoded.sub || decoded.id, email: decoded.email, role: decoded.role || decoded.claims?.role || 'user', isAdmin: decoded.role === 'admin' || decoded.isAdmin === true, permissions: decoded.permissions || { canGenerateQR: decoded.role === 'propietario' || decoded.role === 'admin' || decoded.permissions?.canGenerateQR }, plan: decoded.plan || { type: 'free' } }; // Log para debug solo en desarrollo if (process.env.NODE_ENV === 'development') { console.log('[AUTH] Usuario autenticado:', { userId: req.user.userId, role: req.user.role, path: req.path }); } } catch (err) { // Token inválido o expirado - continuar como anónimo if (process.env.NODE_ENV === 'development' && !req.path.includes('/health')) { console.log('[AUTH] Token inválido:', err.message); } } next(); } // Aplicar el middleware de autenticación a todas las rutas API app.use('/api', restoreUserFromJWT); // ======================================== // 📄 RATE LIMITING OPTIMIZADO + ZTP ESPECÍFICO // ======================================== const makeLimiter = (ms, max, msg) => rateLimit({ windowMs: ms, max, message: { error: 'Too Many Requests', message: msg, retryAfter: Math.ceil(ms / 1000) }, standardHeaders: true, legacyHeaders: false, keyGenerator: (req) => { return req.ip || req.connection.remoteAddress || 'unknown'; }, handler: (req, res, next, options) => { if (req.rateLimit.current === req.rateLimit.limit + 1) { console.warn('🚨 Rate limit alcanzado para IP: ' + req.ip + ' en ' + req.originalUrl); } res.status(options.statusCode).json(options.message); }, skip: (req) => { // No contar preflight if (req.method === 'OPTIONS') return true; // Skip streaming HLS (muchas peticiones legítimas) if (req.url.startsWith('/stream/')) return true; // Skip rate limiting para archivos estáticos return req.url.match(/\.(js|css|png|jpg|jpeg|gif|ico|woff|woff2|ttf|eot|svg|webp)$/); } }); // ✅ Rate limiters específicos const generalLimiter = makeLimiter(15 * 60 * 1000, 1000, 'Demasiadas peticiones generales'); const registerLimiter = makeLimiter(60 * 60 * 1000, 3, 'Demasiados intentos de registro'); // ✅ Rate limiters específicos para ZTP const ztpHeartbeatLimiter = makeLimiter(5 * 60 * 1000, 50, 'Demasiados heartbeats ZTP'); const ztpActivationLimiter = makeLimiter(60 * 60 * 1000, 10, 'Demasiados intentos de activación ZTP'); const ztpSyncLimiter = makeLimiter(10 * 60 * 1000, 30, 'Demasiadas sincronizaciones ZTP'); app.use('/api/', generalLimiter); // ======================================== // 🛡️ MIDDLEWARE ANTI-HEADERS DUPLICADOS // ======================================== app.use((req, res, next) => { const originalSetHeader = res.setHeader; const headersSet = new Set(); res.setHeader = function (name, value) { if (!res.headersSent) { const headerKey = name.toLowerCase(); if (headersSet.has(headerKey)) { const systemHeaders = [ 'content-type', 'ratelimit-policy', 'ratelimit-limit', 'ratelimit-remaining', 'ratelimit-reset', 'access-control-allow-origin', 'access-control-allow-methods', 'access-control-allow-headers', 'access-control-allow-credentials', 'x-frame-options', 'x-content-type-options', 'strict-transport-security', 'referrer-policy', 'permissions-policy' ]; if (!systemHeaders.includes(headerKey)) { console.warn(`⚠️ Header duplicado crítico detectado: '${name}' en ${req.originalUrl}`); } return; } headersSet.add(headerKey); return originalSetHeader.call(this, name, value); } else { console.warn(`⚠️ Headers ya enviados en ${req.originalUrl} - ignorando '${name}'`); } }; next(); }); // ======================================== // 📋 LOGGING MEJORADO CON ZTP // ======================================== app.use((req, res, next) => { const isStatic = req.url.match(/\.(js|css|png|jpg|jpeg|gif|ico|woff|woff2|ttf|eot|svg|webp)$/); if (!isStatic) { const timestamp = new Date().toISOString(); const method = req.method; const url = req.url; const ip = req.ip || 'unknown'; // Log especial para rutas ZTP if (req.url.startsWith('/api/ztp/')) { const mac = req.get('X-ZTP-MAC') || req.body?.mac || req.query?.mac; const ztpVersion = req.get('X-ZTP-Version'); console.log(`${timestamp} → ZTP ${method} ${url} from ${ip}${mac ? ` [MAC: ${mac}]` : ''}${ztpVersion ? ` [v${ztpVersion}]` : ''}`); } else if (req.url.startsWith('/api/share/')) { // Log especial para shares console.log(`${timestamp} → SHARE ${method} ${url} from ${ip}`); } else { console.log(`${timestamp} → ${method} ${url} from ${ip}`); } // Debug headers en desarrollo if (process.env.NODE_ENV === 'development') { if (req.headers['x-forwarded-for']) { console.log(' X-Forwarded-For: ' + req.headers['x-forwarded-for']); } if (req.headers['x-real-ip']) { console.log(' X-Real-IP: ' + req.headers['x-real-ip']); } } } next(); }); console.log('✅ Rutas de streaming automático cargadas'); // ======================================== // 🔌 PROTECCIÓN HLS (ANTES de archivos estáticos) // ======================================== // === PROTECCIÓN HLS SIEMPRE ACTIVA === app.use('/streams', hlsAuth); // Autenticación obligatoria app.use('/streams', hlsNoCacheHeaders); // Headers de cache apropiados app.use('/streams', hlsAccessLog); // Logging opcional // ======================================== // 🗂️ ARCHIVOS ESTÁTICOS CON PROTECCIÓN // ======================================== // ✅ Protección del favicon app.get('/favicon.ico', (req, res) => { res.setHeader('Content-Type', 'image/x-icon'); res.setHeader('Cache-Control', 'public, max-age=86400'); res.status(204).end(); }); // ✅ Directorio /images con protección app.use('/images', express.static(path.join(__dirname, 'deploy/images'), { dotfiles: 'deny', fallthrough: false, index: false, maxAge: '1d', setHeaders: (res, filePath) => { if (!res.headersSent) { res.setHeader('X-Content-Type-Options', 'nosniff'); res.setHeader('Cache-Control', 'public, max-age=86400'); } } })); // ✅ Streams HLS (DESPUÉS del middleware de autenticación) app.use('/streams', express.static('/var/www/streams', { dotfiles: 'deny', maxAge: '0', etag: false, fallthrough: true, setHeaders: (res, filePath) => { if (filePath.endsWith('.m3u8')) { res.setHeader('Content-Type', 'application/vnd.apple.mpegurl'); res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate'); } else if (filePath.endsWith('.ts')) { res.setHeader('Content-Type', 'video/MP2T'); res.setHeader('Cache-Control', 'public, max-age=3600'); } } })); // ======================================== // 🎯 SHORTLINK DE SHARES (ANTES del frontend) // ======================================== app.get('/s/:code', (req, res) => { console.log('[SHORTLINK] Redirigiendo share:', req.params.code); res.redirect(302, `/api/share/${req.params.code}`); }); // ======================================== // 🛣️ FUNCIONES HELPER PARA CARGA SEGURA DE RUTAS // ======================================== // ✅ Safe require mejorado con verificación de Router const safeRequire = (routePath, routeName) => { try { const routeModule = require(routePath); // Verificar que es un router válido de Express if (routeModule && typeof routeModule === 'function' && routeModule.stack) { console.log(`✅ ${routeName} cargadas correctamente`); return routeModule; } else if (routeModule && routeModule.router && typeof routeModule.router === 'function') { console.log(`✅ ${routeName} cargadas correctamente (con propiedad router)`); return routeModule.router; } else { console.warn(`⚠️ ${routeName} no exporta un router válido, creando fallback`); const tempRouter = express.Router(); tempRouter.all('*', (req, res) => { res.status(503).json({ error: 'Service temporarily unavailable', service: routeName }); }); return tempRouter; } } catch (error) { console.warn(`⚠️ ${routeName} no encontradas: ${error.message}`); const tempRouter = express.Router(); tempRouter.all('*', (req, res) => { res.status(503).json({ error: 'Service not available', service: routeName }); }); return tempRouter; } }; // ======================================== // ⏱️ Rate Limiters — Protección contra abuso // ======================================== // Limita intentos de login por IP: máx. 10 cada 15 minutos const loginLimiter = rateLimit({ windowMs: 15 * 60 * 1000, // 15 min max: 10, standardHeaders: true, legacyHeaders: false, message: { error: '⏱️ Demasiados intentos de acceso. Intenta de nuevo más tarde.' } }); app.use('/api/auth/login', loginLimiter); // Limita verificaciones de token (email/activación): máx. 20 cada 15 minutos const tokenLimiter = rateLimit({ windowMs: 15 * 60 * 1000, max: 20, standardHeaders: true, legacyHeaders: false, message: { error: '⏱️ Demasiadas solicitudes de verificación. Espera un momento.' } }); app.use('/api/auth/verify', tokenLimiter); app.use('/api/activation/', tokenLimiter); // ======================================== // 🔧 RATE LIMITERS ESPECÍFICOS PARA ZTP // ======================================== // Aplicar rate limiters a rutas ZTP específicas app.use('/api/ztp/heartbeat', ztpHeartbeatLimiter); app.use('/api/ztp/activate', ztpActivationLimiter); app.use('/api/ztp/sync', ztpSyncLimiter); // ======================================== // 🎬 STREAMING CONTROLLER // ======================================== const streamController = require('./controllers/streamController'); app.use((req, res, next) => { console.log('➡️ REQUEST:', req.method, req.url); next(); }); // ======================================== // ✅ RUTAS ZTP - PRIORIDAD ABSOLUTA (ANTES DE CUALQUIER OTRA) try { const ztpRoutes = require('./routes/ztp'); app.use('/api/ztp', ztpRoutes); try { const ztpRegisterFlash = require('./routes/ztpRegisterFlash'); app.use('/api/ztp', ztpRegisterFlash); console.log("✅ register-flash cargado"); } catch (e) { console.error("❌ Error cargando register-flash:", e.message); } console.log('✅ Rutas ZTP principales cargadas correctamente'); // ✅ Rutas ZTP miniPC const minipcRoutes = require('./routes/minipc'); app.use('/api/ztp/minipc', minipcRoutes); console.log('✅ Rutas ZTP miniPC cargadas'); } catch (error) { console.error('❌ Error crítico cargando rutas ZTP:', error.message); const ztpFallback = express.Router(); ztpFallback.all('*', (req, res) => { res.status(503).json({ error: 'ZTP service unavailable', message: 'Sistema ZTP temporalmente no disponible', timestamp: new Date().toISOString() }); }); app.use('/api/ztp', ztpFallback); } // ✅ RUTAS ZTP SSH try { const ztpSshRoutes = require('./routes/ztp-ssh'); if (ztpSshRoutes && typeof ztpSshRoutes === 'function') { app.use('/api/ztp/ssh', ztpSshRoutes); console.log('✅ Rutas ZTP SSH cargadas correctamente en /api/ztp/ssh'); } else { console.warn('⚠️ Rutas ZTP SSH no válidas, usando fallback'); } } catch (error) { console.warn('⚠️ No se pudo cargar rutas SSH de ZTP:', error.message); // Crear fallback para las rutas SSH problemáticas const sshFallback = express.Router(); sshFallback.post('/register', (req, res) => { res.status(503).json({ error: 'SSH registration unavailable' }); }); sshFallback.get('/authorized', (req, res) => { res.status(503).json({ error: 'SSH authorization unavailable' }); }); app.use('/api/ztp/ssh', sshFallback); } // ✅ Rutas de autenticación - CARGA DIRECTA app.use('/api/auth/register', registerLimiter); const authRoutes = require('./routes/auth'); app.use('/api/auth', authRoutes); app.use('/api/admin', adminAuthRoutes); // Exportar middlewares de auth globalmente const { verifyToken, requireRole, normalizeRole } = authRoutes; global.authMiddleware = { verifyToken, requireRole, normalizeRole }; console.log('✅ Rutas de autenticación cargadas con middlewares exportados'); // ✅ Rutas VPN const vpnRoutes = safeRequire('./routes/vpn', 'Rutas VPN'); app.use('/api/vpn', vpnRoutes); // ✅ Rutas de sesiones const sessionsRoutes = safeRequire('./routes/sessions', 'Rutas de sesiones'); app.use('/api/sessions', sessionsRoutes); // ✅ Rutas de activación const activationRoutes = safeRequire('./routes/activation', 'Rutas de activación'); app.use('/api/activation', activationRoutes); // ✅ RUTAS DE CÁMARAS - UNIFICADAS Y SIN CONFLICTOS const cameraRoutes = require('./routes/cameras'); // Middleware para manejar conflictos entre rutas ZTP y cámaras app.use('/api/cameras', (req, res, next) => { // Si es la ruta de configuración ZTP, redirigir a ZTP if (req.path === '/config' && req.method === 'GET') { if (!req.query.mac) { return res.status(400).json({ error: 'Parámetro mac requerido para configuración de cámaras', suggestion: 'Use /api/ztp/cameras/config?mac=ROUTER_MAC para routers ZTP', alternative: 'O use /api/cameras con autenticación para gestión manual', timestamp: new Date().toISOString() }); } // Redirigir a la ruta ZTP correspondiente return res.redirect(307, `/api/ztp/cameras/config?mac=${req.query.mac}`); } next(); }, cameraRoutes); // ✅ Rutas de logo - CON AUTENTICACIÓN const logoRoutes = safeRequire('./routes/logo', 'Rutas de logo'); app.use('/api/logo', requireAuth, logoRoutes); // ✅ Rutas de carrusel - CON AUTENTICACIÓN const carouselRoutes = safeRequire('./routes/carousel', 'Rutas de carrusel'); app.use('/api/carousel', carouselRoutes); // 📦 BRANDING PÚBLICO (SIN AUTH) const publicBrandingRoutes = safeRequire( './routes/publicBranding', 'Public Branding Routes' ); app.use('/api/public', publicBrandingRoutes); // ✅ Rutas de dispositivo local (miniPC) — auth por X-Device-Key const deviceRoutes = require('./routes/device'); app.use('/api/device', deviceRoutes); const ztpAssetsRoutes = require('./routes/ztpAssets'); app.use('/api/owner-sync', ztpAssetsRoutes); // ✅ Rutas de plan const planRoutes = safeRequire('./routes/plan', 'Rutas de plan'); app.use('/api/plan', planRoutes); // ✅ Rutas de grabaciones const recordingsRoutes = safeRequire('./routes/recordings', 'Rutas de grabaciones'); app.use('/api/recordings', recordingsRoutes); // ✅ Rutas de streaming // const streamRoutes = safeRequire('./routes/stream', 'Rutas de streaming'); // app.use('/api/stream', streamRoutes); const streamingRoutes = require('./routes/streaming'); app.use('/api/streaming', streamingRoutes); // Estado app.get('/api/stream/status', streamController.getStreamStatus); app.get('/api/stream/list', streamController.listStreams); // Cámara individual app.get( '/api/stream/router/:routerMac/camera/:cameraIndex/status', streamController.getCameraStatus ); // Playlist y segmentos app.get( '/api/stream/router/:mac/camera/:index/playlist.m3u8', streamController.getPlaylist ); app.get( '/api/stream/router/:mac/camera/:index/segments/:file', streamController.getSegment ); // QR app.get('/api/stream/qr/:token', streamController.getPlaylistForToken); app.get('/api/stream/token/:token', streamController.getPlaylistForToken); app.get('/api/stream/validate/:token', streamController.validateStreamAccess); app.use('/api/admin', adminRoutes); const routerStateRoutes = require('./routes/routerState'); app.use('/api/router-state', routerStateRoutes); console.log('✅ Control de streams delegado a systemd'); // ✅ Rutas de acceso compartido (tokens) const accessTokenRoutes = safeRequire('./routes/accessTokenRoutes', 'Rutas de tokens de acceso'); app.use('/api/access-tokens', accessTokenRoutes); // ✅ Rutas de usuarios const usersRoutes = safeRequire('./routes/users', 'Rutas de usuarios'); app.use('/api/users', usersRoutes); // ✅ Rutas de notificaciones const notificationsRoutes = safeRequire('./routes/notifications', 'Rutas de notificaciones'); app.use('/api/notifications', notificationsRoutes); // ✅ Rutas de componentes con alias const componentsRoutes = require('./routes/components'); const analyticsRoutes = require('./routes/analytics'); const webAnalyticsRoutes = require('./routes/webAnalytics'); // ... app.use('/api/analytics', webAnalyticsRoutes); // ← Usar esta EN LUGAR de analyticsRoutes // Aplicar autenticación a las rutas de componentes app.use('/api/components', requireAuth, componentsRoutes); app.use('/api/user/components', requireAuth, componentsRoutes); app.use('/api/analytics', analyticsRoutes); console.log('✅ Rutas de componentes cargadas con autenticación y alias /api/user/components'); // ✅ RUTAS WEBSOCKET API - Integración con límites de conexión app.use('/api/ws-api', wsApiRouter); console.log('✅ Rutas WebSocket API con límites de conexión cargadas'); // Mantener compatibilidad con rutas antiguas try { const websocketRoutes = require('./routes/websocket'); if (websocketRoutes && typeof websocketRoutes === 'function') { app.use('/api/ws', websocketRoutes); console.log('✅ Rutas WebSocket legacy cargadas'); } } catch (error) { console.warn('⚠️ Rutas WebSocket legacy no disponibles'); } console.log('Todas las rutas de API configuradas correctamente'); console.log('Sistema unificado de cámaras cargado correctamente'); console.log('Rutas administrativas de cámaras disponibles en /api/admin/cameras'); // Diagnosticar endpoints después de cargar todas las rutas try { if (process.env.NODE_ENV === 'development') { console.log('Endpoints registrados:', listEndpoints(app).length, 'rutas'); } } catch (endpointError) { console.warn('Error listando endpoints:', endpointError.message); } // ======================================== // 📊 FUNCIÓN PARA OBTENER ESTADÍSTICAS ZTP // ======================================== const getZtpStats = async () => { try { // Intentar obtener estadísticas de la base de datos MongoDB const Router = mongoose.models.Router || require('./models/Router'); const AuthorizedKey = mongoose.models.AuthorizedKey || require('./models/AuthorizedKey'); const [ totalRouters, onlineRouters, activatedRouters, vpnActiveRouters, activeKeys, recentActivity ] = await Promise.all([ Router.countDocuments(), Router.countDocuments({ status: 'online' }), Router.countDocuments({ status: 'activated' }), Router.countDocuments({ vpn_active: true }), AuthorizedKey.countDocuments({ active: true }), Router.find().sort({ last_heartbeat: -1 }).limit(5).select('mac model last_heartbeat status') ]); return { routers: { total: totalRouters, online: onlineRouters, activated: activatedRouters, vpn_active: vpnActiveRouters }, ssh: { authorized_keys: activeKeys }, recent_activity: recentActivity, available: true }; } catch (error) { console.warn('⚠️ Error obteniendo estadísticas ZTP:', error.message); return { available: false, error: error.message }; } }; // ✅ Ruta de salud mejorada CON ESTADÍSTICAS ZTP Y SOCKET.IO app.get('/api/health', async (req, res) => { try { const io = req.app?.locals?.io || global.io || null; // Conexiones activas const totalConnections = io ? io.of('/').sockets.size : 0; // ZTP stats let ztpStats = null; try { if (typeof getZtpStats === 'function') { ztpStats = await getZtpStats(); } } catch { /* no-op */ } // Mongoose info segura const dbState = (global.mongoose?.connection?.readyState === 1) ? 'connected' : (typeof global.mongoose?.connection?.readyState === 'number' ? 'disconnected' : 'unknown'); const database = { status: dbState, name: global.mongoose?.connection?.name || null, host: global.mongoose?.connection?.host || null, port: global.mongoose?.connection?.port || null, }; // authUtils seguro const authLoaded = !!global.authUtils || false; const planTypes = (global.authUtils?.PLAN_TYPES) ? Object.values(global.authUtils.PLAN_TYPES) : []; const planCodes = (global.authUtils?.PLAN_CODES) || []; res.json({ status: 'ok', version: '1.0.9', timestamp: new Date().toISOString(), uptime: process.uptime(), environment: process.env.NODE_ENV || 'development', security: { cors: 'enabled', helmet: 'enabled', rateLimit: 'enabled', staticFileProtection: 'enabled', trustProxy: 'secure', headerProtection: 'optimized', payloadValidation: 'enabled', fileExtensionBlocking: 'enabled', ztpRateLimiting: 'enabled', hlsProtection: 'ALWAYS ACTIVE', shareSystem: 'JWT + QR + TimeWindows' }, stats: { totalConnections }, socketio: { status: io ? 'enabled' : 'disabled', connectionLimits: 'active', stats: { totalConnections }, features: { planBasedLimits: true, deviceCategoryDetection: true, autoReconnection: true, roomBasedMessaging: true } }, database, cors: { androidSupport: true, webSupport: true, capacitorSupport: true, ztpSupport: true }, planSystem: { availablePlans: planTypes, validCodes: planCodes, roleNormalization: 'enabled', authUtilsLoaded: authLoaded }, routes: { auth: 'loaded', sessions: 'loaded', activation: 'loaded', cameras: 'loaded (unified)', websocket: 'loaded with connection limits', components: 'loaded with alias', recordings: 'loaded', ztp: 'loaded with full integration', ztpSsh: 'loaded with fallback', vpn: 'loaded', stream: 'loaded (systemd managed)', access: 'loaded', users: 'loaded', notifications: 'loaded', carousel: 'loaded with auth', logo: 'loaded with auth', share: 'loaded with QR + JWT + TimeWindows' }, shares: { qrGeneration: 'enabled', jwtAuth: 'enabled', timeWindows: 'up to 5 per camera', secureLinkIntegration: 'enabled', cookieAuth: 'enabled' }, streaming: { architecture: 'systemd', backend: 'orchestrator only', ffmpeg: 'managed by systemd', hlsDir: '/var/www/streams', user: 'pcgw' }, ztp: ztpStats }); } catch (err) { res.status(200).json({ status: 'ok', socketio: { status: 'unknown' }, stats: { totalConnections: 0 } }); } }); // ======================================== // 🧪 RUTAS DE DESARROLLO CON SEGURIDAD + ZTP DEBUG // ======================================== if (process.env.NODE_ENV !== 'production') { // ✅ Debug del token JWT app.get('/api/auth/_debug', requireAuth, (req, res) => { res.json({ userId: req.userId, role: req.role, email: req.email, issuer: process.env.JWT_ISSUER, payload: req.user, timestamp: new Date().toISOString() }); }); // ✅ Test Socket.IO con límites app.post('/api/debug/socketio-test/:userId', async (req, res) => { const { userId } = req.params; if (!userId || typeof userId !== 'string' || userId.length > 50) { return res.status(400).json({ error: 'Invalid userId' }); } const connectionCount = getUserConnectionCount(userId); const sent = notifyUsers([userId], { type: 'test', message: 'Socket.IO test message', timestamp: new Date().toISOString() }); res.json({ userId, currentConnections: connectionCount, messageSent: sent > 0, timestamp: new Date().toISOString() }); }); // ✅ Test del middleware normalizeRole app.get('/api/debug/role-normalization', (req, res) => { const testCases = Object.values(authUtils.PLAN_TYPES || {}).map(planType => ({ plan: planType, normalizedRole: authUtils.getNormalizedRole ? authUtils.getNormalizedRole(planType) : 'N/A', limits: authUtils.getPlanLimits ? authUtils.getPlanLimits(planType) : 'N/A', display: authUtils.getPlanDisplay ? authUtils.getPlanDisplay(planType) : 'N/A' })); res.json({ message: 'Role normalization test', testCases, middleware: 'normalizeRole available', authUtils: { loaded: !!authUtils, planTypes: authUtils.PLAN_TYPES || {}, roles: authUtils.ROLES || {}, paidPlans: authUtils.PAID_PLANS || [] }, timestamp: new Date().toISOString() }); }); // ✅ DEBUG ZTP ESPECÍFICO app.get('/api/debug/ztp-test', async (req, res) => { try { const ztpStats = await getZtpStats(); res.json({ message: 'ZTP System Debug', ztpRoutes: 'loaded', rateLimiting: { heartbeat: 'enabled (50 req/5min)', activation: 'enabled (10 req/hour)', sync: 'enabled (30 req/10min)' }, database: { connection: mongoose.connection.readyState === 1 ? 'connected' : 'disconnected', models: { Router: !!mongoose.models.Router, AuthorizedKey: !!mongoose.models.AuthorizedKey } }, endpoints: { identify: '/api/ztp/identify', activate: '/api/ztp/activate', heartbeat: '/api/ztp/heartbeat', sync: '/api/ztp/sync', registerSsh: '/api/ztp/register-ssh', authorizedKeys: '/api/ztp/authorized_keys', status: '/api/ztp/status', camerasConfig: '/api/ztp/cameras/config' }, statistics: ztpStats, timestamp: new Date().toISOString() }); } catch (error) { res.status(500).json({ error: 'Error en debug ZTP', message: error.message, timestamp: new Date().toISOString() }); } }); // ✅ Socket.IO connection stats app.get('/api/debug/socketio-stats', (req, res) => { const stats = getConnectionStats(); res.json({ socketio: { enabled: true, stats, limits: { free: { total: 2, mobile: 1, browser: 1 }, premium: { total: 10, mobile: 'unlimited', browser: 'unlimited' } }, timestamp: new Date().toISOString() } }); }); // ✅ Estadísticas del sistema app.get('/api/debug/stats', (req, res) => { const socketStats = getConnectionStats(); res.json({ server: { uptime: process.uptime(), memory: process.memoryUsage(), platform: process.platform, nodeVersion: process.version, environment: process.env.NODE_ENV || 'development' }, mongodb: { readyState: mongoose.connection.readyState, name: mongoose.connection.name, host: mongoose.connection.host, port: mongoose.connection.port }, socketio: socketStats, authentication: { authUtilsLoaded: !!authUtils, planTypesCount: Object.keys(authUtils.PLAN_TYPES || {}).length, availablePlans: Object.values(authUtils.PLAN_TYPES || {}), roleNormalization: 'enabled' }, ztp: { routesLoaded: true, rateLimitingEnabled: true, mongoIntegration: mongoose.connection.readyState === 1 }, shares: { enabled: true, features: ['QR Generation', 'JWT Auth', 'Time Windows', 'Secure Link'] }, streaming: { architecture: 'systemd', backend: 'orchestrator only' }, timestamp: new Date().toISOString() }); }); // ✅ Test CORS app.get('/api/debug/cors-test', (req, res) => { res.json({ origin: req.headers.origin || 'no-origin', userAgent: req.headers['user-agent'], method: req.method, corsWorking: true, allowedOrigins: allowedOriginsRaw, requestIP: req.ip, ztpHeaders: { 'X-ZTP-Version': req.get('X-ZTP-Version'), 'X-ZTP-MAC': req.get('X-ZTP-MAC'), 'X-Hardware-Hash': req.get('X-Hardware-Hash') }, timestamp: new Date().toISOString() }); }); // ✅ Test de seguridad completo app.get('/api/debug/security-test', (req, res) => { res.json({ security: { helmet: 'enabled', rateLimit: 'enabled', cors: 'enabled', staticFileProtection: 'enabled', suspiciousFileBlocking: 'enabled', uriErrorHandling: 'enabled', trustProxy: 'secure (proxy=1)', headerProtection: 'optimized', payloadValidation: 'enabled', ztpRateLimiting: 'enabled', socketioLimits: 'enabled', hlsAuth: 'ALWAYS ACTIVE', shareAuth: 'JWT + Cookies', streamingArchitecture: 'systemd' }, headers: req.headers, ip: req.ip, realIp: req.headers['x-real-ip'] || 'none', forwardedFor: req.headers['x-forwarded-for'] || 'none', timestamp: new Date().toISOString() }); }); } // ======================================== // 🎯 FRONTEND SPA - Build en deploy/ // ======================================== // ✅ Resto del frontend (build React/Capacitor en deploy/) app.use(express.static(path.join(__dirname, 'deploy'), { dotfiles: 'deny', fallthrough: true, index: false, maxAge: '1h', setHeaders: (res, filePath) => { if (!res.headersSent) { res.setHeader('X-Content-Type-Options', 'nosniff'); res.setHeader('X-Frame-Options', 'SAMEORIGIN'); if (filePath.match(/\.(js|css|woff|woff2)$/)) { res.setHeader('Cache-Control', 'public, max-age=31536000'); } } } })); // Lista de rutas del frontend que deben servir index.html const frontendRoutes = [ '/dashboard', '/login', '/cameras', '/settings', '/recordings', '/profile', '/admin', '/support', '/plans', '/notifications' ]; app.use( '/owner-assets', express.static('/var/www/parkingcan-data', { dotfiles: 'deny', fallthrough: false, index: false }) ); // ✅ Servir index.html para todas las rutas del frontend conocidas frontendRoutes.forEach(route => { app.get(route, (req, res) => { const indexPath = path.join(__dirname, 'deploy', 'index.html'); if (fs.existsSync(indexPath)) { res.sendFile(indexPath); } else { res.status(503).json({ error: 'Frontend no disponible', message: 'El build del frontend no está disponible en deploy/index.html', suggestion: 'Ejecuta npm run build en el frontend y despliega los archivos en deploy/', path: req.originalUrl, timestamp: new Date().toISOString() }); } }); }); // ✅ Ruta raíz - servir index.html app.get('/', (req, res) => { const indexPath = path.join(__dirname, 'deploy', 'index.html'); if (fs.existsSync(indexPath)) { res.sendFile(indexPath); } else { res.status(200).json({ message: 'ParkingCan Backend API v1.0.9', status: 'running', architecture: 'systemd-managed streaming', endpoints: { health: '/api/health', auth: '/api/auth', cameras: '/api/cameras', dashboard: '/dashboard', frontend: 'Deploy frontend build to /backend/deploy/' }, timestamp: new Date().toISOString() }); } }); // ✅ CATCH-ALL FINAL - Para cualquier otra ruta no manejada app.get('*', (req, res) => { // Si es una ruta API no encontrada if (req.path.startsWith('/api/')) { return res.status(404).json({ error: 'API endpoint not found', path: req.originalUrl, method: req.method, availableEndpoints: [ '/api/auth - Autenticación', '/api/sessions - Gestión de sesiones', '/api/cameras - Gestión de cámaras (unificado)', '/api/carousel - Carrusel publicitario', '/api/logo - Gestión de logos', '/api/plan - Gestión de planes', '/api/recordings - Grabaciones', '/api/stream - Streaming (systemd)', '/api/access-tokens - Tokens de acceso', '/api/activation - Activación', '/api/users - Gestión de usuarios', '/api/notifications - Notificaciones', '/api/ws - WebSocket API (legacy)', '/api/ws-api - WebSocket API con límites', '/api/components - Componentes', '/api/user/components - Componentes de usuario (alias)', '/api/health - Estado del servidor', '/api/ztp - Zero Touch Provisioning para routers', '/api/ztp/ssh - Gestión SSH para ZTP', '/api/vpn - Gestión VPN', '/api/share - Sistema de shares con QR y JWT', '/api/ai - IA Comportamiento Animal (YOLOv8n)' ], timestamp: new Date().toISOString() }); } // Para cualquier otra ruta, intentar servir el SPA const indexPath = path.join(__dirname, 'deploy', 'index.html'); if (fs.existsSync(indexPath)) { res.sendFile(indexPath); } else { res.status(503).json({ error: 'Frontend no disponible', message: 'El build del frontend no está disponible en deploy/index.html', suggestion: 'Ejecuta npm run build en el frontend y despliega los archivos en deploy/', path: req.originalUrl, timestamp: new Date().toISOString() }); } }); // ======================================== // 🚨 MANEJO GLOBAL DE ERRORES MEJORADO // ======================================== app.use((error, req, res, next) => { console.error('❌ Error no manejado: ' + error.message); console.error(' URL: ' + req.method + ' ' + req.originalUrl); console.error(' IP: ' + req.ip); console.error(' User-Agent: ' + (req.headers['user-agent'] || 'unknown')); // Log especial para errores ZTP if (req.originalUrl.startsWith('/api/ztp/')) { const mac = req.get('X-ZTP-MAC') || req.body?.mac || req.query?.mac; console.error(` ZTP Error - MAC: ${mac || 'unknown'}, Route: ${req.originalUrl}`); } // Log especial para errores de shares if (req.originalUrl.startsWith('/api/share/')) { console.error(` Share Error - Code: ${req.params.code || req.params.id || 'unknown'}`); } const isDevelopment = process.env.NODE_ENV === 'development'; const errorResponse = { error: 'Error interno del servidor', message: isDevelopment ? error.message : 'Internal server error', timestamp: new Date().toISOString() }; if (isDevelopment && error.stack) { errorResponse.stack = error.stack; } if (!res.headersSent) { res.status(error.status || 500).json(errorResponse); } else { console.warn('⚠️ Error ocurrió después de enviar headers'); } }); // ======================================== // 🔧 FUNCIONES HELPER INTEGRADAS CON SOCKET.IO // ======================================== const notifyCameraChange = (cameraId, status, ownerId, metadata = {}) => { notifyUsers([ownerId], { type: 'camera:status_changed', data: { cameraId, status, ...metadata, timestamp: new Date().toISOString() } }); notifyCamera(cameraId, 'status:changed', { status, ...metadata }); console.log(`Notificación de cambio de cámara: ${cameraId}, status: ${status}, owner: ${ownerId}`); return 1; }; const notifyNewRecording = (recording, ownerId) => { const sent = notifyUsers([ownerId], { type: 'recording:new', data: { recordingId: recording._id, cameraId: recording.cameraId, duration: recording.duration, timestamp: new Date().toISOString() } }); console.log(`Nueva grabación para owner: ${ownerId}`, recording); return sent; }; const sendUserNotification = (userId, notification) => { const sent = notifyUsers([userId], { ...notification, timestamp: new Date().toISOString() }); console.log(`Notificación para usuario ${userId}:`, notification); return sent; }; const broadcastToRoles = (roles, message) => { const sent = notifyByRole(roles, { ...message, timestamp: new Date().toISOString() }); console.log(`Broadcast a roles ${JSON.stringify(roles)}:`, message); return sent; }; // ✅ Función específica para notificaciones ZTP const notifyZtpEvent = (mac, event, data) => { console.log(`🔄 ZTP Event: ${event} para router ${mac}:`, data); broadcastToRoles(['admin'], { type: 'ztp:event', event, data: { mac, ...data, timestamp: new Date().toISOString() } }); return 1; }; // ✅ Hacer disponibles las funciones helper y authUtils app.locals.notifyCameraChange = notifyCameraChange; app.locals.notifyNewRecording = notifyNewRecording; app.locals.sendUserNotification = sendUserNotification; app.locals.broadcastToRoles = broadcastToRoles; app.locals.notifyZtpEvent = notifyZtpEvent; // ✅ Funciones Socket.IO disponibles globalmente app.locals.socketio = { notifyUsers, notifyByRole, notifyCamera, broadcastToAll, getConnectionStats, getUserConnectionCount, disconnectUser }; // ✅ Hacer disponible authUtils globalmente para controladores app.locals.authUtils = authUtils; if (typeof global !== 'undefined') { global.authUtils = authUtils; global.socketio = app.locals.socketio; } // ✅ Inyectar wsNotifier globalmente (compatibilidad legacy) try { const wsNotifier = require('./services/wsNotifier'); app.locals.wsNotifier = wsNotifier; if (typeof global !== 'undefined') { global.wsNotifier = wsNotifier; } console.log('✅ wsNotifier cargado y disponible en app.locals y global'); } catch (err) { console.warn('⚠️ wsNotifier no disponible, usando Socket.IO:', err.message); app.locals.wsNotifier = { notifyUser: (userId, data) => notifyUsers([userId], data), notifyAdmins: (data) => notifyByRole(['admin'], data), notifyRoom: (room, data) => notifyCamera(room, 'notification', data) }; } // ======================================== // 🧹 TAREA DE LIMPIEZA AUTOMÁTICA // ======================================== let cleanupInterval = null; const initializeCleanupTask = () => { if (process.env.NODE_ENV === 'production') { try { const { BatchBlock } = require('./models/BatchBlock'); if (BatchBlock && typeof BatchBlock.cleanExpired === 'function') { cleanupInterval = setInterval(function () { (async () => { try { const cleaned = await BatchBlock.cleanExpired(); if (cleaned > 0) { console.log(`🧹 Limpieza automática: ${cleaned} bloqueos expirados desactivados`); } } catch (err) { console.error('❌ Error en limpieza automática:', err); } })(); }, 60 * 60 * 1000); console.log('✅ Tarea de limpieza automática iniciada (cada hora)'); } else { console.warn('⚠️ BatchBlock.cleanExpired no disponible - tarea de limpieza deshabilitada'); } } catch (error) { console.warn('⚠️ Modelo BatchBlock no encontrado - tarea de limpieza deshabilitada:', error.message); } } else { console.log('📋 Tarea de limpieza automática deshabilitada en desarrollo'); } }; // Importar el servicio de email real const { testEmailConfiguration } = require('./utils/emailService'); // ======================================== // 🚀 INICIALIZACIÓN CON ASYNC/AWAIT // ======================================== let server = null; let io = null; (async () => { try { // ⚙️ Verificar estado de MongoDB y conectar si es necesario if (mongoose.connection.readyState !== 1) { await mongoose.connect(); console.log('✅ MongoDB conectado en inicialización'); console.log('📊 Base de datos: ' + mongoose.connection.name); console.log('🔗 Host: ' + mongoose.connection.host + ':' + mongoose.connection.port); } else { console.log('✅ MongoDB ya conectado exitosamente'); console.log('📊 Base de datos: ' + mongoose.connection.name); console.log('🔗 Host: ' + mongoose.connection.host + ':' + mongoose.connection.port); } // ✅ Eventos de MongoDB con logs mejorados mongoose.connection.once('open', () => { console.log('✅ MongoDB conectado'); // Iniciar auto-limpieza de tokens startAutoCleanup(); // 🤖 Deploy Engine — lifecycle watcher const { startDeployEngine } = require('./services/deployEngine'); startDeployEngine(); }); mongoose.connection.on('connected', () => { console.log('🔗 Mongoose conectado a MongoDB'); }); mongoose.connection.on('error', (error) => { console.error('❌ Error en conexión MongoDB: ' + error); }); mongoose.connection.on('disconnected', () => { console.log('🔌 Mongoose desconectado de MongoDB'); }); // ⚙️ Crear servidor HTTP server = http.createServer(app); // ======================================== // 🔌 CONFIGURACIÓN SOCKET.IO CON LÍMITES DE CONEXIÓN // ======================================== io = initializeSocketIO(server); console.log('✅ Socket.IO inicializado con límites de conexión en path /ws'); // ✅ Hacer disponible io globalmente app.locals.io = io; if (typeof global !== 'undefined') { global.io = io; } // ✅ Inicializar tarea de limpieza automática después de conectar a MongoDB initializeCleanupTask(); // ⚙️ Iniciar servidor const PORT = process.env.PORT || 3001; server.listen(PORT, '0.0.0.0', () => { console.log('\n🚀 ================================'); console.log('🚀 Servidor ParkingCan v1.0.9 + Socket.IO + Shares + HLS (systemd)'); console.log('🚀 Puerto: ' + PORT); console.log('🚀 Entorno: ' + (process.env.NODE_ENV || 'development')); console.log('🚀 HTTP: http://localhost:' + PORT); console.log('🔌 Socket.IO: ' + (process.env.WS_PUBLIC_URL || 'ws://localhost:' + PORT + '/ws')); console.log('🌐 Producción: https://api.parkingcan.es'); console.log('🔌 Socket.IO Prod: wss://gestion.parkingcan.es/ws'); console.log('🚀 ================================\n'); console.log('🔌 Socket.IO Características (path: /ws):'); console.log(' ✅ Límites de conexión por plan:'); console.log(' • FREE: 2 sesiones (1 móvil + 1 navegador)'); console.log(' • PREMIUM: ∞ sesiones ilimitadas (sin restricciones)'); console.log(' ✅ Detección automática de dispositivo'); console.log(' ✅ Rooms por usuario y rol'); console.log(' ✅ Notificaciones en tiempo real'); console.log(' ✅ Estadísticas de conexión'); console.log(' ✅ API REST para gestión'); console.log(' ✅ Path unificado: /ws\n'); console.log('📋 Resumen de rutas cargadas:'); console.log(' ✅ /api/auth - Autenticación con JWT'); console.log(' ✅ /api/sessions - Gestión de sesiones'); console.log(' ✅ /api/activation - Activación y verificación'); console.log(' ✅ /api/cameras - Gestión de cámaras (unificado)'); console.log(' ✅ /api/carousel - Carrusel publicitario (con auth)'); console.log(' ✅ /api/logo - Gestión de logos (con auth)'); console.log(' ✅ /api/users - Gestión de usuarios'); console.log(' ✅ /api/notifications - Sistema de notificaciones'); console.log(' ✅ /api/ws-api - API Socket.IO con límites'); console.log(' ✅ /api/components - Componentes (con alias /api/user/components)'); console.log(' ✅ /api/recordings - Grabaciones'); console.log(' ✅ /api/stream - Streaming (systemd managed)'); console.log(' ✅ /api/share - Shares con QR y JWT'); console.log(' ✅ /api/health - Estado del servidor'); console.log(' ✅ /api/vpn - Gestión VPN'); console.log(' ✅ /s/:code - Shortlinks de shares'); console.log(' 🔌 Socket.IO Server configurado'); console.log(' 🤖 /api/ztp - Zero Touch Provisioning\n'); console.log('🎯 Sistema de Shares:'); console.log(' ✅ Generación de QR códigos'); console.log(' ✅ Tokens JWT con expiración configurable'); console.log(' ✅ Hasta 5 franjas horarias por cámara'); console.log(' ✅ Cookies secure_link para nginx'); console.log(' ✅ Integración con HLS protegido'); console.log(' ✅ Control de concurrencia opcional\n'); console.log('🎬 Arquitectura de Streaming:'); console.log(' ✅ Backend: Orquestador SOLO (sin procesos)'); console.log(' ✅ ffmpeg: Gestionado por systemd'); console.log(' ✅ Usuario: pcgw'); console.log(' ✅ HLS Dir: /var/www/streams'); console.log(' ✅ Túnel RTSP: localhost:810X'); console.log(' ✅ Restart automático por systemd\n'); console.log('🤖 Rutas ZTP disponibles:'); console.log(' 📋 POST /api/ztp/identify - Identificación inicial'); console.log(' 🎉 POST /api/ztp/activate - Activación del router'); console.log(' 💓 POST /api/ztp/heartbeat - Heartbeat periódico'); console.log(' 🔄 GET /api/ztp/sync - Sincronización'); console.log(' 🔐 POST /api/ztp/register-ssh - Registro de clave SSH'); console.log(' 🔑 GET /api/ztp/authorized_keys - Claves autorizadas'); console.log(' 📊 POST /api/ztp/status - Reporte de estado'); console.log(' 📷 GET /api/ztp/cameras/config - Config de cámaras\n'); const configWarnings = []; if (!process.env.JWT_SECRET) { configWarnings.push('JWT_SECRET no configurado - autenticación no funcionará'); } if (!process.env.STREAM_SECURE_SECRET) { configWarnings.push('STREAM_SECURE_SECRET no configurado - HLS no funcionará correctamente'); } if (!process.env.SHARE_JWT_SECRET) { configWarnings.push('SHARE_JWT_SECRET no configurado - usando valor por defecto inseguro'); } if (!process.env.MONGODB_URI) { configWarnings.push('MONGODB_URI no configurado - usando MongoDB local'); } if (configWarnings.length > 0) { console.log('⚠️ ADVERTENCIAS DE CONFIGURACIÓN:'); configWarnings.forEach(warning => console.log(' - ' + warning)); console.log(''); } try { testEmailConfiguration().then(result => { if (result.success) { console.log('✅ Configuración de email verificada'); } else { console.warn('⚠️ Configuración de email: ' + result.error); } }); } catch (emailError) { console.warn('⚠️ Error verificando configuración de email:', emailError.message); } if (authUtils && authUtils.PLAN_TYPES) { console.log('✅ AuthUtils cargado con ' + Object.keys(authUtils.PLAN_TYPES).length + ' tipos de plan'); } else { console.warn('⚠️ AuthUtils no cargado correctamente'); } console.log('🔐 Configuración JWT:'); console.log(' ✅ JWT_SECRET configurado: ' + (process.env.JWT_SECRET ? 'SÍ (longitud: ' + process.env.JWT_SECRET.length + ')' : 'NO')); console.log(' ✅ JWT_ISSUER: ' + process.env.JWT_ISSUER); console.log(' ✅ SHARE_JWT_SECRET: ' + (process.env.SHARE_JWT_SECRET ? 'Configurado' : 'Usando default')); console.log(' ✅ STREAM_SECURE_SECRET: ' + (process.env.STREAM_SECURE_SECRET ? 'Configurado' : 'NO CONFIGURADO')); if (process.env.PM2_HOME) { console.log(' ⚠️ Detectado PM2 - Verifica que las variables de entorno estén disponibles:'); console.log(' pm2 show | grep -A5 "env variables"'); console.log(' o usa: pm2 env | grep JWT_SECRET'); } console.log('\n🎉 Servidor listo para recibir conexiones!\n'); const initialStats = getConnectionStats(); console.log('📊 Estadísticas iniciales Socket.IO:'); console.log(' Usuarios conectados: ' + initialStats.totalUsers); console.log(' Conexiones totales: ' + initialStats.totalConnections); console.log(' Rooms activas: ' + initialStats.rooms || 0); console.log(''); console.log('🧪 URLs de prueba:'); console.log(' Health: curl ' + (process.env.NODE_ENV === 'production' ? 'https://api.parkingcan.es' : 'http://localhost:' + PORT) + '/api/health'); console.log(' Auth: curl -X POST ' + (process.env.NODE_ENV === 'production' ? 'https://api.parkingcan.es' : 'http://localhost:' + PORT) + '/api/auth/login'); console.log(' Shares: curl -X POST ' + (process.env.NODE_ENV === 'production' ? 'https://api.parkingcan.es' : 'http://localhost:' + PORT) + '/api/share/create'); console.log(''); }); // ✅ Manejo graceful de cierre const gracefulShutdown = async (signal) => { console.log(`\n🛑 Recibida señal ${signal}. Cerrando servidor...`); // Limpiar intervalo de limpieza automática if (cleanupInterval) { clearInterval(cleanupInterval); console.log('🧹 Tarea de limpieza automática detenida'); } // Cerrar conexiones Socket.IO try { if (io) { broadcastToAll('server:shutdown', { message: 'El servidor se está reiniciando', timestamp: new Date().toISOString() }); io.close(() => { console.log('🔌 Socket.IO cerrado'); }); } } catch (ioCloseError) { console.warn('⚠️ Error cerrando Socket.IO:', ioCloseError.message); } // ✅ CORREGIDO: En arquitectura systemd, los streams persisten console.log('ℹ️ Streams HLS gestionados por systemd - persistirán tras reinicio'); server.close(async () => { console.log('🔌 Servidor HTTP cerrado'); try { await mongoose.connection.close(); console.log('📊 Conexión MongoDB cerrada'); process.exit(0); } catch (err) { console.error('❌ Error cerrando MongoDB:', err); process.exit(1); } }); // Forzar cierre después de 10 segundos si no se completa setTimeout(() => { console.error('❌ Forzando cierre del servidor'); process.exit(1); }, 10000); }; // ✅ Escuchar señales de cierre process.on('SIGTERM', () => gracefulShutdown('SIGTERM')); process.on('SIGINT', () => gracefulShutdown('SIGINT')); // ✅ Manejo de errores no capturados process.on('uncaughtException', (error) => { console.error('❌ Excepción no capturada:', error); if (process.env.NODE_ENV === 'production') { gracefulShutdown('uncaughtException'); } }); process.on('unhandledRejection', (reason, promise) => { console.error('❌ Promesa rechazada no manejada:', reason); console.error('En promesa:', promise); if (process.env.NODE_ENV === 'production') { gracefulShutdown('unhandledRejection'); } }); } catch (error) { console.error('❌ Error fatal iniciando el servidor: ' + error); console.error('Stack trace: ' + error.stack); process.exit(1); } })();