#!/usr/bin/env node 'use strict'; // ============================================================ // InfraNet Pro - Server Node.js // Avvio: node server.js // Apri: http://localhost:8421 // // Struttura: // server.js -> Express app, route CRUD progetti, route /api/poll // auth.js -> autenticazione, sessioni, gestione utenti // drivers/snmp.js -> driver SNMP v1/v2c/v3 // drivers/.js -> futuri driver (Cisco RESTCONF, Aruba Central...) // ============================================================ const express = require('express'); const path = require('path'); const auth = require('./auth'); const PORT = parseInt(process.env.PORT || '8421', 10); // Interfaccia di bind. Default 127.0.0.1 (solo loopback — non esposto in rete; vedi README). // In container si imposta HOST=0.0.0.0 e si pubblica la porta SOLO su 127.0.0.1 dell'host. const HOST = process.env.HOST || '127.0.0.1'; const ROOT = __dirname; // ---- Persistenza progetti (server/projects-store.js) ------------------------ const { PROJECTS_DIR, hardenTree, hardenFile, hardenBackup } = require('./server/projects-store'); // ============================================================ // Express // ============================================================ const app = express(); app.use(express.json({ limit: '20mb' })); // SEC-M3 (audit 2026-07-21): header di sicurezza di base su OGNI risposta. // - nosniff: niente MIME-sniffing (blocca alcuni XSS via content-type confuso). // - frame-ancestors 'none' + X-Frame-Options DENY: niente framing → anti-clickjacking. // - CSP: default-src 'self' con object-src/base-uri stretti. script/style tengono // 'unsafe-inline' PERCHÉ l'app usa handler ed elementi inline (ASSE B non chiuso) e // stili inline nei template. NIENTE 'unsafe-eval': c'era, «per librerie che compilano a // runtime», ma il frontend non ha dipendenze e l'unico `new Function` era codice morto // (rimosso). Lo guardano test/security-headers.test.js (la politica mandata davvero) e // le regole no-eval/no-new-func di eslint.config.js (il sorgente). Tutte // le risorse sono self-hosted (Font Awesome locale, nessun CDN) e le chiamate LLM sono // server-side → connect-src 'self'. img data:/blob: per bgImage base64 e SVG skin. const CSP = [ "default-src 'self'", "script-src 'self' 'unsafe-inline'", "style-src 'self' 'unsafe-inline'", "img-src 'self' data: blob:", "font-src 'self'", "connect-src 'self'", "object-src 'none'", "base-uri 'self'", "form-action 'self'", "frame-ancestors 'none'", ].join('; '); app.use((_, res, next) => { res.set('X-Content-Type-Options', 'nosniff'); res.set('X-Frame-Options', 'DENY'); res.set('Referrer-Policy', 'no-referrer'); res.set('Content-Security-Policy', CSP); next(); }); // Logging app.use((req, res, next) => { const ts = new Date().toTimeString().substring(0, 8); console.log(` [${ts}] ${req.method} ${req.path}`); next(); }); // no-cache sugli asset del frontend (HTML/CSS/JS/lib): il browser DEVE rivalidare // col server prima di riusare la copia in cache -> un file cambiato su disco viene // sempre riservito fresco (304 se invariato). Chiude la classe di bug "ho corretto // il CSS/HTML ma il browser mostra ancora la versione vecchia dalla cache" (modali // tagliati, reskin...). I woff2 di Font Awesome NON sono qui -> restano cacheabili. app.use((req, res, next) => { if (req.method === 'GET' && ( req.path === '/' || req.path === '/export.js' || req.path.startsWith('/styles/') || req.path.startsWith('/dist/') || req.path.startsWith('/lib/') )) res.set('Cache-Control', 'no-cache'); next(); }); // no-store su OGNI risposta /api/*: i JSON dell'API portano dati del progetto, // config mascherate, l'identità dell'utente (/api/auth/me). Vietarne del tutto la // conservazione (cache del browser, disco, proxy condiviso) evita che restino // leggibili dopo il logout o su una macchina condivisa. Vale per ogni metodo, non // solo GET; gli asset del frontend (sopra) restano rivalidabili con no-cache. app.use((req, res, next) => { if (req.path.startsWith('/api/')) res.set('Cache-Control', 'no-store'); next(); }); // ---- Frontend statico ------------------------------------------------------- // CSS modularizzato (cartella styles/): partial ordinate, caricate via // in netmapper.html. Solo .css, niente traversal. app.get('/styles/:file', (req, res) => { if (!/^[a-zA-Z0-9_-]+\.css$/.test(req.params.file)) return res.status(404).end(); res.sendFile(path.join(ROOT, 'styles', req.params.file)); }); // (route storica /app.js RIMOSSA: il monolite pre-ESM non esiste piu al root, il // frontend carica /dist/app.bundle.js; una GET /app.js cadeva su un sendFile ENOENT // che serviva la pagina d'errore HTML di Express col path assoluto del server nel // body. Ora cade nel 404 catch-all JSON pulito, senza info-disclosure.) app.get('/export.js', (_, res) => res.sendFile(path.join(ROOT, 'export.js'))); // Bundle esbuild del frontend (cartella dist/): solo .js + .map, niente traversal. app.get('/dist/:file', (req, res) => { if (!/^[a-zA-Z0-9_.-]+\.(js|map)$/.test(req.params.file)) return res.status(404).end(); res.sendFile(path.join(ROOT, 'dist', req.params.file)); }); // Moduli condivisi browser/test (cartella lib/): solo file .js, niente traversal. app.get('/lib/:file', (req, res) => { if (!/^[a-zA-Z0-9_-]+\.js$/.test(req.params.file)) return res.status(404).end(); res.sendFile(path.join(ROOT, 'lib', req.params.file)); }); // Font Awesome 6 servito localmente da vendor/fontawesome (self-hosted, no CDN, no dep npm). // Esposto solo: css/all.min.css + webfonts fa-*.woff2. Nessun traversal. const FA_ROOT = path.join(ROOT, 'vendor', 'fontawesome'); app.get('/vendor/fontawesome/css/all.min.css', (_, res) => res.sendFile(path.join(FA_ROOT, 'css', 'all.min.css'))); app.get('/vendor/fontawesome/webfonts/:file', (req, res) => { if (!/^fa-[a-z0-9]+-\d+\.woff2$/i.test(req.params.file)) return res.status(404).end(); res.sendFile(path.join(FA_ROOT, 'webfonts', req.params.file)); }); // ---- REST API v1 (server/routes/api-v1.js) --------------------------------- // Read-only, gate a TOKEN Bearer. Montata PRIMA dell'auth di sessione: la // superficie esterna NON passa dal requireAuth globale, si autentica solo col // token e restituisce esclusivamente dati sanitizzati (lib/api-shape.js). app.use(require('./server/routes/api-v1')); // ---- Auth (sessioni + login page + route /api/auth/*) ---------------------- // Deve essere registrato PRIMA delle route protette. auth.register(app); // ---- Gestione token API (server/routes/api-tokens.js) ---------------------- // Solo admin (sessione). Mintare/revocare i token usati dall'API v1 qui sopra. app.use(require('./server/routes/api-tokens')); // ---- Frontend protetto ------------------------------------------------------ app.get('/', (_, res) => res.sendFile(path.join(ROOT, 'netmapper.html'))); // ---- Progetti (server/routes/projects.js) ----------------------------------- app.use(require('./server/routes/projects')); // ---- Organizzazione multi-sede (server/routes/organization.js) -------------- // Il livello SOPRA i progetti: sedi, uplink WAN e collegamenti inter-sede. // UNA per installazione (data/organization.json). app.use(require('./server/routes/organization')); // ---- Storico progetto (server/routes/history.js) ---------------------------- app.use(require('./server/routes/history')); // ---- Skin store (server/routes/skins.js) ------------------------------------ app.use(require('./server/routes/skins')); // ---- Catalogo device-type (server/routes/device-types.js) ------------------- app.use(require('./server/routes/device-types')); // ---- Assistente AI (server/routes/ai.js) ------------------------------------ // Config (GET mascherato / PUT admin) + preview del contesto sanitizzato. // Session-gated (montata dopo auth.register). La chiave resta server-side. app.use(require('./server/routes/ai')); // ---- Sincronizzazione DCIM/IPAM (server/routes/integrations.js) ------------- // Config (GET mascherato / PUT admin) + prova connessione + capabilities. // Session-gated (dopo auth.register). Il token DCIM resta server-side; l'IMPORT // arriva in Fase B, l'EXPORT vive nel modulo a pagamento modules/dcim-export/. app.use(require('./server/routes/integrations')); // ---- Primitive di rete / discovery (server/netscan.js) ---------------------- const { _execFileAsync, _pingHost, _normMac, _parseArpTable, _readArpMap, _readLocalInterfaceMap, _vendorByMac, _extractTitle, _httpProbe, _tcpProbe, _deepScanHost, _parseNetbiosOutput, _netbiosProbe, _parseNetViewOutput, _smbSharesProbe, _deepIdentityScanHost } = require('./server/netscan'); // ---- Classificazione device / discovery meta (server/classify.js) ---------- const { _cleanHostname, _penFromObjectId, _vendorByObjectId, _decodeSysServices, _resolveSysObject, _resolveOsFingerprint, _classifyDiscoveredDevice, _buildDiscoveryMeta, _decorateDiscoveryRow } = require('./server/classify'); // ---- Discovery: poll/discover/topology/crawl (server/routes/discovery.js) --- app.use(require('./server/routes/discovery')); // ---- Export PDF (server/routes/export.js) ----------------------------------- app.use(require('./server/routes/export')); // ---- Moduli a pagamento (modules//server) — plugin generico ----------- // La cartella modules/ e' gitignored: presente solo nei deployment con moduli // licenziati. Ogni modulo esporta function(app, ctx) e monta le sue route // (auth.requireAdmin dove serve); via ctx dichiara una voce di menu e registra // la pulizia dei propri sidecar alla cancellazione di un progetto. Il core resta // ignaro del contenuto (contratto feature-agnostico, non legato ad alcun modulo). const moduleRegistry = require('./server/module-registry'); app.get('/api/modules', (_, res) => res.json(moduleRegistry.getNav())); moduleRegistry.loadModules(app, { auth, // sessione + requireAdmin (in-process, nativi) projectsDir: PROJECTS_DIR, // dove vivono i dati progetto: i moduli ci mettono i propri sidecar registerNav: moduleRegistry.registerNav, onProjectDelete: moduleRegistry.registerProjectDeleteHook, }, path.join(ROOT, 'modules')); // ---- 404 catch-all ---------------------------------------------------------- app.use((req, res) => res.status(404).json({ error: 'Not found' })); // ---- Error handler (JSON, niente stack in chiaro) --------------------------- // Cattura gli errori del body-parser (JSON malformato -> 400, payload >20mb -> // 413) e QUALUNQUE throw sincrono nelle route. Senza questo middleware a 4 // argomenti Express usava il proprio finalhandler di default: pagina HTML con lo // STACK e i path assoluti del server (info-disclosure) invece del JSON atteso // dai consumer dell'API. La firma DEVE avere 4 parametri per essere riconosciuta // come error-handler; `next` viene usato nel ramo headersSent. // // ⚠️ Sotto il 500 si rimandava `err.message` COM'E'. Per un errore del filesystem // quel messaggio porta il PATH ASSOLUTO del server: una GET su /styles/.css passava la regex del nome, cadeva nel sendFile, e rispondeva // 404 con "ENOENT: ... stat 'C:\Users\...\InfranetPro\styles\x.css'" — misurato su // /styles, /dist e /lib. Il 404 catch-all piu' sopra non li vede: un errore // scavalca i middleware normali e arriva solo qui. I codici fs non dicono niente // di utile a chi chiama: si risponde con la forma pulita e il dettaglio resta nel log. const FS_ERRNO = new Set(['ENOENT', 'EACCES', 'EPERM', 'EISDIR', 'ENOTDIR', 'ELOOP', 'ENAMETOOLONG']); app.use((err, req, res, next) => { const status = err.status || err.statusCode || 500; if (status >= 500) console.error(` [ERR] ${req.method} ${req.path}: ${err.message}`); if (res.headersSent) return next(err); // risposta gia' iniziata -> delega a Express const daFs = FS_ERRNO.has(err && err.code); if (daFs && status < 500) console.warn(` [404] ${req.method} ${req.path}: ${err.code}`); const msg = status >= 500 ? 'Internal server error' : daFs ? (status === 404 ? 'Not found' : 'Bad request') : (err.message || 'Bad request'); res.status(status).json({ error: msg }); }); // ---- Start ------------------------------------------------------------------ // Avvia il listen SOLO se eseguito direttamente (node server.js). // Quando il file viene richiesto da un test (require), NON apre la porta. if (require.main === module) { // Retrofit dei permessi: i file scritti prima della regola «le credenziali sono 0600» hanno la // maschera di default e li leggerebbe ogni utente del sistema. Una volta per avvio, solo qui (non // al require: i test puntano le cartelle altrove), best-effort e idempotente. try { const n = hardenTree(PROJECTS_DIR); for (const f of [auth.USERS_FILE, require('./server/api-tokens').TOKENS_FILE]) { hardenFile(f); hardenBackup(f); } if (n) console.log(` [SICUREZZA] permessi dei dati ristretti a 0600: ${n} file`); } catch (e) { console.warn(' [SICUREZZA] retrofit permessi non riuscito:', e.message); } const server = app.listen(PORT, HOST, () => { const shownHost = (HOST === '0.0.0.0' || HOST === '::') ? 'localhost' : HOST; console.log(''); console.log(' ================================================'); console.log(` InfraNet Pro -> http://${shownHost}:${PORT}`); console.log(` Progetti in: ${PROJECTS_DIR}`); console.log(' Premi Ctrl+C per fermare il server'); console.log(' ================================================'); console.log(''); }); server.on('error', err => { if (err.code === 'EADDRINUSE') { console.error(`\n [ERRORE] La porta ${PORT} è già occupata.`); console.error(' Chiudi l\'altra istanza del server (finestra PowerShell o Node.js) e riprova.\n'); } else { console.error(`\n [ERRORE] ${err.message}\n`); } process.exit(1); }); } // ---- Gestori errori globali - evitano crash silenti ----------------------- process.on('uncaughtException', err => { console.error(`\n [FATAL] Eccezione non catturata: ${err.message}`); console.error(err.stack); // App locale: logga l'errore ma non chiudere il server durante workflow lunghi // come discovery/SNMP. Le route principali rispondono gia con errori JSON. }); process.on('unhandledRejection', reason => { console.error(`\n [WARNING] Promise rifiutata non gestita: ${reason}`); }); // Funzioni pure di discovery esposte SOLO per i test di regressione. // Additivo: nessun effetto sul comportamento runtime. module.exports = { app, _internals: { _parseNetbiosOutput, _parseNetViewOutput, _cleanHostname, _penFromObjectId, _vendorByObjectId, _decodeSysServices, _resolveSysObject, _resolveOsFingerprint, _classifyDiscoveredDevice, _buildDiscoveryMeta, }, };