285 lines
8.9 KiB
JavaScript
285 lines
8.9 KiB
JavaScript
'use strict';
|
|
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
const { DatabaseSync } = require('node:sqlite');
|
|
|
|
const dbPath = path.join(__dirname, '..', 'data', 'journal.db');
|
|
const RETENTION_DAYS = 30;
|
|
const IPV4_RE = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/;
|
|
|
|
let db = null;
|
|
|
|
function getDb() {
|
|
if (db) return db;
|
|
fs.mkdirSync(path.dirname(dbPath), { recursive: true });
|
|
db = new DatabaseSync(dbPath);
|
|
db.exec(`
|
|
PRAGMA journal_mode = WAL;
|
|
CREATE TABLE IF NOT EXISTS admin_login_log (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
ts INTEGER NOT NULL,
|
|
ip TEXT NOT NULL,
|
|
username TEXT NOT NULL DEFAULT '',
|
|
success INTEGER NOT NULL,
|
|
reason TEXT NOT NULL DEFAULT ''
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_admin_ts ON admin_login_log(ts);
|
|
CREATE INDEX IF NOT EXISTS idx_admin_ip ON admin_login_log(ip);
|
|
CREATE INDEX IF NOT EXISTS idx_admin_success ON admin_login_log(success);
|
|
CREATE TABLE IF NOT EXISTS auth_log (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
ts INTEGER NOT NULL,
|
|
ip TEXT NOT NULL,
|
|
username TEXT NOT NULL DEFAULT '',
|
|
success INTEGER NOT NULL
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_auth_ts ON auth_log(ts);
|
|
CREATE INDEX IF NOT EXISTS idx_auth_ip ON auth_log(ip);
|
|
CREATE INDEX IF NOT EXISTS idx_auth_success ON auth_log(success);
|
|
CREATE TABLE IF NOT EXISTS block_events (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
ts INTEGER NOT NULL,
|
|
ip TEXT NOT NULL,
|
|
blocked_until INTEGER NOT NULL,
|
|
attempts INTEGER NOT NULL
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_block_ts ON block_events(ts);
|
|
CREATE TABLE IF NOT EXISTS active_blocks (
|
|
ip TEXT PRIMARY KEY,
|
|
blocked_until INTEGER NOT NULL,
|
|
attempts INTEGER NOT NULL
|
|
);
|
|
CREATE TABLE IF NOT EXISTS ip_whitelist (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
pattern TEXT NOT NULL UNIQUE,
|
|
comment TEXT NOT NULL DEFAULT ''
|
|
);
|
|
`);
|
|
return db;
|
|
}
|
|
|
|
// ================= IP helpers (IPv4) =================
|
|
|
|
function ipToInt(ip) {
|
|
const m = IPV4_RE.exec(String(ip || ''));
|
|
if (!m) return null;
|
|
const parts = m.slice(1).map(Number);
|
|
if (parts.some((p) => p > 255)) return null;
|
|
return ((parts[0] << 24) | (parts[1] << 16) | (parts[2] << 8) | parts[3]) >>> 0;
|
|
}
|
|
|
|
function isValidPattern(pattern) {
|
|
const value = String(pattern || '').trim();
|
|
if (!value) return false;
|
|
const parts = value.split('/');
|
|
if (parts.length === 1) {
|
|
return ipToInt(value) !== null;
|
|
}
|
|
if (parts.length === 2) {
|
|
const prefix = Number(parts[1]);
|
|
return Number.isInteger(prefix) && prefix >= 0 && prefix <= 32 && ipToInt(parts[0]) !== null;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
function patternContains(pattern, ipInt) {
|
|
const parts = String(pattern).trim().split('/');
|
|
const base = ipToInt(parts[0]);
|
|
if (base === null || ipInt === null) return false;
|
|
if (parts.length === 1) return base === ipInt;
|
|
const prefix = Number(parts[1]);
|
|
if (prefix === 0) return true;
|
|
const mask = prefix === 32 ? 0xffffffff : (0xffffffff << (32 - prefix)) >>> 0;
|
|
return (base & mask) === (ipInt & mask);
|
|
}
|
|
|
|
// ================= Whitelist =================
|
|
|
|
function listWhitelist() {
|
|
return getDb()
|
|
.prepare('SELECT id, pattern, comment FROM ip_whitelist ORDER BY id')
|
|
.all()
|
|
.map((r) => ({ id: r.id, pattern: r.pattern, comment: r.comment }));
|
|
}
|
|
|
|
function addWhitelist(pattern, comment) {
|
|
const value = String(pattern || '').trim();
|
|
if (!isValidPattern(value)) {
|
|
const err = new Error('Некорректный IP или маска (пример: 192.168.1.0/24)');
|
|
err.status = 400;
|
|
throw err;
|
|
}
|
|
try {
|
|
const info = getDb()
|
|
.prepare('INSERT INTO ip_whitelist (pattern, comment) VALUES (?, ?)')
|
|
.run(value, String(comment || '').trim());
|
|
return { id: Number(info.lastInsertRowid), pattern: value, comment: String(comment || '').trim() };
|
|
} catch (err) {
|
|
if (String(err.message || '').includes('UNIQUE')) {
|
|
const e = new Error('Этот IP/маска уже в списке');
|
|
e.status = 409;
|
|
throw e;
|
|
}
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
function removeWhitelist(id) {
|
|
getDb().prepare('DELETE FROM ip_whitelist WHERE id = ?').run(Number(id));
|
|
}
|
|
|
|
function isWhitelisted(ip) {
|
|
if (!ip) return false;
|
|
const ipInt = ipToInt(ip);
|
|
if (ipInt === null) return false;
|
|
const rows = getDb().prepare('SELECT pattern FROM ip_whitelist').all();
|
|
return rows.some((r) => patternContains(r.pattern, ipInt));
|
|
}
|
|
|
|
// ================= Journals =================
|
|
|
|
function logAdminEntry({ ip, username, success, reason }) {
|
|
getDb()
|
|
.prepare('INSERT INTO admin_login_log (ts, ip, username, success, reason) VALUES (?, ?, ?, ?, ?)')
|
|
.run(Date.now(), String(ip || ''), String(username || ''), success ? 1 : 0, String(reason || ''));
|
|
}
|
|
|
|
function logAuthEntry({ ip, username, success }) {
|
|
getDb()
|
|
.prepare('INSERT INTO auth_log (ts, ip, username, success) VALUES (?, ?, ?, ?)')
|
|
.run(Date.now(), String(ip || ''), String(username || ''), success ? 1 : 0);
|
|
}
|
|
|
|
// ================= Blocks =================
|
|
|
|
function blockIp(ip, blockMs, attempts) {
|
|
const now = Date.now();
|
|
const blockedUntil = now + blockMs;
|
|
getDb()
|
|
.prepare('INSERT INTO active_blocks (ip, blocked_until, attempts) VALUES (?, ?, ?) ON CONFLICT(ip) DO UPDATE SET blocked_until = excluded.blocked_until, attempts = excluded.attempts')
|
|
.run(String(ip), blockedUntil, attempts);
|
|
getDb()
|
|
.prepare('INSERT INTO block_events (ts, ip, blocked_until, attempts) VALUES (?, ?, ?, ?)')
|
|
.run(now, String(ip), blockedUntil, attempts);
|
|
}
|
|
|
|
function getActiveBlock(ip) {
|
|
const row = getDb().prepare('SELECT ip, blocked_until, attempts FROM active_blocks WHERE ip = ?').get(String(ip || ''));
|
|
if (!row) return null;
|
|
return { ip: row.ip, blockedUntil: row.blocked_until, attempts: row.attempts };
|
|
}
|
|
|
|
function removeActiveBlock(ip) {
|
|
getDb().prepare('DELETE FROM active_blocks WHERE ip = ?').run(String(ip || ''));
|
|
}
|
|
|
|
function getActiveBlocks() {
|
|
const now = Date.now();
|
|
const rows = getDb()
|
|
.prepare('SELECT ip, blocked_until, attempts FROM active_blocks WHERE blocked_until > ? ORDER BY blocked_until')
|
|
.all(now);
|
|
return rows.map((r) => ({
|
|
ip: r.ip,
|
|
blockedUntil: r.blocked_until,
|
|
attempts: r.attempts,
|
|
remainingMs: r.blocked_until - now
|
|
}));
|
|
}
|
|
|
|
function getBlockHistory(limit) {
|
|
const n = Math.min(Math.max(Number(limit) || 200, 1), 1000);
|
|
return getDb()
|
|
.prepare('SELECT ts, ip, blocked_until, attempts FROM block_events ORDER BY ts DESC LIMIT ?')
|
|
.all(n)
|
|
.map((r) => ({ ts: r.ts, ip: r.ip, blockedUntil: r.blocked_until, attempts: r.attempts }));
|
|
}
|
|
|
|
// ================= Queries =================
|
|
|
|
const JOURNAL_TABLES = {
|
|
admin: 'admin_login_log',
|
|
auth: 'auth_log'
|
|
};
|
|
|
|
function queryJournal(type, filters) {
|
|
const table = JOURNAL_TABLES[type];
|
|
if (!table) {
|
|
const err = new Error('Неизвестный тип журнала');
|
|
err.status = 400;
|
|
throw err;
|
|
}
|
|
const where = [];
|
|
const params = [];
|
|
const f = filters || {};
|
|
|
|
if (f.success === '1' || f.success === 'true' || f.success === 1) {
|
|
where.push('success = 1');
|
|
} else if (f.success === '0' || f.success === 'false' || f.success === 0) {
|
|
where.push('success = 0');
|
|
}
|
|
if (f.from) {
|
|
const from = Date.parse(String(f.from));
|
|
if (!Number.isNaN(from)) {
|
|
where.push('ts >= ?');
|
|
params.push(from);
|
|
}
|
|
}
|
|
if (f.to) {
|
|
const to = Date.parse(String(f.to));
|
|
if (!Number.isNaN(to)) {
|
|
where.push('ts <= ?');
|
|
params.push(to);
|
|
}
|
|
}
|
|
if (f.ip) {
|
|
where.push('ip LIKE ?');
|
|
params.push(`%${String(f.ip).trim()}%`);
|
|
}
|
|
if (f.username) {
|
|
where.push('username LIKE ?');
|
|
params.push(`%${String(f.username).trim()}%`);
|
|
}
|
|
const limit = Math.min(Math.max(Number(f.limit) || 200, 1), 1000);
|
|
const reasonCol = table === 'admin_login_log' ? ', reason' : '';
|
|
const sql = `SELECT id, ts, ip, username, success${reasonCol} FROM ${table} ${where.length ? 'WHERE ' + where.join(' AND ') : ''} ORDER BY ts DESC LIMIT ${limit}`;
|
|
return getDb().prepare(sql).all(...params).map((r) => ({
|
|
id: r.id,
|
|
ts: r.ts,
|
|
ip: r.ip,
|
|
username: r.username,
|
|
success: !!r.success,
|
|
reason: r.reason || ''
|
|
}));
|
|
}
|
|
|
|
// ================= Retention =================
|
|
|
|
function cleanup() {
|
|
const cutoff = Date.now() - RETENTION_DAYS * 24 * 60 * 60 * 1000;
|
|
const database = getDb();
|
|
database.prepare('DELETE FROM admin_login_log WHERE ts < ?').run(cutoff);
|
|
database.prepare('DELETE FROM auth_log WHERE ts < ?').run(cutoff);
|
|
database.prepare('DELETE FROM block_events WHERE ts < ?').run(cutoff);
|
|
database.prepare('DELETE FROM active_blocks WHERE blocked_until <= ?').run(Date.now());
|
|
}
|
|
|
|
setInterval(cleanup, 60 * 60 * 1000).unref();
|
|
|
|
module.exports = {
|
|
dbPath,
|
|
RETENTION_DAYS,
|
|
isValidPattern,
|
|
isWhitelisted,
|
|
listWhitelist,
|
|
addWhitelist,
|
|
removeWhitelist,
|
|
logAdminEntry,
|
|
logAuthEntry,
|
|
blockIp,
|
|
getActiveBlock,
|
|
removeActiveBlock,
|
|
getActiveBlocks,
|
|
getBlockHistory,
|
|
queryJournal
|
|
}; |