v2: CRUD-модуль — /v2/app с выбором компании, добавление/изменение/удаление записей
This commit is contained in:
@@ -0,0 +1,216 @@
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// V2 — CRUD-роутер (только для авторизованных)
|
||||
//
|
||||
// Вход: sessionUser из v2_ сессии (email, clientId, allClientIds, profiles, ...)
|
||||
// Всегда показывает выбор компании (даже если одна).
|
||||
// Переключение — смена activeClientId в сессии (без IAM).
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
const express = require('express');
|
||||
const q = require('../db/queries');
|
||||
const { validate } = require('../validators');
|
||||
|
||||
function createCrudRouter() {
|
||||
const router = express.Router();
|
||||
|
||||
// ── middleware: только для залогиненных ─────────────────────────────────
|
||||
router.use((req, res, next) => {
|
||||
if (!req.session.v2_user) return res.redirect('/v2/login');
|
||||
next();
|
||||
});
|
||||
|
||||
// ── helpers ──────────────────────────────────────────────────────────────
|
||||
function user(req) { return req.session.v2_user; }
|
||||
function token(req) { return req.session.v2_token; }
|
||||
|
||||
// activeClientId — текущая выбранная компания
|
||||
function activeClientId(req) {
|
||||
return req.session.v2_user.activeClientId || req.session.v2_user.clientId;
|
||||
}
|
||||
|
||||
// Список компаний для выпадающего списка
|
||||
function companies(req) {
|
||||
const u = user(req);
|
||||
if (u.profiles && u.profiles.length > 0) return u.profiles;
|
||||
// fallback: одна компания из clientId
|
||||
return [{ client_id: u.clientId, company_name: u.companyName || u.clientId, is_active_profile: true }];
|
||||
}
|
||||
|
||||
// ── GET /v2/app — главная страница ──────────────────────────────────────
|
||||
router.get('/', async (req, res) => {
|
||||
try {
|
||||
const clId = activeClientId(req);
|
||||
const allCompanies = companies(req);
|
||||
const company = await q.getOrCreateCompany(clId, clId);
|
||||
|
||||
const includeDeleted = req.query.deleted === '1';
|
||||
const entries = await q.listEntries(company.id, includeDeleted);
|
||||
const limit = await q.getLimit(company);
|
||||
const used = entries.filter(e => !e.deleted_at).length;
|
||||
|
||||
const switchTo = req.query.switchTo;
|
||||
if (switchTo && allCompanies.find(c => c.client_id === switchTo)) {
|
||||
req.session.v2_user.activeClientId = switchTo;
|
||||
return res.redirect('/v2/app');
|
||||
}
|
||||
|
||||
res.send(renderApp({ entries, company, limit, used, allCompanies, clId, user: user(req), includeDeleted }));
|
||||
} catch (e) {
|
||||
res.status(500).send('<h2>Ошибка</h2><pre>' + e.message + '</pre><a href="/v2/app">Назад</a>');
|
||||
}
|
||||
});
|
||||
|
||||
// ── POST /v2/app/add ────────────────────────────────────────────────────
|
||||
router.post('/add', async (req, res) => {
|
||||
try {
|
||||
const clId = activeClientId(req);
|
||||
const company = await q.getOrCreateCompany(clId, clId);
|
||||
const u = user(req);
|
||||
|
||||
const result = await q.createEntry(
|
||||
company.id,
|
||||
req.body.cidr || '',
|
||||
req.body.comment || '',
|
||||
u.email,
|
||||
u.originalUserEmail || null // impersonated_by
|
||||
);
|
||||
|
||||
const msg = result.wasNormalized ? 'Добавлено (адрес нормализован)' : 'Добавлено';
|
||||
res.redirect('/v2/app?msg=' + encodeURIComponent(msg));
|
||||
} catch (e) {
|
||||
res.redirect('/v2/app?error=' + encodeURIComponent(e.message));
|
||||
}
|
||||
});
|
||||
|
||||
// ── POST /v2/app/edit/:id ───────────────────────────────────────────────
|
||||
router.post('/edit/:id', async (req, res) => {
|
||||
try {
|
||||
const clId = activeClientId(req);
|
||||
const company = await q.getOrCreateCompany(clId, clId);
|
||||
const u = user(req);
|
||||
|
||||
const result = await q.updateEntry(
|
||||
parseInt(req.params.id),
|
||||
company.id,
|
||||
req.body.cidr || '',
|
||||
req.body.comment || '',
|
||||
u.email,
|
||||
u.originalUserEmail || null
|
||||
);
|
||||
|
||||
const msg = result.wasNormalized ? 'Изменено (адрес нормализован)' : 'Изменено';
|
||||
res.redirect('/v2/app?msg=' + encodeURIComponent(msg));
|
||||
} catch (e) {
|
||||
res.redirect('/v2/app?error=' + encodeURIComponent(e.message));
|
||||
}
|
||||
});
|
||||
|
||||
// ── POST /v2/app/delete/:id ─────────────────────────────────────────────
|
||||
router.post('/delete/:id', async (req, res) => {
|
||||
try {
|
||||
const clId = activeClientId(req);
|
||||
const company = await q.getOrCreateCompany(clId, clId);
|
||||
const u = user(req);
|
||||
|
||||
await q.deleteEntry(
|
||||
parseInt(req.params.id),
|
||||
company.id,
|
||||
u.email,
|
||||
u.originalUserEmail || null
|
||||
);
|
||||
|
||||
res.redirect('/v2/app?msg=' + encodeURIComponent('Удалено'));
|
||||
} catch (e) {
|
||||
res.redirect('/v2/app?error=' + encodeURIComponent(e.message));
|
||||
}
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
|
||||
// ── HTML-рендеринг (временный, без EJS) ─────────────────────────────────────
|
||||
|
||||
function renderApp({ entries, company, limit, used, allCompanies, clId, user, includeDeleted }) {
|
||||
const msg = (s) => s ? `<div style="background:#4caf50;color:#fff;padding:8px;margin:8px 0">${s}</div>` : '';
|
||||
const err = (s) => s ? `<div style="background:#f44336;color:#fff;padding:8px;margin:8px 0">${s}</div>` : '';
|
||||
|
||||
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>`
|
||||
).join('');
|
||||
|
||||
const rows = entries.map(e => {
|
||||
const del = e.deleted_at;
|
||||
const style = del ? 'text-decoration:line-through;opacity:0.5' : '';
|
||||
const actions = del ? '<span style="color:#888">удалено</span>' : `
|
||||
<form method="POST" action="/v2/app/edit/${e.id}" style="display:inline">
|
||||
<input name="cidr" value="${e.value_cidr}" size="18">
|
||||
<input name="comment" value="${e.comment || ''}" size="20">
|
||||
<button>изменить</button>
|
||||
</form>
|
||||
<form method="POST" action="/v2/app/delete/${e.id}" style="display:inline">
|
||||
<button onclick="return confirm('Удалить?')">удалить</button>
|
||||
</form>`;
|
||||
return `<tr style="${style}">
|
||||
<td>${e.value_cidr}</td><td>${e.comment || ''}</td><td>${e.created_by}</td>
|
||||
<td>${new Date(e.created_at).toLocaleString('ru')}</td>
|
||||
<td>${actions}</td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
|
||||
return `<!DOCTYPE html>
|
||||
<html><head><meta charset="utf-8"><title>V2 — IP WhiteList</title>
|
||||
<style>
|
||||
body { font-family: sans-serif; max-width:960px; margin:20px auto; color:#222; }
|
||||
table { width:100%; border-collapse:collapse; margin:10px 0; }
|
||||
th,td { border:1px solid #ccc; padding:6px; text-align:left; }
|
||||
th { background:#f5f5f5; }
|
||||
.bar { display:flex; justify-content:space-between; align-items:center; background:#eee; padding:10px; margin-bottom:10px; }
|
||||
button,input,select { padding:4px 8px; }
|
||||
.add-form { background:#f0f8ff; padding:10px; margin:10px 0; }
|
||||
a { color:#1976d2; }
|
||||
</style></head><body>
|
||||
|
||||
<div class="bar">
|
||||
<span><strong>V2 IP WhiteList</strong></span>
|
||||
<span>${user.email} | ${user.fio || ''} ${user.isAdmin ? '| ADMIN' : ''}</span>
|
||||
<span>
|
||||
<form method="GET" action="/v2/app" style="display:inline">
|
||||
<select name="switchTo" onchange="this.form.submit()">
|
||||
${companyOptions}
|
||||
</select>
|
||||
</form>
|
||||
<a href="/v2/logout">Выйти</a>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
${msg('')}${err('')}
|
||||
|
||||
<script>
|
||||
const params = new URLSearchParams(location.search);
|
||||
const m = params.get('msg'), e = params.get('error');
|
||||
if (m) document.querySelector('body').insertAdjacentHTML('afterbegin', '<div style="background:#4caf50;color:#fff;padding:8px">'+m+'</div>');
|
||||
if (e) document.querySelector('body').insertAdjacentHTML('afterbegin', '<div style="background:#f44336;color:#fff;padding:8px">'+e+'</div>');
|
||||
if (m||e) history.replaceState(null,'','/v2/app');
|
||||
</script>
|
||||
|
||||
<p>Записей: <strong>${used}</strong> из ${limit}</p>
|
||||
|
||||
<div class="add-form">
|
||||
<form method="POST" action="/v2/app/add">
|
||||
<input name="cidr" placeholder="x.x.x.x/xx" size="18" required>
|
||||
<input name="comment" placeholder="комментарий" size="30">
|
||||
<button>Добавить</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<table>
|
||||
<tr><th>CIDR</th><th>Комментарий</th><th>Кто</th><th>Когда</th><th></th></tr>
|
||||
${rows || '<tr><td colspan="5">Нет записей</td></tr>'}
|
||||
</table>
|
||||
|
||||
<p><a href="/v2/app?deleted=${includeDeleted ? '0' : '1'}">${includeDeleted ? 'Скрыть удалённые' : 'Показать удалённые'}</a></p>
|
||||
|
||||
</body></html>`;
|
||||
}
|
||||
|
||||
module.exports = { createCrudRouter };
|
||||
@@ -0,0 +1,23 @@
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// V2 — подключение к PostgreSQL
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
const { Pool } = require('pg');
|
||||
const config = require('../config');
|
||||
|
||||
const pool = new Pool({
|
||||
host: process.env.DB_HOST || 'localhost',
|
||||
port: process.env.DB_PORT || 5432,
|
||||
database: process.env.DB_NAME || 'ipwhitelist',
|
||||
user: process.env.DB_USER || 'super',
|
||||
password: process.env.DB_PASS || '',
|
||||
ssl: process.env.DB_SSLMODE === 'require' ? { rejectUnauthorized: false } : false,
|
||||
max: 10,
|
||||
idleTimeoutMillis: 30000,
|
||||
});
|
||||
|
||||
pool.on('error', (err) => {
|
||||
console.error('[v2:db] pool error:', err.message);
|
||||
});
|
||||
|
||||
module.exports = { pool };
|
||||
@@ -0,0 +1,232 @@
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// V2 — SQL-запросы (единственное место работы с БД)
|
||||
// Все функции работают с таблицами v2_companies, v2_entries, v2_audit
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
const { pool } = require('./index');
|
||||
const { validate, overlaps } = require('../validators');
|
||||
|
||||
// ── Компании ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Получить или создать компанию по client_id (W-номеру).
|
||||
* Атомарный upsert — исключает дубли при параллельных запросах.
|
||||
*/
|
||||
async function getOrCreateCompany(clientId, companyName) {
|
||||
const res = await pool.query(
|
||||
`INSERT INTO v2_companies (client_id, name) VALUES ($1, $2)
|
||||
ON CONFLICT (client_id) DO UPDATE SET name = COALESCE(v2_companies.name, EXCLUDED.name)
|
||||
RETURNING *`,
|
||||
[clientId, companyName || clientId]
|
||||
);
|
||||
return res.rows[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* Лимит записей компании: custom_limit или DEFAULT_LIMIT (15).
|
||||
*/
|
||||
async function getLimit(company) {
|
||||
const defaultLimit = parseInt(process.env.DEFAULT_LIMIT, 10) || 15;
|
||||
return company.custom_limit != null ? company.custom_limit : defaultLimit;
|
||||
}
|
||||
|
||||
// ── Записи ────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Список записей компании.
|
||||
* @param {number} companyId — v2_companies.id
|
||||
* @param {boolean} includeDeleted — показать удалённые
|
||||
*/
|
||||
async function listEntries(companyId, includeDeleted = false) {
|
||||
let sql = 'SELECT * FROM v2_entries WHERE company_id = $1';
|
||||
const params = [companyId];
|
||||
if (!includeDeleted) sql += ' AND deleted_at IS NULL';
|
||||
sql += ' ORDER BY created_at DESC';
|
||||
return (await pool.query(sql, params)).rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* Создать запись (с валидацией, проверкой лимита и пересечений).
|
||||
*/
|
||||
async function createEntry(companyId, rawValue, comment, userEmail, impersonatedBy) {
|
||||
const { cidr, wasNormalized } = validate(rawValue);
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
|
||||
const company = (await client.query(
|
||||
'SELECT * FROM v2_companies WHERE id = $1 FOR UPDATE', [companyId]
|
||||
)).rows[0];
|
||||
if (!company) throw new Error('Компания не найдена');
|
||||
|
||||
const limit = await getLimit(company);
|
||||
const cnt = (await client.query(
|
||||
'SELECT COUNT(*)::int AS c FROM v2_entries WHERE company_id = $1 AND deleted_at IS NULL',
|
||||
[companyId]
|
||||
)).rows[0].c;
|
||||
if (cnt >= limit) throw new Error(`Лимит исчерпан: ${cnt} из ${limit}`);
|
||||
|
||||
const existing = (await client.query(
|
||||
'SELECT value_cidr FROM v2_entries WHERE company_id = $1 AND deleted_at IS NULL',
|
||||
[companyId]
|
||||
)).rows;
|
||||
for (const row of existing) {
|
||||
if (row.value_cidr === cidr) throw new Error('Такой адрес уже существует');
|
||||
if (overlaps(cidr, row.value_cidr))
|
||||
throw new Error(`Пересечение с существующей записью ${row.value_cidr}`);
|
||||
}
|
||||
|
||||
const res = await client.query(
|
||||
`INSERT INTO v2_entries (company_id, value_cidr, comment, created_by)
|
||||
VALUES ($1, $2, $3, $4) RETURNING *`,
|
||||
[companyId, cidr, comment || null, userEmail]
|
||||
);
|
||||
await logAudit(userEmail, companyId, 'CREATE', null, cidr, res.rows[0].id, impersonatedBy, client);
|
||||
await client.query('COMMIT');
|
||||
return { entry: res.rows[0], wasNormalized };
|
||||
} catch (e) {
|
||||
await client.query('ROLLBACK');
|
||||
throw e;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Обновить запись.
|
||||
*/
|
||||
async function updateEntry(entryId, companyId, rawValue, comment, userEmail, impersonatedBy) {
|
||||
const { cidr, wasNormalized } = validate(rawValue);
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
await client.query('SELECT * FROM v2_companies WHERE id = $1 FOR UPDATE', [companyId]);
|
||||
|
||||
const old = (await client.query(
|
||||
'SELECT * FROM v2_entries WHERE id = $1 AND company_id = $2 AND deleted_at IS NULL',
|
||||
[entryId, companyId]
|
||||
)).rows[0];
|
||||
if (!old) throw new Error('Запись не найдена');
|
||||
|
||||
const existing = (await client.query(
|
||||
'SELECT value_cidr FROM v2_entries WHERE company_id = $1 AND deleted_at IS NULL AND id <> $2',
|
||||
[companyId, entryId]
|
||||
)).rows;
|
||||
for (const row of existing) {
|
||||
if (row.value_cidr === cidr) throw new Error('Такой адрес уже существует');
|
||||
if (overlaps(cidr, row.value_cidr))
|
||||
throw new Error(`Пересечение с существующей записью ${row.value_cidr}`);
|
||||
}
|
||||
|
||||
const res = await client.query(
|
||||
`UPDATE v2_entries SET value_cidr = $1, comment = $2, updated_by = $3, updated_at = NOW()
|
||||
WHERE id = $4 AND company_id = $5 RETURNING *`,
|
||||
[cidr, comment || old.comment, userEmail, entryId, companyId]
|
||||
);
|
||||
await logAudit(userEmail, companyId, 'UPDATE', old.value_cidr, cidr, entryId, impersonatedBy, client);
|
||||
await client.query('COMMIT');
|
||||
return { entry: res.rows[0], wasNormalized };
|
||||
} catch (e) {
|
||||
await client.query('ROLLBACK');
|
||||
throw e;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Удалить запись (soft delete).
|
||||
*/
|
||||
async function deleteEntry(entryId, companyId, userEmail, impersonatedBy) {
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
|
||||
const old = (await client.query(
|
||||
'SELECT * FROM v2_entries WHERE id = $1 AND company_id = $2 AND deleted_at IS NULL',
|
||||
[entryId, companyId]
|
||||
)).rows[0];
|
||||
if (!old) throw new Error('Запись не найдена');
|
||||
|
||||
await client.query(
|
||||
'UPDATE v2_entries SET deleted_by = $1, deleted_at = NOW() WHERE id = $2 AND company_id = $3',
|
||||
[userEmail, entryId, companyId]
|
||||
);
|
||||
await logAudit(userEmail, companyId, 'DELETE', old.value_cidr, null, entryId, impersonatedBy, client);
|
||||
await client.query('COMMIT');
|
||||
} catch (e) {
|
||||
await client.query('ROLLBACK');
|
||||
throw e;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
// ── Админ ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
async function getCompanyById(id) {
|
||||
const res = await pool.query('SELECT * FROM v2_companies WHERE id = $1', [id]);
|
||||
return res.rows[0] || null;
|
||||
}
|
||||
|
||||
async function getAllCompanies() {
|
||||
const res = await pool.query(`
|
||||
SELECT c.*, COUNT(e.id)::int AS active_count
|
||||
FROM v2_companies c
|
||||
LEFT JOIN v2_entries e ON e.company_id = c.id AND e.deleted_at IS NULL
|
||||
GROUP BY c.id
|
||||
ORDER BY c.name, c.client_id
|
||||
`);
|
||||
return res.rows;
|
||||
}
|
||||
|
||||
async function setLimit(companyId, newLimit) {
|
||||
const result = await pool.query(
|
||||
'UPDATE v2_companies SET custom_limit = $1, updated_at = NOW() WHERE id = $2',
|
||||
[newLimit, companyId]
|
||||
);
|
||||
if (result.rowCount === 0) {
|
||||
const err = new Error('Company not found: ' + companyId);
|
||||
err.status = 404;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Экспорт ───────────────────────────────────────────────────────────────────
|
||||
|
||||
async function getExportCIDRs(companyId = null) {
|
||||
let sql = 'SELECT value_cidr FROM v2_entries WHERE deleted_at IS NULL';
|
||||
const params = [];
|
||||
if (companyId) { sql += ' AND company_id = $1'; params.push(companyId); }
|
||||
sql += ' ORDER BY value_cidr';
|
||||
return (await pool.query(sql, params)).rows.map(r => r.value_cidr);
|
||||
}
|
||||
|
||||
// ── Аудит ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
async function logAudit(userEmail, companyId, action, oldValue, newValue, entryId, impersonatedBy, db = pool) {
|
||||
await db.query(
|
||||
`INSERT INTO v2_audit (user_email, company_id, action, old_value, new_value, entry_id, impersonated_by)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)`,
|
||||
[userEmail, companyId, action, oldValue, newValue, entryId || null, impersonatedBy || null]
|
||||
);
|
||||
}
|
||||
|
||||
async function getAudit(companyId = null) {
|
||||
let sql = `
|
||||
SELECT a.*, c.name AS company_name, c.client_id
|
||||
FROM v2_audit a
|
||||
JOIN v2_companies c ON c.id = a.company_id
|
||||
`;
|
||||
const params = [];
|
||||
if (companyId) { sql += ' WHERE a.company_id = $1'; params.push(companyId); }
|
||||
sql += ' ORDER BY a.created_at DESC LIMIT 500';
|
||||
return (await pool.query(sql, params)).rows;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getOrCreateCompany, getLimit,
|
||||
listEntries, createEntry, updateEntry, deleteEntry,
|
||||
getExportCIDRs, getAudit,
|
||||
getCompanyById, getAllCompanies, setLimit,
|
||||
};
|
||||
@@ -0,0 +1,82 @@
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// V2 — валидация IPv4/CIDR
|
||||
// Чистые функции, только net (встроенный Node.js)
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
const net = require('net');
|
||||
|
||||
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',
|
||||
];
|
||||
|
||||
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('Некорректный формат');
|
||||
|
||||
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;
|
||||
const networkAddr = [
|
||||
(network >>> 24) & 0xff, (network >>> 16) & 0xff,
|
||||
(network >>> 8) & 0xff, network & 0xff,
|
||||
].join('.');
|
||||
const normalized = networkAddr + '/' + mask;
|
||||
|
||||
for (const blocked of BLOCKED_RANGES)
|
||||
if (overlaps(normalized, blocked))
|
||||
throw new Error(`Диапазон ${normalized} пересекается с запрещённым (${blocked})`);
|
||||
|
||||
return { cidr: normalized, wasNormalized: addr !== networkAddr };
|
||||
}
|
||||
|
||||
function overlaps(cidr1, cidr2) {
|
||||
const a = cidrToRange(cidr1), b = cidrToRange(cidr2);
|
||||
return a.start <= b.end && b.start <= a.end;
|
||||
}
|
||||
|
||||
function cidrToRange(cidr) {
|
||||
const [addr, maskStr] = cidr.split('/');
|
||||
const ip = addr.split('.').reduce((acc, o) => (acc << 8) + parseInt(o, 10), 0) >>> 0;
|
||||
const mask = parseInt(maskStr, 10);
|
||||
return { start: ip, end: (ip | ((1 << (32 - mask)) - 1)) >>> 0 };
|
||||
}
|
||||
|
||||
function aggregateCIDRs(cidrs) {
|
||||
const ranges = cidrs.map(c => cidrToRange(c)).sort((a, b) => a.start - b.start);
|
||||
const merged = [];
|
||||
for (const r of ranges) {
|
||||
const last = merged[merged.length - 1];
|
||||
if (last && r.start <= last.end + 1) last.end = Math.max(last.end, r.end);
|
||||
else merged.push({ ...r });
|
||||
}
|
||||
return merged.map(r => {
|
||||
let start = r.start, end = r.end, cidrs = [];
|
||||
while (start <= end) {
|
||||
let maxMask = 32;
|
||||
while (maxMask > 0 && ((start & ((1 << (32 - maxMask)) - 1)) !== 0 || (start + (1 << (32 - maxMask)) - 1) > end)) maxMask--;
|
||||
cidrs.push(numToIP(start) + '/' + maxMask);
|
||||
start += (1 << (32 - maxMask));
|
||||
}
|
||||
return cidrs;
|
||||
}).flat();
|
||||
}
|
||||
|
||||
function numToIP(n) {
|
||||
return [(n >>> 24) & 0xff, (n >>> 16) & 0xff, (n >>> 8) & 0xff, n & 0xff].join('.');
|
||||
}
|
||||
|
||||
module.exports = { validate, overlaps, cidrToRange, aggregateCIDRs, numToIP, BLOCKED_RANGES };
|
||||
Reference in New Issue
Block a user