server.js
Rama main · réplica de lectura del repositorio original.
#!/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/<name>.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 <link>
// 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/<name>/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/<nome mai
// esistito>.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,
},
};