diff --git a/sql/schema.sql b/sql/schema.sql new file mode 100644 index 0000000..22511fe --- /dev/null +++ b/sql/schema.sql @@ -0,0 +1,39 @@ +-- IP WhiteList schema + +CREATE TABLE IF NOT EXISTS companies ( + id SERIAL PRIMARY KEY, + client_id VARCHAR(64) UNIQUE NOT NULL, + name VARCHAR(255), + custom_limit INTEGER DEFAULT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS whitelist_entries ( + id SERIAL PRIMARY KEY, + company_id INTEGER NOT NULL REFERENCES companies(id), + value_cidr VARCHAR(18) NOT NULL, + comment VARCHAR(255), + created_by VARCHAR(255) NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_by VARCHAR(255), + updated_at TIMESTAMPTZ, + deleted_by VARCHAR(255), + deleted_at TIMESTAMPTZ +); + +CREATE INDEX IF NOT EXISTS idx_entries_active + ON whitelist_entries(company_id, value_cidr) WHERE deleted_at IS NULL; + +CREATE TABLE IF NOT EXISTS audit_log ( + id SERIAL PRIMARY KEY, + user_email VARCHAR(255) NOT NULL, + company_id INTEGER NOT NULL, + action VARCHAR(32) NOT NULL, + old_value TEXT, + new_value TEXT, + entry_id INTEGER, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_audit_company ON audit_log(company_id); diff --git a/src/queries.js b/src/queries.js new file mode 100644 index 0000000..d00dfb7 --- /dev/null +++ b/src/queries.js @@ -0,0 +1,149 @@ +const { pool } = require('./db'); +const { validate, overlaps } = require('./validators'); + +// ── Companies ── + +async function getOrCreateCompany(clientId, companyName) { + let res = await pool.query('SELECT * FROM companies WHERE client_id = $1', [clientId]); + if (res.rows.length > 0) return res.rows[0]; + + res = await pool.query( + 'INSERT INTO companies (client_id, name) VALUES ($1, $2) RETURNING *', + [clientId, companyName || clientId] + ); + return res.rows[0]; +} + +async function getLimit(company) { + const defaultLimit = parseInt(process.env.DEFAULT_LIMIT, 10) || 15; + return company.custom_limit || defaultLimit; +} + +// ── Entries ── + +async function listEntries(companyId, includeDeleted = false) { + let sql = 'SELECT * FROM whitelist_entries WHERE company_id = $1'; + if (!includeDeleted) sql += ' AND deleted_at IS NULL'; + sql += ' ORDER BY created_at DESC'; + return (await pool.query(sql, [companyId])).rows; +} + +async function createEntry(companyId, rawValue, comment, userEmail) { + const { cidr, wasNormalized } = validate(rawValue); + + // Проверка лимита + const company = (await pool.query('SELECT * FROM companies WHERE id = $1', [companyId])).rows[0]; + const limit = await getLimit(company); + const cnt = (await pool.query( + 'SELECT COUNT(*)::int AS c FROM whitelist_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 pool.query( + 'SELECT value_cidr FROM whitelist_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 pool.query( + `INSERT INTO whitelist_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); + + return { entry: res.rows[0], wasNormalized }; +} + +async function updateEntry(entryId, companyId, rawValue, comment, userEmail) { + const old = (await pool.query( + 'SELECT * FROM whitelist_entries WHERE id = $1 AND company_id = $2 AND deleted_at IS NULL', + [entryId, companyId] + )).rows[0]; + if (!old) throw new Error('Запись не найдена'); + + const { cidr, wasNormalized } = validate(rawValue); + + // Проверка пересечений (исключая саму себя) + const existing = (await pool.query( + 'SELECT value_cidr FROM whitelist_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 pool.query( + `UPDATE whitelist_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); + + return { entry: res.rows[0], wasNormalized }; +} + +async function deleteEntry(entryId, companyId, userEmail) { + const old = (await pool.query( + 'SELECT * FROM whitelist_entries WHERE id = $1 AND company_id = $2 AND deleted_at IS NULL', + [entryId, companyId] + )).rows[0]; + if (!old) throw new Error('Запись не найдена'); + + await pool.query( + 'UPDATE whitelist_entries SET deleted_by = $1, deleted_at = NOW() WHERE id = $2', + [userEmail, entryId] + ); + + await logAudit(userEmail, companyId, 'DELETE', old.value_cidr, null, entryId); +} + +// ── Export ── + +async function getExportCIDRs() { + const rows = (await pool.query( + 'SELECT value_cidr FROM whitelist_entries WHERE deleted_at IS NULL ORDER BY value_cidr' + )).rows; + return rows.map(r => r.value_cidr); +} + +// ── Audit ── + +async function logAudit(userEmail, companyId, action, oldValue, newValue, entryId) { + await pool.query( + `INSERT INTO audit_log (user_email, company_id, action, old_value, new_value, entry_id) + VALUES ($1, $2, $3, $4, $5, $6)`, + [userEmail, companyId, action, oldValue, newValue, entryId || null] + ); +} + +async function getAudit(companyId = null) { + let sql = 'SELECT * FROM audit_log'; + const params = []; + if (companyId) { + sql += ' WHERE company_id = $1'; + params.push(companyId); + } + sql += ' ORDER BY created_at DESC LIMIT 500'; + return (await pool.query(sql, params)).rows; +} + +module.exports = { + getOrCreateCompany, getLimit, + listEntries, createEntry, updateEntry, deleteEntry, + getExportCIDRs, + getAudit, +}; diff --git a/src/validators.js b/src/validators.js new file mode 100644 index 0000000..eb974de --- /dev/null +++ b/src/validators.js @@ -0,0 +1,99 @@ +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', + '255.255.255.255/32', +]; + +/** + * Проверить и нормализовать IPv4/CIDR. + * Возвращает { cidr: 'x.x.x.x/yy', wasNormalized: bool } + * или выбрасывает ошибку с понятным текстом. + */ +function validate(input) { + const raw = (input || '').trim(); + if (!raw) throw new Error('Пустое значение'); + + // Отсекаем IPv6 и доменные имена + if (raw.includes(':')) throw new Error('IPv6 не поддерживается'); + if (/[a-zA-Z]/.test(raw.replace(/\./g, '').replace(/\//g, '').replace(/\d/g, ''))) + throw new Error('Некорректный формат'); + + // Если нет маски — добавляем /32 + let cidr = raw.includes('/') ? raw : raw + '/32'; + + const [addr, maskStr] = cidr.split('/'); + const mask = parseInt(maskStr, 10); + + if (isNaN(mask) || mask < 22 || mask > 32) { + throw new Error('Маска должна быть от /22 до /32'); + } + + if (!net.isIPv4(addr)) throw new Error('Некорректный IPv4 адрес'); + + // Нормализация: обнуляем host-биты + const ipNum = addr.split('.').reduce((acc, octet) => (acc << 8) + parseInt(octet, 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 wasNormalized = addr !== networkAddr; + const normalized = networkAddr + '/' + mask; + + // Проверка запрещённых диапазонов + for (const blocked of BLOCKED_RANGES) { + if (isSubnetOf(normalized, blocked)) { + throw new Error(`Диапазон ${normalized} запрещён (${blocked})`); + } + } + + return { cidr: normalized, wasNormalized }; +} + +/** + * Проверить пересечение двух CIDR + */ +function overlaps(cidr1, cidr2) { + const a = cidrToRange(cidr1); + const b = cidrToRange(cidr2); + return a.start <= b.end && b.start <= a.start || + b.start <= a.end && a.start <= b.start; +} + +/** + * Проверить что cidr является подсетью parent + */ +function isSubnetOf(cidr, parent) { + const child = cidrToRange(cidr); + const par = cidrToRange(parent); + return child.start >= par.start && child.end <= par.end; +} + +function cidrToRange(cidr) { + const [addr, maskStr] = cidr.split('/'); + const mask = parseInt(maskStr, 10); + const ip = addr.split('.').reduce((acc, o) => (acc << 8) + parseInt(o, 10), 0) >>> 0; + const start = ip >>> 0; + const end = (ip | ((1 << (32 - mask)) - 1)) >>> 0; + return { start, end }; +} + +module.exports = { validate, overlaps, BLOCKED_RANGES };