From e91f1b0ff36b29c694c50eeef3981c25f84e3790 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CNaeel=E2=80=9D?= Date: Fri, 12 Jun 2026 16:53:33 +0400 Subject: [PATCH] =?UTF-8?q?v2:=20CRUD-=D0=BC=D0=BE=D0=B4=D1=83=D0=BB=D1=8C?= =?UTF-8?q?=20=E2=80=94=20/v2/app=20=D1=81=20=D0=B2=D1=8B=D0=B1=D0=BE?= =?UTF-8?q?=D1=80=D0=BE=D0=BC=20=D0=BA=D0=BE=D0=BC=D0=BF=D0=B0=D0=BD=D0=B8?= =?UTF-8?q?=D0=B8,=20=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB=D0=B5=D0=BD?= =?UTF-8?q?=D0=B8=D0=B5/=D0=B8=D0=B7=D0=BC=D0=B5=D0=BD=D0=B5=D0=BD=D0=B8?= =?UTF-8?q?=D0=B5/=D1=83=D0=B4=D0=B0=D0=BB=D0=B5=D0=BD=D0=B8=D0=B5=20?= =?UTF-8?q?=D0=B7=D0=B0=D0=BF=D0=B8=D1=81=D0=B5=D0=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 2 +- v2/server.js | 4 + v2/sql/schema.sql | 54 +++++++++ v2/src/crud/index.js | 216 ++++++++++++++++++++++++++++++++++ v2/src/db/index.js | 23 ++++ v2/src/db/queries.js | 232 +++++++++++++++++++++++++++++++++++++ v2/src/validators/index.js | 82 +++++++++++++ 7 files changed, 612 insertions(+), 1 deletion(-) create mode 100644 v2/sql/schema.sql create mode 100644 v2/src/crud/index.js create mode 100644 v2/src/db/index.js create mode 100644 v2/src/db/queries.js create mode 100644 v2/src/validators/index.js diff --git a/package.json b/package.json index e2587bf..06a8fc8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ipwhitelist", - "version": "0.5.66", + "version": "0.5.67", "description": "IP WhiteList microservice for cloud provider", "main": "server.js", "scripts": { diff --git a/v2/server.js b/v2/server.js index 1dc0495..5d45992 100644 --- a/v2/server.js +++ b/v2/server.js @@ -8,6 +8,7 @@ const express = require('express'); const config = require('./src/config'); const auth = require('./src/auth'); +const crud = require('./src/crud'); const oidcConfig = { baseUrl: config.kcBaseUrl, @@ -102,6 +103,9 @@ ${u.isImpersonated ? `

⚠ } }); + // ── CRUD (/v2/app) ──────────────────────────────────────────── + router.use('/app', crud.createCrudRouter()); + // ── GET /v2/logout ─────────────────────────────────────────────────────── router.get('/logout', (req, res) => { delete req.session.v2_user; diff --git a/v2/sql/schema.sql b/v2/sql/schema.sql new file mode 100644 index 0000000..ccff7d6 --- /dev/null +++ b/v2/sql/schema.sql @@ -0,0 +1,54 @@ +-- ═══════════════════════════════════════════════════════════════════════════════ +-- V2 schema — отдельные таблицы, не пересекаются с основным приложением +-- ═══════════════════════════════════════════════════════════════════════════════ + +-- Компании (по W-номеру = client_id) +CREATE TABLE IF NOT EXISTS v2_companies ( + id SERIAL PRIMARY KEY, + client_id VARCHAR(64) UNIQUE NOT NULL, -- WZ01112, WZ03709 ... + name VARCHAR(255), -- название компании + custom_limit INTEGER DEFAULT NULL -- NULL = использовать DEFAULT_LIMIT + CHECK (custom_limit IS NULL OR custom_limit >= 0), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- Записи белого списка (soft delete через deleted_at) +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 -- формат: x.x.x.x/xx (CIDR или /32) + CHECK (value_cidr ~ '^(\d{1,3}\.){3}\d{1,3}/\d{1,2}$'), + comment VARCHAR(255), + created_by VARCHAR(255) NOT NULL, -- email создателя + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_by VARCHAR(255), + updated_at TIMESTAMPTZ, + deleted_by VARCHAR(255), + deleted_at TIMESTAMPTZ +); + +-- Уникальность: в одной компании не может быть двух активных записей с одинаковым CIDR +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, -- кто совершил действие + company_id INTEGER NOT NULL, -- в какой компании + action VARCHAR(32) NOT NULL -- CREATE, UPDATE, DELETE + CHECK (action IN ('CREATE','UPDATE','DELETE')), + old_value TEXT, -- предыдущий CIDR (для UPDATE) + new_value TEXT, -- новый CIDR + entry_id INTEGER, -- id записи в v2_entries + impersonated_by VARCHAR(255), -- кто реально (если имперсонация) + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_v2_audit_company_time + ON v2_audit(company_id, created_at DESC); diff --git a/v2/src/crud/index.js b/v2/src/crud/index.js new file mode 100644 index 0000000..d1b627e --- /dev/null +++ b/v2/src/crud/index.js @@ -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('

Ошибка

' + e.message + '
Назад'); + } + }); + + // ── 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 ? `
${s}
` : ''; + const err = (s) => s ? `
${s}
` : ''; + + const companyOptions = allCompanies.map(c => + `` + ).join(''); + + const rows = entries.map(e => { + const del = e.deleted_at; + const style = del ? 'text-decoration:line-through;opacity:0.5' : ''; + const actions = del ? 'удалено' : ` +
+ + + +
+
+ +
`; + return ` + ${e.value_cidr}${e.comment || ''}${e.created_by} + ${new Date(e.created_at).toLocaleString('ru')} + ${actions} + `; + }).join(''); + + return ` +V2 — IP WhiteList + + +
+ V2 IP WhiteList + ${user.email} | ${user.fio || ''} ${user.isAdmin ? '| ADMIN' : ''} + +
+ +
+ Выйти +
+
+ +${msg('')}${err('')} + + + +

Записей: ${used} из ${limit}

+ +
+
+ + + +
+
+ + + + ${rows || ''} +
CIDRКомментарийКтоКогда
Нет записей
+ +

${includeDeleted ? 'Скрыть удалённые' : 'Показать удалённые'}

+ +`; +} + +module.exports = { createCrudRouter }; diff --git a/v2/src/db/index.js b/v2/src/db/index.js new file mode 100644 index 0000000..ed55856 --- /dev/null +++ b/v2/src/db/index.js @@ -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 }; diff --git a/v2/src/db/queries.js b/v2/src/db/queries.js new file mode 100644 index 0000000..4bf6c4f --- /dev/null +++ b/v2/src/db/queries.js @@ -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, +}; diff --git a/v2/src/validators/index.js b/v2/src/validators/index.js new file mode 100644 index 0000000..976b78e --- /dev/null +++ b/v2/src/validators/index.js @@ -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 };