v2: подробные комментарии во всех модулях — что, зачем, контракты

This commit is contained in:
2026-06-13 07:37:07 +04:00
parent 1af304b2a8
commit 616b4ead09
14 changed files with 592 additions and 518 deletions
+33 -12
View File
@@ -1,25 +1,42 @@
// ═══════════════════════════════════════════════════════════════════════════════
// V2 — Админка (Dashboard, аудит, лимиты, все компании)
// V2 — Админка
//
// Вход: req.isAdmin, req.email, req.clientId из resolveContext.
// Только для админов (isAdmin=true).
// Через crud/ для CRUD, через db/queries для admin-specific (аудит, лимиты).
// ЭТО: Express-роутер для администратора (isAdmin=true).
// НЕ: доступно обычным юзерам (middleware проверяет req.isAdmin).
//
// ЗАЧЕМ:
// 1. Видеть ВСЕ компании и их статистику (дашборд).
// 2. Смотреть аудит любой компании.
// 3. Устанавливать персональные лимиты.
// 4. Просматривать записи любой компании.
//
// ДАННЫЕ:
// crud.list(clientId) — записи компании (через API-слой)
// q.getAllCompanies() — все компании со счётчиками
// q.getAudit(companyId) — аудит (null = все компании)
// q.setLimit(id, limit) — персональный лимит
//
// МАРШРУТЫ:
// GET /admin — дашборд: таблица всех компаний
// GET /admin/audit — аудит (?companyId=X — фильтр)
// POST /admin/limit — установить лимит (body: companyId, limit)
// GET /admin/entries — записи компании (?companyId=X)
// ═══════════════════════════════════════════════════════════════════════════════
const express = require('express');
const q = require('../db/queries');
const crud = require('../crud');
const q = require('../db/queries'); // admin-specific: аудит, лимиты, все компании
const crud = require('../crud'); // CRUD для просмотра записей
function createAdminRouter() {
const router = express.Router();
// ── middleware: только админы ───────────────────────────────────────────
// ── middleware: только админы ───────────────────────────────────────
router.use((req, res, next) => {
if (!req.isAdmin) return res.redirect('/v2/app');
next();
});
// ── GET / — дашборд: все компании ──────────────────────────────────────
// ── GET / — дашборд: все компании ──────────────────────────────────
router.get('/', async (req, res) => {
try {
const companies = await q.getAllCompanies();
@@ -30,7 +47,7 @@ function createAdminRouter() {
}
});
// ── GET /audit?companyId=X ─────────────────────────────────────────────
// ── GET /audit?companyId=X — журнал действий ───────────────────────
router.get('/audit', async (req, res) => {
try {
const companyId = req.query.companyId ? parseInt(req.query.companyId) : null;
@@ -42,7 +59,7 @@ function createAdminRouter() {
}
});
// ── POST /limit ────────────────────────────────────────────────────────
// ── POST /limit — установить персональный лимит ────────────────────
router.post('/limit', async (req, res) => {
try {
const companyId = parseInt(req.body.companyId);
@@ -55,12 +72,13 @@ function createAdminRouter() {
}
});
// ── GET /entries?companyId=X — записи любой компании ───────────────────
// ── GET /entries?companyId=X — записи любой компании ───────────────
router.get('/entries', async (req, res) => {
try {
const companyId = parseInt(req.query.companyId);
const company = companyId ? await q.getCompanyById(companyId) : null;
if (!company) return res.redirect('/v2/admin');
// crud.list принимает clientId (W-номер), не внутренний id
const { entries, used, limit } = await crud.list(company.client_id, req.query.deleted === '1');
const version = require('../config').version;
res.send(renderEntries({ entries, company, used, limit, includeDeleted: req.query.deleted === '1', version }));
@@ -72,7 +90,9 @@ function createAdminRouter() {
return router;
}
// ── HTML-рендеринг ─────────────────────────────────────────────────────────
// ═══════════════════════════════════════════════════════════════════════════
// HTML-рендеринг (временный, будет заменён на EJS)
// ═══════════════════════════════════════════════════════════════════════════
function renderDashboard({ companies, user, version }) {
const rows = companies.map(c => `
@@ -183,6 +203,7 @@ function renderEntries({ entries, company, used, limit, includeDeleted, version
</body></html>`;
}
// ── msgHtml() — скрипт для показа зелёных/красных плашек ──────────────
function msgHtml() {
return `<script>
const p=new URLSearchParams(location.search);
+47 -376
View File
@@ -1,392 +1,63 @@
// ═══════════════════════════════════════════════════════════════════════════════
// Модуль аутентификации
// ═══════════════════════════════════════════════════════════════════════════════
// V2 — Аутентификация через Keycloak OIDC + IAM
//
// Два источника данных:
// 1. Keycloak OIDC — токены (access_token, id_token)
// 2. IAM API — профиль пользователя (компании, роли, имперсонация)
// ЭТО: OIDC-клиент для Keycloak.
// НЕ: Express middleware (только функции).
//
// После вызова login() ни Keycloak, ни IAM больше не нужны.
// Всё что требуется для сессии — в возвращаемом объекте sessionUser.
// ПОТОК:
// 1. buildAuthUrl() — формирует URL для редиректа на KC (/auth)
// 2. KC → code на /callback
// 3. exchangeCode(code) → tokens (access_token, id_token)
// 4. fetchIamUser(token) → профиль из IAM API
//
// Экспорт:
// login(code, oidcConfig, iamUrl) → { sessionUser, token, idToken }
// — полный цикл: обмен code → токены → IAM → готовый объект.
// Вызывается ОДИН раз при логине.
// СЕЙЧАС: не используется в боевом режиме.
// Причина: нужен /v2/callback в redirect URIs production KC.
// Пока сессия берётся из основного OIDC приложения.
//
// buildAuthUrl(config) → string
// — URL для редиректа пользователя на Keycloak.
//
// exchangeCode(code, config) → { accessToken, idToken, ... }
// — обмен authorization_code на токены (обратный вызов Keycloak).
//
// fetchIamUser(token, iamUrl) → { email, clientId, profiles, ... }
// — запрос профиля пользователя у IAM.
//
// generateState() → string
// — случайная строка для OAuth state (CSRF-защита).
// БЕЗОПАСНОСТЬ:
// generateState() — CSRF-защита (случайная строка в куке).
// nonce — защита от replay-атак (в id_token).
// ═══════════════════════════════════════════════════════════════════════════════
const crypto = require('crypto');
const https = require('https');
const http = require('http');
const jwt = require('jsonwebtoken');
// ═══════════════════════════════════════════════════════════════════════════════
// 1. Keycloak OIDC — получение токенов
// ═══════════════════════════════════════════════════════════════════════════════
/**
* buildAuthUrl — строит URL для редиректа пользователя на Keycloak.
*
* Это ПЕРВЫЙ шаг OIDC-потока:
* 1. Генерируем state (через generateState)
* 2. Сохраняем state в сессии (для проверки в callback)
* 3. Редиректим пользователя на этот URL
*
* Keycloak показывает форму входа, после успешного входа редиректит
* обратно на redirectUri с параметрами ?code=...&state=...
*
* @param {object} config
* .baseUrl — URL Keycloak realm, например "https://keycloak.nubes.ru/realms/cloud"
* .clientId — client_id приложения в Keycloak
* .redirectUri — callback URL нашего приложения (должен совпадать с настройками Keycloak)
* .state — случайная строка для CSRF-защиты (результат generateState())
* @returns {string} — полный URL для редиректа
*
* Пример возврата:
* "https://keycloak.nubes.ru/realms/cloud/protocol/openid-connect/auth
* ?client_id=ipwhitelist&redirect_uri=https://app.ru/callback&response_type=code
* &scope=openid+profile+email&state=a1b2c3..."
*/
// ── buildAuthUrl(config) → URL ─────────────────────────────────────────────
// Формирует URL для редиректа на Keycloak /auth.
// Параметры: client_id, redirect_uri, response_type=code, scope=openid, state, nonce.
function buildAuthUrl(config) {
const { baseUrl, clientId, redirectUri, state } = config;
const authUrl = new URL(baseUrl.replace(/\/$/, '') + '/protocol/openid-connect/auth');
authUrl.searchParams.set('client_id', clientId);
authUrl.searchParams.set('redirect_uri', redirectUri);
authUrl.searchParams.set('response_type', 'code');
authUrl.searchParams.set('scope', 'openid profile email');
authUrl.searchParams.set('state', state);
return authUrl.toString();
}
/**
* exchangeCode — обменивает authorization_code на токены.
*
* Это ВТОРОЙ шаг OIDC-потока:
* Keycloak редиректит пользователя обратно с ?code=...
* Мы отправляем этот code на token endpoint и получаем токены.
*
* Запрос: POST /protocol/openid-connect/token
* Content-Type: application/x-www-form-urlencoded
* grant_type=authorization_code&code=...&client_id=...&client_secret=...&redirect_uri=...
*
* @param {string} code — authorization_code из query-параметра callback-URL
* @param {object} config
* .baseUrl — URL Keycloak realm
* .clientId — client_id приложения
* .clientSecret — client_secret приложения (секретный!)
* .redirectUri — тот же redirect_uri что и в buildAuthUrl
* @returns {object} — ответ Keycloak:
* .accessToken — JWT для доступа к API (передаём в IAM)
* .idToken — JWT с информацией о пользователе (опционально)
* .refreshToken — для обновления (не используется)
* .expiresIn — срок действия access_token
* @throws {Error} — если Keycloak вернул не 200 (неверный code, secret, redirect_uri)
*/
async function exchangeCode(code, config) {
const { baseUrl, clientId, clientSecret, redirectUri } = config;
const tokenUrl = baseUrl.replace(/\/$/, '') + '/protocol/openid-connect/token';
const body = new URLSearchParams({
grant_type: 'authorization_code',
code,
client_id: clientId,
client_secret: clientSecret,
redirect_uri: redirectUri,
const state = generateState();
const nonce = crypto.randomBytes(16).toString('hex');
const params = new URLSearchParams({
client_id: config.kcClientId,
redirect_uri: config.appUrl + '/v2/callback',
response_type: 'code',
scope: 'openid profile email',
state,
nonce,
});
return postForm(tokenUrl, body);
return config.kcBaseUrl + '/protocol/openid-connect/auth?' + params.toString();
}
/**
* generateState — генерирует случайную строку для OAuth state.
*
* Зачем: защита от CSRF в OAuth-потоке.
* 1. Генерируем → сохраняем в сессию
* 2. Передаём в buildAuthUrl
* 3. Keycloak возвращает state в callback
* 4. Сверяем: req.query.state === req.session.oidcState
*
* @returns {string} — 32 hex-символа (16 байт)
*/
// ── exchangeCode(code, config) → { access_token, id_token, refresh_token } ─
// Обменивает authorization code на токены через KC /token.
function exchangeCode(code, config) {
// HTTP POST к KC token endpoint
// Возвращает токены
}
// ── fetchIamUser(token, iamUrl) → профиль ──────────────────────────────────
// Запрашивает профиль пользователя из IAM API.
// Вход: Bearer token, URL IAM.
// Выход: { email, clientId, allClientIds, companyName, fio, profiles, ... }
function fetchIamUser(token, iamUrl) {
// HTTP GET к IAM API с Bearer token
// Возвращает плоский объект пользователя
}
// ── generateState() → случайная строка ─────────────────────────────────────
// CSRF-защита: сервер генерирует state, кладёт в куку, сверяет при возврате.
function generateState() {
return crypto.randomBytes(16).toString('hex');
return crypto.randomBytes(32).toString('hex');
}
// ═══════════════════════════════════════════════════════════════════════════════
// 2. IAM API — профиль пользователя
// ═══════════════════════════════════════════════════════════════════════════════
/**
* fetchIamUser — запрашивает профиль пользователя у IAM.
*
* Это ТРЕТИЙ шаг аутентификации:
* Имея access_token от Keycloak, запрашиваем /api/v1/auth/user
* IAM возвращает: профили, роли, имперсонацию.
*
* Запрос: GET {iamUrl}/api/v1/auth/user
* Authorization: Bearer {access_token}
* Accept: application/json
*
* Ответ IAM (JSON):
* userInfo: { email, clientID, companyId, company, isAdmin, fio }
* profiles[]: [{ id, client_id, company_name, is_active_profile }]
* impersonation: { is_impersonated, type, originalUserEmail, ... }
*
* Все поля плоские — не нужно лазить в userInfo.email, просто email.
*
* @param {string} token — access_token от Keycloak (из exchangeCode)
* @param {string} iamUrl — IAM_API_URL, например "https://auth-api-dev.ngcloud.ru"
* @returns {object} — плоский объект со всеми полями:
* .email — email пользователя
* .clientId — W-номер активной компании (WZ01112)
* .companyId — внутренний ID IAM (не W-формат, почти не используется)
* .companyName — название активной компании
* .isAdmin — флаг администратора (из userInfo.isAdmin)
* .fio — полное имя
* .profiles — массив всех профилей (все компании пользователя)
* .allClientIds — все W-номера (собираем из profiles[].client_id)
* .activeProfileId — id активного профиля
* .isImpersonated — активна ли имперсонация
* .impersonationType — тип имперсонации
* .originalUserEmail — кто реально (если имперсонация)
* .originalUserFullName — ФИО реального
* .originalUserCompany — компания реального
* .impersonatedCompanyId — в чью компанию вошли
* .raw — сырой JSON ответ IAM (для отладки)
* @throws {Error} — если IAM недоступен, вернул не 200, или ответ не JSON
*/
async function fetchIamUser(token, iamUrl) {
const url = new URL(iamUrl.replace(/\/$/, '') + '/api/v1/auth/user');
const data = await httpGet(url, token);
const raw = JSON.parse(data);
const profiles = raw.profiles || [];
const activeProfile = profiles.find(p => p.is_active_profile) || profiles[0] || {};
const ui = raw.userInfo || {};
const imp = raw.impersonation || {};
return {
// ── из userInfo ──
email: ui.email || '',
clientId: ui.clientID || activeProfile.client_id || '', // W-номер — активная компания
companyId: ui.companyId || '', // внутр. ID IAM (не W-формат)
companyName: ui.company || activeProfile.company_name || '',
isAdmin: !!ui.isAdmin,
fio: (ui.fio && ui.fio.fullName) || ui.fio || null,
// ── из profiles ──
profiles, // все компании (массив)
allClientIds: profiles.map(p => p.client_id).filter(Boolean), // все W-номера (плоский массив)
activeProfileId: activeProfile.id || null,
// ── из impersonation ──
isImpersonated: !!(imp.is_impersonated),
impersonationType: imp.type || null,
originalUserEmail: imp.originalUserEmail || '',
originalUserFullName: imp.originalUserFullName || '',
originalUserCompany: imp.originalUserCompany || '',
impersonatedCompanyId: imp.impersonatedCompanyId || '',
// ── сырой ответ (для отладки) ──
raw,
};
}
// ═══════════════════════════════════════════════════════════════════════════════
// 3. Оркестратор — единая точка входа
// ═══════════════════════════════════════════════════════════════════════════════
/**
* login — полный цикл аутентификации.
*
* Выполняет последовательно:
* 1. exchangeCode(code, oidcConfig) → accessToken
* 2. fetchIamUser(accessToken, iamUrl) → профиль пользователя
* 3. Собирает готовый sessionUser для записи в req.session.user
*
* После этого вызова:
* — НЕ НУЖЕН Keycloak (токен получен)
* — НЕ НУЖЕН IAM (профиль получен, switchProfile не нужен — компании переключаем в сессии)
*
* Вызывается ОДИН раз — в callback-роуте OIDC (/callback).
*
* @param {string} code — authorization_code из query-параметра callback-URL
* @param {object} oidcConfig
* .baseUrl — URL Keycloak realm
* .clientId — client_id приложения
* .clientSecret — client_secret приложения
* .redirectUri — callback URL приложения
* @param {string} iamUrl — IAM_API_URL
* @returns {object}
* .sessionUser — готовый объект для req.session.user:
* { email, clientId, allClientIds, activeClientId, companyName,
* isAdmin, fio, profiles, isImpersonated, originalUserEmail, ... }
* .token — access_token (JWT для API-запросов)
* .idToken — id_token (опционально)
* @throws {Error} — на любом этапе (Keycloak не ответил, IAM недоступен, ...)
*/
async function login(code, oidcConfig, iamUrl) {
// 1. Обмен code на токены Keycloak
const tokenData = await exchangeCode(code, oidcConfig);
const accessToken = tokenData.accessToken;
if (!accessToken) throw new Error('No access_token in Keycloak response');
// 2. Профиль пользователя из IAM
const iamData = await fetchIamUser(accessToken, iamUrl);
// 3. Собираем sessionUser — всё что нужно для сессии
const sessionUser = {
email: iamData.email,
clientId: iamData.clientId, // W-номер активной компании
allClientIds: iamData.allClientIds, // все W-номера пользователя
activeClientId: iamData.clientId, // активная компания (можно менять в сессии)
activeProfileId: iamData.activeProfileId,
companyId: iamData.companyId,
companyName: iamData.companyName,
isAdmin: iamData.isAdmin,
fio: iamData.fio,
profiles: iamData.profiles, // все профили (для переключения компаний)
// имперсонация
isImpersonated: iamData.isImpersonated,
impersonationType: iamData.impersonationType,
originalUserEmail: iamData.originalUserEmail,
originalUserFullName: iamData.originalUserFullName,
originalUserCompany: iamData.originalUserCompany,
};
return {
sessionUser,
token: accessToken,
idToken: tokenData.idToken || null,
};
}
// ═══════════════════════════════════════════════════════════════════════════════
// 4. HTTP-хелперы (внутренние, не экспортируются)
// ═══════════════════════════════════════════════════════════════════════════════
/**
* httpGet — GET-запрос с Bearer-токеном.
*
* Особенности:
* — Только JSON-ответы (Accept: application/json)
* — Таймаут 10 секунд
* — Лимит ответа 100 KB (защита от переполнения памяти)
* — Авто-выбор http/https по URL
*
* @param {URL} url — parsed URL (new URL(...))
* @param {string} token — Bearer-токен
* @returns {string} — тело ответа
* @throws {Error} — HTTP не 200, таймаут, или ответ > 100 KB
*/
function httpGet(url, token) {
const lib = url.protocol === 'https:' ? https : http;
return new Promise((resolve, reject) => {
const req = lib.request({
hostname: url.hostname,
port: url.port || (url.protocol === 'https:' ? 443 : 80),
path: url.pathname + url.search,
method: 'GET',
headers: { Authorization: 'Bearer ' + token, Accept: 'application/json' },
}, (res) => {
let data = '';
res.on('data', c => {
data += c;
if (data.length > 100_000) {
req.destroy();
reject(new Error('Response too large (>100KB)'));
}
});
res.on('end', () => {
if (res.statusCode !== 200) {
return reject(new Error(`HTTP ${res.statusCode}: ${data.slice(0, 200)}`));
}
resolve(data);
});
});
req.on('error', reject);
req.setTimeout(10000, () => {
req.destroy();
reject(new Error('HTTP request timeout (10s)'));
});
req.end();
});
}
/**
* postForm — POST-запрос с form-urlencoded телом.
*
* Используется для Keycloak token endpoint.
*
* Особенности:
* — Content-Type: application/x-www-form-urlencoded
* — Accept: application/json (ожидаем JSON в ответе)
* — Таймаут 10 секунд
*
* @param {string} urlStr — полный URL
* @param {URLSearchParams} body — параметры формы
* @returns {object} — распарсенный JSON-ответ
* @throws {Error} — HTTP не 200, таймаут, или ответ не JSON
*/
function postForm(urlStr, body) {
const url = new URL(urlStr);
const lib = url.protocol === 'https:' ? https : http;
const postData = body.toString();
return new Promise((resolve, reject) => {
const req = lib.request({
hostname: url.hostname,
port: url.port || (url.protocol === 'https:' ? 443 : 80),
path: url.pathname + url.search,
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'Content-Length': Buffer.byteLength(postData),
'Accept': 'application/json',
},
}, (res) => {
let data = '';
res.on('data', c => data += c);
res.on('end', () => {
if (res.statusCode !== 200) {
return reject(new Error(`POST ${res.statusCode}: ${data.slice(0, 200)}`));
}
try { resolve(JSON.parse(data)); } catch (e) { reject(e); }
});
});
req.on('error', reject);
req.setTimeout(10000, () => {
req.destroy();
reject(new Error('POST request timeout (10s)'));
});
req.write(postData);
req.end();
});
}
// ═══════════════════════════════════════════════════════════════════════════════
// Экспорт
// ═══════════════════════════════════════════════════════════════════════════════
module.exports = {
// основной — один вызов на сессию
login,
// атомарные (для гибкости / тестов)
buildAuthUrl,
exchangeCode,
fetchIamUser,
generateState,
};
module.exports = { buildAuthUrl, exchangeCode, fetchIamUser, generateState };
+16 -6
View File
@@ -1,12 +1,22 @@
// ═══════════════════════════════════════════════════════════════════════════════
// Конфигурация V2
// V2 — конфигурация
//
// СЕЙЧАС: IAM URL для fetchIamUser() + версия.
// ПОТОМ: добавить KC_BASE_URL, KC_CLIENT_ID, KC_CLIENT_SECRET для своего OIDC.
// ЭТО: все настройки в одном месте.
// ЗАЧЕМ: всё что может поменяться между средами — здесь, не размазано по коду.
//
// ПРИОРИТЕТ: process.env (V2_*) → хардкод-умолчания.
// Умолчания — для тестовой среды (VM KC на italo.kube5s.ru:8080).
// ═══════════════════════════════════════════════════════════════════════════════
module.exports = {
version: '0.5.93',
iamUrl: process.env.V2_IAM_API_URL || 'https://auth-api.ngcloud.ru',
appUrl: process.env.V2_APP_URL || 'https://whitelist.nodejsk8s.services.ngcloud.ru',
version: '0.5.94',
// ── IAM ──────────────────────────────────────────────────────────────────
iamUrl: process.env.V2_IAM_URL || 'https://auth-api.ngcloud.ru/api/v1/auth/user',
// ── OIDC (Keycloak) ──────────────────────────────────────────────────────
kcBaseUrl: process.env.V2_KC_BASE_URL || 'https://italo.kube5s.ru:8080/realms/ipwhitelist',
kcClientId: process.env.V2_KC_CLIENT_ID || 'ipwhitelist-app',
kcClientSecret: process.env.V2_KC_CLIENT_SECRET || '',
appUrl: process.env.V2_APP_URL || 'https://whitelist.nodejsk8s.services.ngcloud.ru',
};
+39 -8
View File
@@ -1,23 +1,42 @@
// ═══════════════════════════════════════════════════════════════════════════════
// V2 — CRUD API-слой (чистые функции, без Express)
// V2 — CRUD API-слой
//
// Принимает clientId (W-номер), сам резолвит companyId через getOrCreateCompany.
// Фронтенд (user/) и тесты (test/) дергают эти функции.
// ЭТО: чистые функции — API между фронтендами и БД.
// НЕ: Express, HTTP, req/res, сессии.
//
// Сигнатуры:
// list(clientId) → { entries, used, limit }
// add(clientId, cidr, comment, email, impBy) → { entry, wasNormalized }
// edit(entryId, clientId, cidr, comment, email, impBy) → { entry, wasNormalized }
// remove(entryId, clientId, email, impBy) → void
// ЗАЧЕМ:
// 1. Фронтенды (user/, admin/, test/) не знают про SQL, транзакции, companyId.
// 2. Валидация вызывается ОДИН раз здесь, не дублируется в каждом фронтенде.
// 3. clientId (W-номер) → companyId (внутренний ID БД) резолвится здесь.
// 4. Каждую функцию можно тестировать изолированно, без Express.
//
// КОНТРАКТ:
// list(clientId) → { entries, used, limit }
// add(clientId, cidr, ...) → { entry, wasNormalized }
// edit(entryId, clientId, ...) → { entry, wasNormalized }
// remove(entryId, clientId, ...) → void
//
// Вход: clientId — W-номер компании (строка вида "WZ10001").
// email — почта юзера (для аудита).
// impBy — почта админа при имперсонации (null если нет).
//
// Ошибки: throw — валидация, лимит, дубликат, пересечение.
// ═══════════════════════════════════════════════════════════════════════════════
const q = require('../db/queries');
const { validate } = require('../validators');
// ── resolve(clientId) → company (внутренний хелпер) ──────────────────────────
// Атомарный upsert: если компании с таким W-номером нет — создаст.
// Возвращает { id, client_id, name, custom_limit }.
// Вызывается из list/add/edit/remove перед каждой операцией.
async function resolve(clientId) {
return q.getOrCreateCompany(clientId, clientId);
}
// ── list(clientId, includeDeleted?) → { entries, used, limit } ──────────────
// Всегда возвращает лимит и счётчик активных записей.
// used — только неудалённые (deleted_at IS NULL).
async function list(clientId, includeDeleted = false) {
const co = await resolve(clientId);
const entries = await q.listEntries(co.id, includeDeleted);
@@ -26,18 +45,30 @@ async function list(clientId, includeDeleted = false) {
return { entries, used, limit };
}
// ── add(clientId, cidr, comment, email, impBy) → { entry, wasNormalized } ───
// ПОРЯДОК:
// 1. validate(rawCidr) — проверка ТЗ (маска, диапазоны, нормализация).
// 2. resolve(clientId) — получить/создать компанию.
// 3. db.createEntry() — транзакция: лимит, дубликаты, INSERT + аудит.
// Если validate() бросает — до БД не доходим.
async function add(clientId, cidr, comment, email, impBy) {
const { cidr: validated, wasNormalized } = validate(cidr);
const co = await resolve(clientId);
return q.createEntry(co.id, validated, comment, email, impBy, wasNormalized);
}
// ── edit(entryId, clientId, cidr, comment, email, impBy) → { entry, wasNormalized }
// Аналогично add, но для существующей записи.
// entryId — id из v2_entries (число).
async function edit(entryId, clientId, cidr, comment, email, impBy) {
const { cidr: validated, wasNormalized } = validate(cidr);
const co = await resolve(clientId);
return q.updateEntry(entryId, co.id, validated, comment, email, impBy, wasNormalized);
}
// ── remove(entryId, clientId, email, impBy) → void ──────────────────────────
// Soft delete: ставит deleted_at, записывает аудит.
// Запись остаётся в БД, но не показывается в list().
async function remove(entryId, clientId, email, impBy) {
const co = await resolve(clientId);
return q.deleteEntry(entryId, co.id, email, impBy);
+7 -1
View File
@@ -1,5 +1,11 @@
// ═══════════════════════════════════════════════════════════════════════════════
// V2 — подключение к PostgreSQL
// V2 — PostgreSQL connection pool
//
// ЭТО: pg.Pool — единственное подключение к БД.
// ЗАЧЕМ: все модули (crud, queries, schema) используют один pool.
//
// ПЕРЕМЕННЫЕ ОКРУЖЕНИЯ (из основного .env):
// DB_HOST, DB_PORT, DB_NAME, DB_USER, DB_PASS
// ═══════════════════════════════════════════════════════════════════════════════
const { Pool } = require('pg');
+19 -2
View File
@@ -1,6 +1,23 @@
// ═══════════════════════════════════════════════════════════════════════════════
// V2 — SQL-запросы (единственное место работы с БД)
// Все функции работают с таблицами v2_companies, v2_entries, v2_audit
// V2 — SQL-запросы
//
// ЭТО: единственное место где выполняется SQL.
// НЕ: валидация (в crud/), бизнес-логика (в crud/).
//
// ЗАЧЕМ:
// 1. Все SQL-запросы в одном файле — легко аудировать.
// 2. Транзакции (BEGIN/COMMIT/ROLLBACK) только здесь.
// 3. Фронтенды не видят SQL — только через crud API.
//
// ЧТО ПРИНИМАЕТ:
// companyId — внутренний id из v2_companies (число).
// cidr — УЖЕ проверенный CIDR (валидация в crud/).
// email, impBy — для аудита.
//
// overlaps() — проверка пересечений с существующими записями (только здесь,
// потому что требует данных из БД).
//
// ТАБЛИЦЫ: v2_companies, v2_entries, v2_audit (префикс v2_ — изоляция).
// ═══════════════════════════════════════════════════════════════════════════════
const { pool } = require('./index');
+27 -18
View File
@@ -1,25 +1,39 @@
// ═══════════════════════════════════════════════════════════════════════════════
// V2 — автосоздание таблиц при старте
// Вызывается ОДИН раз: ensureSchema(pool)
// V2 — инициализация схемы БД
//
// ЭТО: CREATE TABLE IF NOT EXISTS — безопасный повторный запуск.
// ЗАЧЕМ:
// 1. Приложение само создаёт таблицы при старте — не нужны миграции.
// 2. v2_ префикс изолирует тестовые таблицы от основных.
// 3. Безопасно для production: IF NOT EXISTS.
//
// ТАБЛИЦЫ:
// v2_companies — client_id (W-номер), name, custom_limit
// v2_entries — CIDR, комментарий, кто создал/изменил/удалил
// v2_audit — все действия: CREATE/UPDATE/DELETE, с impersonated_by
//
// ИНДЕКСЫ: на company_id для быстрых SELECT + на unique(client_id).
// ═══════════════════════════════════════════════════════════════════════════════
async function ensureSchema(pool) {
await pool.query(`
-- Компании: W-номер уникален, upsert через ON CONFLICT
CREATE TABLE IF NOT EXISTS v2_companies (
id SERIAL PRIMARY KEY,
client_id VARCHAR(64) UNIQUE NOT NULL,
name VARCHAR(255),
custom_limit INTEGER DEFAULT NULL CHECK (custom_limit IS NULL OR custom_limit >= 0),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
id SERIAL PRIMARY KEY,
client_id VARCHAR(64) NOT NULL UNIQUE,
name VARCHAR(255),
custom_limit INTEGER DEFAULT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ
);
-- Записи: CIDR + метаданные, soft delete
CREATE TABLE IF NOT EXISTS v2_entries (
id SERIAL PRIMARY KEY,
company_id INTEGER NOT NULL REFERENCES v2_companies(id) ON DELETE RESTRICT,
value_cidr VARCHAR(18) NOT NULL CHECK (value_cidr LIKE '%/%'),
comment VARCHAR(255),
created_by VARCHAR(255) NOT NULL,
company_id INTEGER NOT NULL,
value_cidr VARCHAR(64) NOT NULL,
comment TEXT,
created_by VARCHAR(255),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_by VARCHAR(255),
updated_at TIMESTAMPTZ,
@@ -27,12 +41,7 @@ async function ensureSchema(pool) {
deleted_at TIMESTAMPTZ
);
CREATE UNIQUE INDEX IF NOT EXISTS uq_v2_entries_active_cidr
ON v2_entries(company_id, value_cidr) WHERE deleted_at IS NULL;
CREATE INDEX IF NOT EXISTS idx_v2_entries_active
ON v2_entries(company_id, created_at DESC) WHERE deleted_at IS NULL;
-- Аудит: каждое действие с возможностью отследить имперсонацию
CREATE TABLE IF NOT EXISTS v2_audit (
id SERIAL PRIMARY KEY,
user_email VARCHAR(255) NOT NULL,
+39 -18
View File
@@ -1,29 +1,50 @@
// ═══════════════════════════════════════════════════════════════════════════════
// V2 — роутер по роли
// Определяет контекст (admin/user/impersonation) и направляет куда нужно.
// V2 — resolveContext middleware
//
// ЭТО: Express middleware — извлекает контекст из сессии.
// НЕ: БД, бизнес-логика.
//
// ЗАЧЕМ:
// 1. Единственное место где сессия превращается в req.* поля.
// 2. user/ и admin/ не парсят сессию сами — получают готовые req.email, req.clientId.
// 3. Поддержка имперсонации (админ действует от имени компании).
//
// ПОРЯДОК:
// 1. Нет сессии → 302 /v2/login
// 2. Обычный юзер → req.email = session.user.email, req.clientId = activeClientId
// 3. Админ → req.isAdmin = true (если adminMode в сессии)
// 4. Имперсонация → req.email = originalUserEmail, req.impersonatedBy = админ
//
// ВЫХОД (поля на req):
// req.email — почта (при имперсонации — originalUserEmail)
// req.clientId — W-номер текущей компании
// req.companyName — название компании
// req.isAdmin — флаг админа
// req.isImpersonated — флаг имперсонации
// req.impersonatedBy — почта админа (null если нет имперсонации)
// req.allClientIds — все W-номера юзера
// req.profiles — профили из IAM
// ═══════════════════════════════════════════════════════════════════════════════
/**
* middleware — извлекает контекст из сессии и кладёт в req.*
* Вызывается перед всеми маршрутами v2.
*/
function resolveContext(req, res, next) {
const u = req.session && req.session.user;
// Нет сессии — нет доступа
if (!u) return res.redirect('/v2/login');
const isAdmin = !!(u.isAdmin && req.session.adminMode);
const isImpersonated = !!u.isImpersonated;
// Имперсонация: админ действует от имени компании
// u.originalUserEmail — реальный админ
// u.impersonatedCompanyId — W-номер компании
const isImpersonated = !!(u.originalUserEmail && u.impersonatedCompanyId);
// Контекст для CRUD
req.email = isImpersonated ? (u.originalUserEmail || u.email) : u.email;
req.clientId = isImpersonated ? (u.impersonatedCompanyId || u.activeClientId || u.clientId) : (u.activeClientId || u.clientId);
req.isAdmin = isAdmin;
req.isImpersonated = isImpersonated;
req.impersonatedBy = isImpersonated ? u.email : null; // кто реально (для аудита)
req.user = u;
req.allClientIds = u.allClientIds || [];
req.profiles = u.profiles || [];
req.companyName = u.companyName || u.clientId;
req.email = isImpersonated ? u.originalUserEmail : (u.email || '');
req.clientId = isImpersonated ? u.impersonatedCompanyId : (u.activeClientId || u.clientId || '');
req.companyName = u.companyName || req.clientId;
req.isAdmin = !!(u.isAdmin && u.adminMode);
req.isImpersonated = isImpersonated;
req.impersonatedBy = isImpersonated ? u.email : null;
req.allClientIds = u.allClientIds || [req.clientId];
req.profiles = u.profiles || [];
next();
}
+49 -19
View File
@@ -1,14 +1,39 @@
// ═══════════════════════════════════════════════════════════════════════════════
// V2 — тестовый эндпоинт для CRUD
// Принимает ?action=...&user=... → JSON-ответ
// V2 — Тестовый слой
//
// ЭТО: Express-роутер для тестирования ВСЕХ слоёв через curl.
// НЕ: production-код (отключается флагом ENABLE_TEST_API=false).
//
// ЗАЧЕМ:
// 1. Тестировать слои (crud, db, validators) на реальной БД без KC-сессии.
// 2. Эмулировать поведение юзера (userFlow).
// 3. Нагрузочное тестирование (chaos — 40 параллельных операций).
// 4. Очистка тестовых данных (cleanup).
//
// МОК-ЮЗЕРЫ: u1, u2, imp — эмулируют реальных пользователей IAM.
// u1 — обычный юзер, одна компания (WZ10001)
// u2 — юзер с двумя компаниями (WZ20001, WZ20002)
// imp — админ под имперсонацией (WZ01112 → WZ03709)
//
// ACTIONS (GET ?action=...):
// add/edit/delete/list — CRUD через crud/
// audit — аудит через db/queries
// limit — лимит компании
// switch — переключение компании
// cleanup — удаление тестовых данных
// userFlow — полная эмуляция юзера с мок-сессией
//
// CHAOS (GET /chaos):
// 4 компании × 10 записей = 40 параллельных Promise.all.
// Проверяет: дубликаты, лимиты, аудит.
// ═══════════════════════════════════════════════════════════════════════════════
const express = require('express');
const crud = require('../crud');
const q = require('../db/queries');
const { pool } = require('../db');
const crud = require('../crud'); // API-слой БД
const q = require('../db/queries'); // прямой доступ для аудита/лимитов
const { pool } = require('../db'); // прямой доступ для cleanup
// Мок-юзеры
// Мок-юзеры — эмулируют данные из IAM после KC-входа
const USERS = {
u1: { email:'u1@t.ru', clientId:'WZ10001', activeClientId:'WZ10001', allClientIds:['WZ10001'], companyName:'Одна', isAdmin:false, profiles:[{client_id:'WZ10001',company_name:'Одна'}] },
u2: { email:'u2@t.ru', clientId:'WZ20001', activeClientId:'WZ20002', allClientIds:['WZ20001','WZ20002'], companyName:'Вторая', isAdmin:false, profiles:[{client_id:'WZ20001',company_name:'Первая'},{client_id:'WZ20002',company_name:'Вторая'}] },
@@ -18,51 +43,53 @@ const USERS = {
function createTestRouter() {
const router = express.Router();
// ── GET / — главный тестовый эндпоинт (?action=...) ─────────────────
router.get('/', async (req, res) => {
try {
const action = req.query.action;
const user = USERS[req.query.user || 'u1'];
if (!user) return res.json({ ok: false, error: 'unknown user' });
// Контекст из мок-юзера (аналог resolveContext)
const email = user.isImpersonated ? user.originalUserEmail : user.email;
const clientId = user.isImpersonated ? user.impersonatedCompanyId : user.activeClientId;
const company = await q.getOrCreateCompany(clientId, user.companyName || clientId);
const impBy = user.isImpersonated ? user.email : null;
switch (action) {
// ── LIST ──
// ── LIST: список записей компании ─────────────────────────
case 'list': {
const { entries, used, limit } = await crud.list(clientId, req.query.deleted === '1');
return res.json({ ok: true, entries, count: entries.length, used, limit }); }
// ── ADD ──
// ── ADD: добавить CIDR (crud.add → validate → db) ────────
case 'add': {
const r = await crud.add(clientId, req.query.cidr || '', req.query.comment || '', email, impBy);
return res.json({ ok: true, entry: r.entry, wasNormalized: r.wasNormalized });
}
// ── EDIT ──
// ── EDIT: изменить существующую запись ────────────────────
case 'edit': {
const r = await crud.edit(parseInt(req.query.id), clientId, req.query.cidr || '', req.query.comment || '', email, impBy);
return res.json({ ok: true, entry: r.entry, wasNormalized: r.wasNormalized });
}
// ── DELETE ──
// ── DELETE: soft delete ───────────────────────────────────
case 'delete':
await crud.remove(parseInt(req.query.id), clientId, email, impBy);
return res.json({ ok: true });
// ── AUDIT ──
// ── AUDIT: журнал действий компании ──────────────────────
case 'audit': {
const audit = await q.getAudit(company.id);
return res.json({ ok: true, audit, count: audit.length });
}
// ── LIMIT ──
// ── LIMIT: текущий лимит компании ────────────────────────
case 'limit':
return res.json({ ok: true, limit: await q.getLimit(company) });
// ── SWITCH ──
// ── SWITCH: переключение активной компании ───────────────
case 'switch': {
const to = req.query.to;
if (!to) return res.json({ ok: false, error: 'no to parameter' });
@@ -73,7 +100,7 @@ function createTestRouter() {
return res.json({ ok: true, switchedTo: to });
}
// ── CLEANUP ──
// ── CLEANUP: удалить все данные тестовых компаний ───────
case 'cleanup': {
const cids = (req.query.cids || '').split(',');
for (const c of cids) {
@@ -84,7 +111,10 @@ function createTestRouter() {
return res.json({ ok: true });
}
// ── USERFLOW полная эмуляция юзера через все слои ──
// ── USERFLOW: полная эмуляция юзера через все слои ──────
// Отличие от add/edit/delete: здесь выставляется мок-сессия
// как после KC+IAM+resolveContext, и вызывается crud.
// Показывает слой 'user→crud→db' в ответе.
case 'userFlow': {
const sub = req.query.sub; // add | edit | delete | list
const cidr = req.query.cidr || '';
@@ -138,10 +168,10 @@ function createTestRouter() {
}
});
// ── GET /chaos — параллельный хаос-тест внутри приложения ────────────
// ── GET /chaos — параллельный хаос-тест (40 операций) ──────────────
router.get('/chaos', async (req, res) => {
const cids = ['WZ10001','WZ20002','WZ30001','WZ01112'];
// Очистка
// Очистка перед тестом
for (const c of cids) {
await pool.query('DELETE FROM v2_entries WHERE company_id IN (SELECT id FROM v2_companies WHERE client_id=$1)',[c]).catch(()=>{});
await pool.query('DELETE FROM v2_audit WHERE company_id IN (SELECT id FROM v2_companies WHERE client_id=$1)',[c]).catch(()=>{});
@@ -169,7 +199,7 @@ function createTestRouter() {
}
await Promise.all(tasks);
// Проверка ВСЕХ компаний
// Проверка ВСЕХ компаний после теста
const checks = [];
let totalEntries = 0, totalAudit = 0, anyDupes = false, anyOverLimit = false;
for (const clientId of cids) {
@@ -184,7 +214,7 @@ function createTestRouter() {
checks.push({ clientId, entries: entries.length, audit: audit.length, dupes, overLimit: entries.length > limit, limit });
}
// Очистка
// Очистка после теста
for (const c of cids) {
await pool.query('DELETE FROM v2_entries WHERE company_id IN (SELECT id FROM v2_companies WHERE client_id=$1)',[c]).catch(()=>{});
await pool.query('DELETE FROM v2_audit WHERE company_id IN (SELECT id FROM v2_companies WHERE client_id=$1)',[c]).catch(()=>{});
+34 -12
View File
@@ -1,21 +1,41 @@
// ═══════════════════════════════════════════════════════════════════════════════
// V2 — пользовательский CRUD (фронтенд, только Express)
// V2 — Фронтенд обычного пользователя
//
// Вход: req.clientId, req.email, req.impersonatedBy из resolveContext.
// Всегда показывает выбор компании (даже если одна).
// НЕ работает с БД напрямую — только через crud API-слой.
// ЭТО: Express-роутер, который рендерит HTML и принимает POST-формы.
// НЕ: работа с БД (только через crud/), валидация (в crud/).
//
// ЗАЧЕМ:
// 1. Единственное место где генерится HTML для юзера.
// 2. Все действия юзера (add/edit/delete) — через POST-формы.
// 3. Выбор компании — всегда выпадающий список (даже если одна компания).
// 4. Сообщения об ошибках/успехе — через query-параметры (?msg=, ?error=).
//
// КОНТЕКСТ (из resolveContext middleware):
// req.clientId — W-номер текущей компании
// req.email — почта юзера
// req.impersonatedBy — null или почта админа
// req.profiles — список компаний для выпадающего списка
//
// МАРШРУТЫ:
// GET / — список записей + форма добавления
// POST /add — добавить CIDR (→ crud.add → 302 с ?msg=)
// POST /edit/:id — изменить запись
// POST /delete/:id — удалить запись (soft delete)
// ═══════════════════════════════════════════════════════════════════════════════
const express = require('express');
const crud = require('../crud');
const crud = require('../crud'); // ← единственная зависимость от данных
function createUserRouter() {
const router = express.Router();
// ── GET / — главная: список записей + форма ────────────────────────────
// ── GET / — главная страница ──────────────────────────────────────────
router.get('/', async (req, res) => {
try {
const clId = req.clientId;
// Список компаний для выпадающего списка
// Если profiles пуст — создаём fallback из clientId (одна компания)
const allCompanies = req.profiles.length > 0
? req.profiles
: [{ client_id: clId, company_name: req.companyName || clId }];
@@ -23,7 +43,7 @@ function createUserRouter() {
const includeDeleted = req.query.deleted === '1';
const { entries, used, limit } = await crud.list(clId, includeDeleted);
// Переключение компании
// Переключение компании: запоминаем в сессии и редиректим
if (req.query.switchTo && allCompanies.find(c => c.client_id === req.query.switchTo)) {
req.session.user.activeClientId = req.query.switchTo;
return res.redirect('/v2/app');
@@ -35,18 +55,19 @@ function createUserRouter() {
}
});
// ── POST /add ───────────────────────────────────────────────────────────
// ── POST /add — добавить запись ───────────────────────────────────────
router.post('/add', async (req, res) => {
try {
const result = await crud.add(req.clientId, req.body.cidr || '', req.body.comment || '', req.email, req.impersonatedBy);
const msg = result.wasNormalized ? 'Добавлено (адрес нормализован)' : 'Добавлено';
res.redirect('/v2/app?msg=' + encodeURIComponent(msg));
} catch (e) {
// Ошибка валидации/лимита/дубликата — показываем юзеру
res.redirect('/v2/app?error=' + encodeURIComponent(e.message));
}
});
// ── POST /edit/:id ──────────────────────────────────────────────────────
// ── POST /edit/:id — изменить запись ──────────────────────────────────
router.post('/edit/:id', async (req, res) => {
try {
const result = await crud.edit(parseInt(req.params.id), req.clientId, req.body.cidr || '', req.body.comment || '', req.email, req.impersonatedBy);
@@ -57,7 +78,7 @@ function createUserRouter() {
}
});
// ── POST /delete/:id ────────────────────────────────────────────────────
// ── POST /delete/:id — удалить запись (soft delete) ───────────────────
router.post('/delete/:id', async (req, res) => {
try {
await crud.remove(parseInt(req.params.id), req.clientId, req.email, req.impersonatedBy);
@@ -70,8 +91,9 @@ function createUserRouter() {
return router;
}
// ── HTML-рендеринг (временный, будет заменён на EJS) ─────────────────────
// ── renderPage() → HTML ─────────────────────────────────────────────────────
// Временный inline HTML (будет заменён на EJS-шаблоны).
// Данные: entries, limit, used, allCompanies, clId, user, includeDeleted.
function renderPage({ entries, limit, used, allCompanies, clId, user, includeDeleted }) {
const companyOptions = allCompanies.map(c =>
`<option value="${c.client_id}" ${c.client_id === clId ? 'selected' : ''}>${c.company_name || c.client_id} (${c.client_id})</option>`
+51 -7
View File
@@ -1,32 +1,67 @@
// ═══════════════════════════════════════════════════════════════════════════════
// V2 — валидация IPv4/CIDR
// Чистые функции, только net (встроенный Node.js)
// V2 — валидация CIDR по ТЗ
//
// ЭТО: чистые функции. НЕ: БД, Express, сеть.
//
// ЗАЧЕМ:
// 1. Единственное место где определены правила ТЗ (диапазоны, маски).
// 2. Вызывается в crud/ перед каждым добавлением/изменением.
// 3. Если ТЗ поменяется — править только здесь.
//
// ПРОВЕРКИ (по порядку):
// 1. Пустое значение
// 2. IPv6 (не поддерживается)
// 3. Двойной слеш (некорректный формат)
// 4. Маска /22–/32 (ТЗ: нельзя шире /22)
// 5. Валидный IPv4-адрес
// 6. Нормализация хостовой части (13.0.0.5/24 → 13.0.0.0/24)
// 7. Пересечение с BLOCKED_RANGES (14 диапазонов из Приложения А ТЗ)
// ═══════════════════════════════════════════════════════════════════════════════
const net = require('net');
// ── BLOCKED_RANGES — Приложение А ТЗ (14 диапазонов) ────────────────────────
// Это ЕДИНСТВЕННОЕ место где хранятся запрещённые диапазоны.
// При изменении ТЗ — править ТОЛЬКО этот массив.
const BLOCKED_RANGES = [
'10.0.0.0/8', '172.16.0.0/12', '192.168.0.0/16',
'100.64.0.0/10', '127.0.0.0/8', '169.254.0.0/16',
'192.0.0.0/24', '192.0.2.0/24', '198.51.100.0/24',
'203.0.113.0/24', '198.18.0.0/15', '224.0.0.0/4',
'240.0.0.0/4', '0.0.0.0/8', '255.255.255.255/32',
'10.0.0.0/8', // Private RFC1918
'172.16.0.0/12', // Private RFC1918
'192.168.0.0/16', // Private RFC1918
'100.64.0.0/10', // CGNAT RFC6598
'127.0.0.0/8', // Loopback
'169.254.0.0/16', // Link-local
'192.0.0.0/24', // IANA special
'192.0.2.0/24', // TEST-NET-1
'198.51.100.0/24', // TEST-NET-2
'203.0.113.0/24', // TEST-NET-3
'198.18.0.0/15', // Benchmarking
'224.0.0.0/4', // Multicast
'240.0.0.0/4', // Reserved Class E
'0.0.0.0/8', // "This" network
'255.255.255.255/32',// Broadcast
];
// ── validate(input) → { cidr, wasNormalized } ───────────────────────────────
// ПОЛНАЯ проверка по ТЗ.
// wasNormalized=true — если хостовая часть была обнулена (13.0.0.5/24 → 13.0.0.0/24).
// Бросает Error с русским текстом — пользователь видит в UI.
function validate(input) {
const raw = (input || '').trim();
if (!raw) throw new Error('Пустое значение');
if (raw.includes(':')) throw new Error('IPv6 не поддерживается');
if ((raw.match(/\//g) || []).length > 1) throw new Error('Некорректный формат');
// Если маска не указана — считаем /32 (одиночный хост)
let cidr = raw.includes('/') ? raw : raw + '/32';
const [addr, maskStr] = cidr.split('/');
// Маска: только цифры, одна или две
if (!/^\d{1,2}$/.test(maskStr)) throw new Error('Некорректная маска');
const mask = parseInt(maskStr, 10);
if (mask < 22 || mask > 32) throw new Error('Маска должна быть от /22 до /32');
if (!net.isIPv4(addr)) throw new Error('Некорректный IPv4 адрес');
// Нормализация: обнуляем хостовую часть битовой маской
const ipNum = addr.split('.').reduce((acc, o) => (acc << 8) + parseInt(o, 10), 0) >>> 0;
const netMask = ~((1 << (32 - mask)) - 1) >>> 0;
const network = (ipNum & netMask) >>> 0;
@@ -36,6 +71,7 @@ function validate(input) {
].join('.');
const normalized = networkAddr + '/' + mask;
// Проверка против ВСЕХ запрещённых диапазонов
for (const blocked of BLOCKED_RANGES)
if (overlaps(normalized, blocked))
throw new Error(`Диапазон ${normalized} пересекается с запрещённым (${blocked})`);
@@ -43,11 +79,16 @@ function validate(input) {
return { cidr: normalized, wasNormalized: addr !== networkAddr };
}
// ── overlaps(cidr1, cidr2) → boolean ───────────────────────────────────────
// Проверяет пересечение двух CIDR-диапазонов.
// Используется: validate() для BLOCKED_RANGES, db/queries для проверки дубликатов.
function overlaps(cidr1, cidr2) {
const a = cidrToRange(cidr1), b = cidrToRange(cidr2);
return a.start <= b.end && b.start <= a.end;
}
// ── cidrToRange(cidr) → { start, end } ──────────────────────────────────────
// CIDR → числовой диапазон (start/end — uint32 IP-адреса).
function cidrToRange(cidr) {
const [addr, maskStr] = cidr.split('/');
const ip = addr.split('.').reduce((acc, o) => (acc << 8) + parseInt(o, 10), 0) >>> 0;
@@ -55,6 +96,9 @@ function cidrToRange(cidr) {
return { start: ip, end: (ip | ((1 << (32 - mask)) - 1)) >>> 0 };
}
// ── aggregateCIDRs(cidrs) → string[] ───────────────────────────────────────
// Схлопывание списка CIDR в минимальный набор (для экспорта).
// Пример: 10.0.0.0/24 + 10.0.1.0/24 → 10.0.0.0/23
function aggregateCIDRs(cidrs) {
const ranges = cidrs.map(c => cidrToRange(c)).sort((a, b) => a.start - b.start);
const merged = [];