Files
ipwhitelist-app/server.js
T

172 lines
8.6 KiB
JavaScript

/**
* server.js — точка входа приложения.
*
* Отвечает только за:
* 1. Инициализацию Express и глобальных middleware (helmet, static, body-parser)
* 2. Инициализацию auth (initAuth)
* 3. Подключение роутеров: src/api/routes/ (API), ui/routes/ (UI)
* 4. Старт HTTP-сервера
*
* API-логика — в src/api/routes/
* UI-логика — в ui/routes/
* DB-запросы — в src/queries.js
* Auth/JWT — в src/auth.js
* Валидация — в src/validators.js
* Конфиг/хелперы — в src/config.js
*/
'use strict';
const express = require('express');
const cookieParser = require('cookie-parser');
const path = require('path');
const helmet = require('helmet');
require('dotenv').config();
const { checkConnection, ensureSchema } = require('./src/db');
const { initAuth } = require('./src/auth');
const q = require('./src/queries');
const { MOCK_USERS, APP_VERSION } = require('./src/config');
const { authLimiter } = require('./src/middleware/rateLimit');
const { createSessionMiddleware } = require('./src/middleware/session');
const { aggregateCIDRs } = require('./src/validators');
const { CSP_DIRECTIVES } = require('./src/middleware/csp');
const { createUiRouter } = require('./ui/index');
const app = express();
const PORT = process.env.PORT || 3000;
app.set('view engine', 'ejs');
app.set('views', path.join(__dirname, 'views'));
// Версия доступна во всех шаблонах как <%= appVersion %> без передачи вручную.
app.locals.appVersion = APP_VERSION;
// ── Безопасность HTTP-заголовков ───────────────────────────────────────────────
// helmet выставляет все security-заголовки одним вызовом, включая CSP.
// Директивы CSP вынесены в src/middleware/csp.js.
app.use(helmet({ contentSecurityPolicy: { directives: CSP_DIRECTIVES } }));
// За reverse-proxy (nginx/k8s ingress) — доверяем первому хопу для secure cookies.
if (process.env.NODE_ENV === 'production') app.set('trust proxy', 1);
app.use(express.static(path.join(__dirname, 'public')));
app.use(express.urlencoded({ extended: true, limit: '32kb' }));
app.use(cookieParser());
async function start() {
const auth = await initAuth();
// ── Сессии ────────────────────────────────────────────────────────────────
const { pool } = require('./src/db');
app.use(createSessionMiddleware(pool));
// ── Fail-fast в production ────────────────────────────────────────────────
// Если секреты не заданы — приложение в проде немедленно падает.
// Дефолтные значения — только для локальной разработки.
if (process.env.NODE_ENV === 'production') {
const DEFAULT_SESSION = 'dev-session-secret-change-me';
if (!process.env.SESSION_SECRET || process.env.SESSION_SECRET === DEFAULT_SESSION) {
console.error('FATAL: SESSION_SECRET не задан или равен дефолту в production!');
process.exit(1);
}
if (!process.env.DB_PASS) {
console.error('FATAL: DB_PASS не задан в production!');
process.exit(1);
}
}
// k8s liveness probe — простая проверка что сервер жив
app.get('/healthz', (req, res) => res.send('OK'));
// 🔧 DEBUG: показать IAM-данные из сессии (убрать после теста!)
app.get('/debug-session', async (req, res) => {
if (!req.session || !req.session.user) return res.json({ error: 'no session' });
const { token, idToken, ...safe } = req.session;
// Последняя запись аудита для проверки impersonated_by
let lastAudit = null;
try {
const { getAudit } = require('./src/queries');
const audit = await getAudit(null);
lastAudit = audit.slice(0, 3);
} catch(e) { lastAudit = 'error: ' + e.message; }
res.json({
user: req.session.user,
hasToken: !!token,
tokenPreview: token ? token.slice(0, 50) + '...' : null,
tokenPayload: token ? (() => { try { const p = JSON.parse(Buffer.from(token.split('.')[1], 'base64url').toString()); return { iss: p.iss, sub: p.sub, email: p.email, ClientID: p.ClientID, iat: p.iat }; } catch(e) { return 'decode error'; } })() : null,
lastAudit,
});
// JWKS endpoint для валидации токенов внешними сервисами (только в mock-режиме)
if (auth.jwksHandler) app.get('/.well-known/jwks.json', auth.jwksHandler);
// ── /export — публичный (ВРЕМЕННО без авторизации) ───────────────────────
// 🔮 КОГДА Keycloak SSO: заменить на app.use('/export', auth.bearerMiddleware, ...)
app.get('/export',
/* 🔮 auth.bearerMiddleware, */
async (req, res) => {
try {
const cidrs = await q.getExportCIDRs();
const aggregated = aggregateCIDRs(cidrs);
const text = aggregated.join('\n') + (aggregated.length ? '\n' : '');
res.set('Content-Type', 'text/plain; charset=utf-8');
const fname = (req.query.filename || 'white-list.txt').replace(/[^\w\-_. ]/g, '_');
const disp = req.query.view === '1' ? 'inline' : 'attachment';
res.set('Content-Disposition', disp + '; filename="' + fname + '"');
res.send(text);
} catch (e) {
console.error('[export] Error:', e);
res.status(500).send('Ошибка экспорта');
}
}
);
// ── OIDC-роуты (Keycloak) — активны ТОЛЬКО когда KC_CLIENT_ID задан ─────
// В mock-режиме (сейчас): заглушки, работает ui/routes/auth.js
// 🔮 В проде: задать KC_CLIENT_ID + KC_CLIENT_SECRET в .env
const { createRouter: createOidcRouter } = require('./src/routes/oidc');
app.use('/', createOidcRouter({ auth }));
// ── REST API v1 (Bearer JWT, без сессий/CSRF) ─────────────────────────────
const { createApiRouter } = require('./src/api/index');
app.use('/api/v1', createApiRouter({ auth, q }));
// ── UI-слой: SSR через EJS, данные из /api/v1/* ───────────────────────────
// Монтируется ПОСЛЕ /api/v1/ — не перехватывает API-запросы.
// Хранит Bearer token в сессии, рендерит те же views/*.ejs.
app.use('/', createUiRouter({ auth, MOCK_USERS, authLimiter }));
// ── Общий обработчик ошибок ───────────────────────────────────────────────
// eslint-disable-next-line no-unused-vars
app.use((err, req, res, next) => {
const ctx = {
url: req.method + ' ' + req.originalUrl,
user: (req.user && req.user.email) || 'none',
clientId: (req.user && req.user.clientId) || '-',
};
const detail = err instanceof Error
? (err.stack || err.message || String(err))
: JSON.stringify(err);
console.error('Unhandled error:', ctx, detail);
res.status(500).send('Ошибка [' + ctx.url + '] user=' + ctx.user + ' clientId=' + ctx.clientId + ' — ' + detail.slice(0, 500));
});
// ── Старт сервера ─────────────────────────────────────────────────────────────
checkConnection()
.then(() => { console.log('DB connected'); return ensureSchema().catch(e => console.error('[db] Schema error:', e.message)); })
.catch(e => console.error('DB not ready:', e.message));
return app;
}
// Запускаем сервер только если файл запущен напрямую (не через require)
if (require.main === module) {
start()
.then(a => a.listen(PORT, () => console.log('Server on port ' + PORT)))
.catch(e => { console.error('Startup error:', e); process.exit(1); });
}
module.exports = { start };