Backend: SQLite storage, EvoBGP integration, filters in SQL

Made-with: Cursor
This commit is contained in:
2026-04-20 01:17:00 +07:00
parent 7f3eaea40a
commit 1c6e6ab24a
27 changed files with 2406 additions and 2542 deletions
+103
View File
@@ -0,0 +1,103 @@
/**
* Бэкапы MikroTik на локальной ФС (вместо S3).
* Логические ключи сохраняют вид backups/mikrotik/{serverId}/{ts}-{rand}.rsc
*/
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const BACKUP_PREFIX = 'backups/mikrotik';
function backupRootDir() {
const raw = process.env.MIKROTIK_BACKUP_DIR || path.join(__dirname, '..', 'data', 'backups', 'mikrotik');
if (!fs.existsSync(raw)) {
fs.mkdirSync(raw, { recursive: true });
}
return raw;
}
function parseBackupKey(key) {
const m = String(key || '').match(/^backups\/mikrotik\/([^/]+)\/(.+)$/);
if (!m) return null;
return { safeId: m[1], fileName: m[2] };
}
function fsPathForKey(key) {
const p = parseBackupKey(key);
if (!p) return null;
return path.join(backupRootDir(), p.safeId, p.fileName);
}
function buildBackupKey(serverId, createdAt, suffix) {
const safeId = String(serverId || 'unknown').replace(/[^a-zA-Z0-9._-]/g, '_');
const ts = createdAt.toISOString().replace(/[:-]/g, '').replace(/\.\d+Z$/, 'Z');
const rand = suffix || crypto.randomBytes(3).toString('hex');
return `${BACKUP_PREFIX}/${safeId}/${ts}-${rand}.rsc`;
}
function etagForContent(content) {
const h = crypto.createHash('sha256').update(Buffer.from(String(content), 'utf8')).digest('hex');
return `"${h}"`;
}
async function writeBackupFile(key, content, contentType = 'text/plain') {
const p = parseBackupKey(key);
if (!p) throw new Error('Invalid backup key');
const dir = path.join(backupRootDir(), p.safeId);
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
const fp = path.join(dir, p.fileName);
await fs.promises.writeFile(fp, String(content), 'utf8');
const st = await fs.promises.stat(fp);
const etag = etagForContent(String(content));
return {
etag,
lastModified: st.mtime.toISOString(),
contentLength: st.size,
};
}
async function readBackupFile(key) {
const fp = fsPathForKey(key);
if (!fp) throw new Error('Invalid backup key');
const body = await fs.promises.readFile(fp, 'utf8');
const st = await fs.promises.stat(fp);
const etag = etagForContent(body);
return {
body,
etag,
lastModified: st.mtime.toISOString(),
contentLength: st.size,
};
}
async function listBackupObjects(serverId, maxKeys = 200) {
const safeId = String(serverId || '').replace(/[^a-zA-Z0-9._-]/g, '_');
const dir = path.join(backupRootDir(), safeId);
if (!fs.existsSync(dir)) return [];
const names = await fs.promises.readdir(dir);
const out = [];
for (const name of names) {
if (out.length >= maxKeys) break;
const fp = path.join(dir, name);
const st = await fs.promises.stat(fp);
if (!st.isFile()) continue;
const key = `${BACKUP_PREFIX}/${safeId}/${name}`;
out.push({
key,
size: st.size,
lastModified: st.mtime.toISOString(),
etag: etagForContent(await fs.promises.readFile(fp, 'utf8')),
});
}
return out;
}
module.exports = {
BACKUP_PREFIX,
buildBackupKey,
writeBackupFile,
readBackupFile,
listBackupObjects,
backupRootDir,
};
+167
View File
@@ -0,0 +1,167 @@
/**
* SQLite blob store (logical S3 keys → rows in `blobs`).
*/
const crypto = require('crypto');
const { openDatabase } = require('../db/sqliteDb');
function sha256Etag(buf) {
const h = crypto.createHash('sha256').update(buf).digest('hex');
return `"${h}"`;
}
function namespaceFromKey(objectKey) {
const k = String(objectKey || '');
if (k.startsWith('ping-cache/') || k.startsWith('speed-test-cache/') || k.startsWith('network-map-cache/') ||
k.startsWith('uptime-monitor-cache/') || k.startsWith('ping-services/')) {
return 'cache';
}
if (k.startsWith('filter-manager/config-') || k.startsWith('filter-manager/server-filters-')) {
return 'per_server';
}
if (k === 'mikrotik-frouting-config.txt') {
return 'derived';
}
return 'config';
}
function isVersionedKey(db, objectKey) {
const row = db.prepare('SELECT 1 FROM versioned_keys WHERE object_key = ?').get(objectKey);
return Boolean(row);
}
function trimVersions(db, objectKey, keep = 50) {
db.prepare(
`DELETE FROM blob_versions WHERE object_key = ? AND id NOT IN (
SELECT id FROM blob_versions WHERE object_key = ? ORDER BY created_at DESC, id DESC LIMIT ?
)`
).run(objectKey, objectKey, keep);
}
/**
* @param {Buffer|string} body
*/
function writeBlobTx(db, objectKey, body, contentType) {
const buf = Buffer.isBuffer(body) ? body : Buffer.from(String(body), 'utf8');
const ns = namespaceFromKey(objectKey);
const etag = sha256Etag(buf);
const now = Date.now();
const size = buf.length;
const existing = db.prepare('SELECT body, content_type, etag, updated_at, created_at FROM blobs WHERE object_key = ?').get(objectKey);
const run = () => {
if (existing && isVersionedKey(db, objectKey)) {
const oldBuf = existing.body;
db.prepare(
`INSERT INTO blob_versions (object_key, body, byte_size, etag, created_at)
VALUES (?, ?, ?, ?, ?)`
).run(objectKey, oldBuf, oldBuf.length, existing.etag, existing.updated_at);
trimVersions(db, objectKey);
}
if (existing) {
db.prepare(
`UPDATE blobs SET namespace = ?, content_type = ?, body = ?, byte_size = ?, etag = ?, updated_at = ?
WHERE object_key = ?`
).run(ns, contentType, buf, size, etag, now, objectKey);
} else {
db.prepare(
`INSERT INTO blobs (object_key, namespace, content_type, body, byte_size, etag, updated_at, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
).run(objectKey, ns, contentType, buf, size, etag, now, now);
}
};
db.transaction(run)();
return {
etag,
lastModified: new Date(now).toISOString(),
contentLength: size,
};
}
function readBlob(objectKey) {
const db = openDatabase();
const row = db.prepare(
'SELECT body, etag, updated_at, byte_size FROM blobs WHERE object_key = ?'
).get(objectKey);
if (!row) return null;
const bodyStr = row.body.toString('utf8');
return {
body: bodyStr,
etag: row.etag,
lastModified: new Date(row.updated_at).toISOString(),
contentLength: row.byte_size,
};
}
function headMetaRow(objectKey) {
const db = openDatabase();
const row = db.prepare('SELECT etag, updated_at, byte_size FROM blobs WHERE object_key = ?').get(objectKey);
if (!row) {
return { etag: null, lastModified: null, contentLength: null };
}
return {
etag: row.etag,
lastModified: new Date(row.updated_at).toISOString(),
contentLength: row.byte_size,
};
}
function headEtag(objectKey) {
const m = headMetaRow(objectKey);
return m.etag || undefined;
}
function deleteBlob(objectKey) {
const db = openDatabase();
db.prepare('DELETE FROM blobs WHERE object_key = ?').run(objectKey);
}
function listByPrefix(prefix, maxKeys = 100) {
const db = openDatabase();
const rows = db.prepare(
`SELECT object_key AS key, byte_size AS size, updated_at, etag FROM blobs
WHERE object_key >= ? AND object_key < ? ORDER BY object_key LIMIT ?`
).all(prefix, prefix + '\uffff', maxKeys);
return rows.map((r) => ({
key: r.key,
size: r.size,
lastModified: new Date(r.updated_at).toISOString(),
etag: r.etag,
}));
}
function listVersions(objectKey, maxKeys = 50) {
const db = openDatabase();
return db.prepare(
`SELECT id, object_key, byte_size, etag, created_at FROM blob_versions
WHERE object_key = ? ORDER BY created_at DESC, id DESC LIMIT ?`
).all(objectKey, maxKeys);
}
function rollbackToVersionId(objectKey, versionId) {
const db = openDatabase();
const vid = Number(versionId);
if (!Number.isFinite(vid)) throw new Error('Invalid versionId');
const ver = db.prepare('SELECT body, etag, created_at FROM blob_versions WHERE id = ? AND object_key = ?').get(vid, objectKey);
if (!ver) throw new Error('Version not found');
const buf = ver.body;
const contentType = objectKey.endsWith('.json') ? 'application/json' : 'text/plain';
writeBlobTx(db, objectKey, buf, contentType);
return headMetaRow(objectKey);
}
module.exports = {
namespaceFromKey,
writeBlobTx,
readBlob,
headMetaRow,
headEtag,
deleteBlob,
listByPrefix,
listVersions,
rollbackToVersionId,
sha256Etag,
};
+426
View File
@@ -0,0 +1,426 @@
/**
* Серверный клиент EvoBGP API (Bearer, base /v1). Логика выборки совместима с frontend/src/lib/evobgpData.js
*/
const { normalizeEvobgpBaseUrl } = require('../routes/evobgpProxyRoutes');
const PAGE_LIMIT = 500;
const DEFAULT_TIMEOUT_MS = Number(process.env.EVOBGP_CLIENT_TIMEOUT_MS) || 30000;
function getBaseAndToken() {
const base = normalizeEvobgpBaseUrl(process.env.EVOBGP_API_URL || '');
const token = String(process.env.EVOBGP_API_TOKEN || '').trim();
return { base, token };
}
function isConfigured() {
const { base, token } = getBaseAndToken();
return Boolean(base && token);
}
async function evobgpRequest(method, pathname, { query, body } = {}) {
const { base, token } = getBaseAndToken();
if (!base || !token) {
const err = new Error('EvoBGP not configured');
err.code = 'E_EVOBGP_NOT_CONFIGURED';
throw err;
}
const u = new URL(pathname.startsWith('/') ? pathname.slice(1) : pathname, base.endsWith('/') ? base : `${base}/`);
if (query && typeof query === 'object') {
for (const [k, v] of Object.entries(query)) {
if (v !== undefined && v !== null) u.searchParams.set(k, String(v));
}
}
const ctrl = new AbortController();
const t = setTimeout(() => ctrl.abort(), DEFAULT_TIMEOUT_MS);
try {
const res = await fetch(u.toString(), {
method,
headers: {
Authorization: `Bearer ${token}`,
Accept: 'application/json',
...(body !== undefined ? { 'Content-Type': 'application/json' } : {}),
},
body: body !== undefined ? JSON.stringify(body) : undefined,
signal: ctrl.signal,
});
const text = await res.text();
let data = null;
try {
data = text ? JSON.parse(text) : null;
} catch (_) {
data = text;
}
if (!res.ok) {
const err = new Error(typeof data === 'object' && data?.message ? data.message : res.statusText);
err.code = 'E_EVOBGP_HTTP';
err.status = res.status;
err.body = data;
throw err;
}
return data;
} finally {
clearTimeout(t);
}
}
async function paginate(path, params = {}) {
const items = [];
let cursor = null;
while (true) {
const res = await evobgpRequest('GET', path, {
query: { ...params, limit: PAGE_LIMIT, ...(cursor ? { cursor } : {}) },
});
const chunk = Array.isArray(res?.items) ? res.items : [];
items.push(...chunk);
if (!res?.has_more || !res?.next_cursor) break;
cursor = res.next_cursor;
}
return items;
}
async function fetchRouterListsCatalog() {
return evobgpRequest('GET', '/router-lists/catalog');
}
function getCatalogItems(section) {
if (!section || typeof section !== 'object') return [];
return Array.isArray(section.items) ? section.items : [];
}
async function listCommunities() {
let communities = [];
try {
const catalog = await fetchRouterListsCatalog();
communities = getCatalogItems(catalog.communities);
} catch {
communities = await paginate('/communities');
}
return communities.map((c) => ({
id: c.id,
value: String(c.community || ''),
name: String(c.title || ''),
description: '',
tags: [],
color: '',
icon: '',
}));
}
async function getOrCreateModule(moduleType, defaultName) {
const modules = await paginate('/modules', { type: moduleType });
if (modules.length > 0) return modules[0];
const created = await evobgpRequest('POST', '/modules', {
body: { type: moduleType, name: defaultName, enabled: true },
});
return created?.data || created;
}
function normalizeList(items, keyName) {
const out = [];
const seen = new Set();
for (const item of items || []) {
const key = String(item?.[keyName] || '').trim().toLowerCase();
if (!key || seen.has(key)) continue;
seen.add(key);
out.push({ ...item, [keyName]: key });
}
return out;
}
async function buildCommunityMaps() {
const communities = await listCommunities();
const byId = new Map(communities.map((c) => [c.id, c.value]));
const byValue = new Map(communities.map((c) => [c.value, c.id]));
return { communities, byId, byValue };
}
function mapCommunityToValue(communityId, byId) {
if (!communityId) return '';
return byId.get(communityId) || String(communityId);
}
async function syncEntries({
moduleType,
moduleName,
listPath,
keyName,
createBody,
patchBody,
currentItems,
originalItems,
requireCommunity = false,
}) {
const module = await getOrCreateModule(moduleType, moduleName);
const moduleId = module.id;
const { byId, byValue } = await buildCommunityMaps();
const existing = await paginate(`/modules/${moduleId}/${listPath}`);
const existingByKey = new Map();
for (const row of existing) {
const entryKey = String(row?.[keyName] || '').trim().toLowerCase();
if (!entryKey) continue;
existingByKey.set(entryKey, row);
}
const normalizedCurrent = normalizeList(currentItems, keyName);
const normalizedOriginal = normalizeList(originalItems, keyName);
const currentMap = new Map(normalizedCurrent.map((i) => [String(i[keyName]).toLowerCase(), i]));
const originalKeys = new Set(normalizedOriginal.map((i) => String(i[keyName]).toLowerCase()));
for (const key of originalKeys) {
if (!currentMap.has(key) && existingByKey.has(key)) {
const row = existingByKey.get(key);
await evobgpRequest('DELETE', `/modules/${moduleId}/${listPath}/${row.id}`);
existingByKey.delete(key);
}
}
for (const [key, item] of currentMap.entries()) {
const rawCommunity = String(item.community || '').trim();
const communityId = byValue.get(rawCommunity) || null;
if (requireCommunity && !communityId) {
throw new Error(`Community "${rawCommunity}" не найдена в справочнике`);
}
const existingRow = existingByKey.get(key);
if (!existingRow) {
await evobgpRequest('POST', `/modules/${moduleId}/${listPath}`, { body: createBody(item, communityId) });
continue;
}
const currentCommunity = mapCommunityToValue(existingRow.community_id, byId);
if (currentCommunity !== rawCommunity) {
await evobgpRequest('PATCH', `/modules/${moduleId}/${listPath}/${existingRow.id}`, {
body: patchBody(item, communityId),
});
}
}
}
async function fetchDomainsData() {
try {
const catalog = await fetchRouterListsCatalog();
const communities = getCatalogItems(catalog.communities);
const byId = new Map(communities.map((c) => [c.id, String(c.community || '')]));
const rows = getCatalogItems(catalog.domains);
return rows
.map((row) => ({
id: row?.entry?.id || row?.id || '',
domain: String(row?.entry?.fqdn || row?.fqdn || '')
.trim()
.toLowerCase(),
community: mapCommunityToValue(row?.entry?.community_id || row?.community_id || null, byId),
}))
.filter((row) => row.domain);
} catch {
const module = await getOrCreateModule('DOMAINS', 'Domains');
const { byId } = await buildCommunityMaps();
const rows = await paginate(`/modules/${module.id}/domain-entries`);
return rows.map((row) => ({
id: row.id,
domain: String(row.fqdn || '')
.trim()
.toLowerCase(),
community: mapCommunityToValue(row.community_id, byId),
}));
}
}
async function fetchIpRangesData() {
try {
const catalog = await fetchRouterListsCatalog();
const communities = getCatalogItems(catalog.communities);
const byId = new Map(communities.map((c) => [c.id, String(c.community || '')]));
const rows = getCatalogItems(catalog.ip_ranges);
return rows
.map((row) => ({
id: row?.entry?.id || row?.id || '',
ipRange: String(row?.entry?.prefix || row?.prefix || '').trim(),
community: mapCommunityToValue(row?.entry?.community_id || row?.community_id || null, byId),
}))
.filter((row) => row.ipRange);
} catch {
const module = await getOrCreateModule('IP_RANGES', 'IP ranges');
const { byId } = await buildCommunityMaps();
const rows = await paginate(`/modules/${module.id}/ip-range-entries`);
return rows.map((row) => ({
id: row.id,
ipRange: String(row.prefix || '').trim(),
community: mapCommunityToValue(row.community_id, byId),
}));
}
}
async function fetchAsnsData() {
try {
const catalog = await fetchRouterListsCatalog();
const communities = getCatalogItems(catalog.communities);
const byId = new Map(communities.map((c) => [c.id, String(c.community || '')]));
const rows = getCatalogItems(catalog.asns);
return rows
.map((row) => ({
id: row?.entry?.id || row?.id || '',
asn: String(row?.entry?.asn || row?.asn || '').trim(),
community: mapCommunityToValue(row?.entry?.community_id || row?.community_id || null, byId),
}))
.filter((row) => row.asn);
} catch {
const module = await getOrCreateModule('AS_PREFIXES', 'ASNs');
const { byId } = await buildCommunityMaps();
const rows = await paginate(`/modules/${module.id}/as-entries`);
return rows.map((row) => ({
id: row.id,
asn: String(row.asn || '').trim(),
community: mapCommunityToValue(row.community_id, byId),
}));
}
}
/** Auto-URL: секция каталога или модуль AUTO_URLS / URL_LIST (настраивается env). */
async function fetchAutoUrlsData() {
try {
const catalog = await fetchRouterListsCatalog();
for (const name of ['auto_urls', 'auto_url', 'urls', 'url_list']) {
const sec = catalog[name];
const items = getCatalogItems(sec);
if (items.length) {
const communities = getCatalogItems(catalog.communities);
const byId = new Map(communities.map((c) => [c.id, String(c.community || '')]));
return items.map((row) => {
const url = String(row?.entry?.url || row?.entry?.fqdn || row?.url || row?.fqdn || '').trim();
const community = mapCommunityToValue(
row?.entry?.community_id || row?.community_id || null,
byId,
);
return { url, community };
}).filter((r) => r.url);
}
}
} catch (_) {}
const modType = process.env.EVOBGP_AUTO_URL_MODULE_TYPE || 'AUTO_URLS';
try {
const module = await getOrCreateModule(modType, 'Auto URLs');
const listPath = process.env.EVOBGP_AUTO_URL_LIST_PATH || 'url-entries';
const rows = await paginate(`/modules/${module.id}/${listPath}`);
const { byId } = await buildCommunityMaps();
return rows.map((row) => ({
url: String(row.url || row.fqdn || '').trim(),
community: mapCommunityToValue(row.community_id, byId),
})).filter((r) => r.url);
} catch {
return [];
}
}
async function saveDomainsData(currentItems, originalItems) {
await syncEntries({
moduleType: 'DOMAINS',
moduleName: 'Domains',
listPath: 'domain-entries',
keyName: 'domain',
currentItems,
originalItems,
createBody: (item, communityId) => ({ fqdn: item.domain, community_id: communityId }),
patchBody: (item, communityId) => ({ fqdn: item.domain, community_id: communityId }),
});
}
async function saveIpRangesData(currentItems, originalItems) {
await syncEntries({
moduleType: 'IP_RANGES',
moduleName: 'IP ranges',
listPath: 'ip-range-entries',
keyName: 'ipRange',
currentItems,
originalItems,
requireCommunity: true,
createBody: (item, communityId) => ({ prefix: item.ipRange, community_id: communityId }),
patchBody: (item, communityId) => ({ prefix: item.ipRange, community_id: communityId }),
});
}
async function saveAsnsData(currentItems, originalItems) {
await syncEntries({
moduleType: 'AS_PREFIXES',
moduleName: 'ASNs',
listPath: 'as-entries',
keyName: 'asn',
currentItems,
originalItems,
createBody: (item, communityId) => ({ asn: Number(item.asn), community_id: communityId }),
patchBody: (item, communityId) => ({ asn: Number(item.asn), community_id: communityId }),
});
}
async function saveCommunitiesData(currentItems) {
const existing = await paginate('/communities');
const existingByValue = new Map(existing.map((c) => [String(c.community || ''), c]));
const nextByValue = new Map();
for (const item of currentItems || []) {
const value = String(item?.value || '').trim();
if (!value) continue;
nextByValue.set(value, item);
}
for (const [value, row] of existingByValue.entries()) {
if (!nextByValue.has(value)) {
await evobgpRequest('DELETE', `/communities/${row.id}`);
}
}
for (const [value, item] of nextByValue.entries()) {
const title = String(item?.name || '').trim();
const row = existingByValue.get(value);
if (!row) {
await evobgpRequest('POST', '/communities', { body: { community: value, title } });
continue;
}
if (String(row.title || '') !== title) {
await evobgpRequest('PATCH', `/communities/${row.id}`, { body: { community: value, title } });
}
}
}
async function saveAutoUrlsData(urls) {
const originalItems = await fetchAutoUrlsData();
const list = (urls || []).map((u) => ({
url: String(u?.url || '').trim(),
community: String(u?.community || '').trim(),
})).filter((u) => u.url && u.community);
const modType = process.env.EVOBGP_AUTO_URL_MODULE_TYPE || 'AUTO_URLS';
const listPath = process.env.EVOBGP_AUTO_URL_LIST_PATH || 'url-entries';
const keyName = process.env.EVOBGP_AUTO_URL_KEY_FIELD || 'url';
const currentItems = list.map((u) => ({ [keyName]: u.url, community: u.community }));
const origMapped = originalItems.map((u) => ({ [keyName]: u.url, community: u.community }));
await syncEntries({
moduleType: modType,
moduleName: 'Auto URLs',
listPath,
keyName,
currentItems,
originalItems: origMapped,
requireCommunity: true,
createBody: (item, communityId) => ({ url: item[keyName], community_id: communityId }),
patchBody: (item, communityId) => ({ url: item[keyName], community_id: communityId }),
});
}
module.exports = {
isConfigured,
evobgpRequest,
paginate,
fetchRouterListsCatalog,
listCommunities,
fetchDomainsData,
fetchIpRangesData,
fetchAsnsData,
fetchAutoUrlsData,
saveCommunitiesData,
saveDomainsData,
saveIpRangesData,
saveAsnsData,
saveAutoUrlsData,
buildCommunityMaps,
mapCommunityToValue,
};
+124
View File
@@ -0,0 +1,124 @@
/**
* Глобальные и simple-фильтры в таблице app_filter_rules (не в blobs JSON).
*/
const crypto = require('crypto');
const { openDatabase } = require('../db/sqliteDb');
/** @typedef {'global'|'simple'} FilterScope */
function assertScope(scope) {
if (scope !== 'global' && scope !== 'simple') throw new Error('Invalid filter scope');
}
function mtimeMetaKey(scope) {
return `filter_rules_mtime_${scope}`;
}
function sha256EtagFromString(s) {
const h = crypto.createHash('sha256').update(Buffer.from(s, 'utf8')).digest('hex');
return `"${h}"`;
}
function rowToItem(row) {
let extra = {};
try {
extra = JSON.parse(row.extra_json || '{}');
if (!extra || typeof extra !== 'object' || Array.isArray(extra)) extra = {};
} catch {
extra = {};
}
const out = {
community: row.community,
gateway: row.gateway,
description: row.description || '',
};
for (const k of Object.keys(extra).sort()) {
if (k === 'community' || k === 'gateway' || k === 'description') continue;
out[k] = extra[k];
}
return out;
}
/**
* @param {FilterScope} scope
*/
function listRules(scope) {
assertScope(scope);
const db = openDatabase();
const rows = db
.prepare(
`SELECT community, gateway, description, extra_json FROM app_filter_rules WHERE scope = ? ORDER BY position ASC`,
)
.all(scope);
return rows.map(rowToItem);
}
/**
* @param {FilterScope} scope
* @returns {{ etag: string, lastModified: string|null, contentLength: number }}
*/
function headMetaForScope(scope) {
assertScope(scope);
const items = listRules(scope);
const body = JSON.stringify(items);
const etag = sha256EtagFromString(body);
const contentLength = Buffer.byteLength(body, 'utf8');
const db = openDatabase();
const row = db.prepare('SELECT value FROM app_meta WHERE key = ?').get(mtimeMetaKey(scope));
const lastModified = row?.value ? new Date(Number(row.value)).toISOString() : null;
return { etag, lastModified, contentLength };
}
/**
* @param {FilterScope} scope
* @param {any[]} items
* @param {{ validateItem?: (item: any, i: number) => string|null }} [opts]
*/
function replaceRules(scope, items, opts = {}) {
assertScope(scope);
const { validateItem } = opts;
if (!Array.isArray(items)) throw new Error('items must be an array');
if (validateItem) {
for (let i = 0; i < items.length; i++) {
const err = validateItem(items[i], i);
if (err) {
const e = new Error(err);
e.code = 'E_SCHEMA';
throw e;
}
}
}
const db = openDatabase();
const now = Date.now();
const del = db.prepare('DELETE FROM app_filter_rules WHERE scope = ?');
const ins = db.prepare(
`INSERT INTO app_filter_rules (scope, position, community, gateway, description, extra_json, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
);
const metaUpsert = db.prepare('INSERT OR REPLACE INTO app_meta (key, value) VALUES (?, ?)');
const run = () => {
del.run(scope);
for (let i = 0; i < items.length; i++) {
const it = items[i] || {};
const community = String(it.community || '').trim();
const gateway = String(it.gateway || '').trim();
const description = String(it.description || '').trim();
const extra = { ...it };
delete extra.community;
delete extra.gateway;
delete extra.description;
ins.run(scope, i, community, gateway, description, JSON.stringify(extra), now);
}
metaUpsert.run(mtimeMetaKey(scope), String(now));
};
db.transaction(run)();
return headMetaForScope(scope);
}
module.exports = {
listRules,
headMetaForScope,
replaceRules,
};
+2 -2
View File
@@ -4,7 +4,7 @@
* Работает внутри Node-процесса backend:
* - периодически обходит jumphost-сервера из servers.json
* - вызывает RouterOS REST API /rest/export (compact)
* - сохраняет конфиг в S3 через saveBackupForServer
* - сохраняет конфиг на диск через saveBackupForServer
*
* Управляется через переменные окружения:
* - MIKROTIK_BACKUP_ENABLED=true|false (по умолчанию true)
@@ -131,7 +131,7 @@ async function runBackupOnce(logger) {
log.info(
{ component: 'mikrotik-backup', serverId: id, key: result.key },
'Automatic backup saved to S3',
'Automatic backup saved to disk',
);
} catch (err) {
log.error(
+120 -243
View File
@@ -1,58 +1,35 @@
/**
* Сервис для работы с S3 (Yandex Object Storage)
* Централизованные операции чтения/записи/кэширования
* Локальное хранилище объектов (SQLite). Сохранены имена экспортов readS3TextObject / writeS3JsonObject и т.д. для совместимости с роутами.
* Справочники EvoBGP не хранятся здесь — см. evobgpClient.js
*/
const { S3Client, GetObjectCommand, HeadObjectCommand, PutObjectCommand, DeleteObjectCommand, ListObjectsV2Command } = require('@aws-sdk/client-s3');
const { NodeHttpHandler } = require('@smithy/node-http-handler');
const http = require('http');
const https = require('https');
const { openDatabase } = require('../db/sqliteDb');
const blobStorage = require('./blobStorage');
// S3 Client setup
const s3 = new S3Client({
endpoint: 'https://storage.yandexcloud.net',
region: process.env.AWS_REGION,
forcePathStyle: true,
maxAttempts: 3,
requestHandler: new NodeHttpHandler({
httpAgent: new http.Agent({ keepAlive: true }),
httpsAgent: new https.Agent({ keepAlive: true })
}),
credentials: {
accessKeyId: process.env.AWS_ACCESS_KEY_ID || process.env.S3_ACCESS_KEY_ID,
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY || process.env.S3_SECRET_ACCESS_KEY,
}
});
openDatabase();
const BUCKET_NAME = process.env.S3_BUCKET_NAME;
const BUCKET_NAME = process.env.SQLITE_BUCKET_LABEL || 'sqlite';
// In-memory cache
const cache = {
text: new Map(),
head: new Map(),
max: 100,
ttlMs: 30_000
ttlMs: 30_000,
};
/**
* Конвертирует stream в строку
*/
async function streamToString(stream) {
if (!stream) return '';
if (typeof stream.transformToString === 'function') {
return await stream.transformToString();
}
return await new Promise((resolve, reject) => {
let chunks = [];
stream.on('data', (c) => chunks.push(Buffer.isBuffer(c) ? c : Buffer.from(String(c))));
stream.once('error', reject);
stream.once('end', () => resolve(Buffer.concat(chunks).toString('utf-8')));
});
if (!stream) return '';
if (typeof stream.transformToString === 'function') {
return await stream.transformToString();
}
return await new Promise((resolve, reject) => {
const chunks = [];
stream.on('data', (c) => chunks.push(Buffer.isBuffer(c) ? c : Buffer.from(String(c))));
stream.once('error', reject);
stream.once('end', () => resolve(Buffer.concat(chunks).toString('utf-8')));
});
}
/**
* Получить значение из кэша
*/
function getCache(map, key) {
const v = map.get(key);
if (!v) return null;
@@ -63,9 +40,6 @@ function getCache(map, key) {
return v.value;
}
/**
* Установить значение в кэш
*/
function setCache(map, key, value) {
if (map.size >= cache.max) {
const firstKey = map.keys().next().value;
@@ -74,213 +48,115 @@ function setCache(map, key, value) {
map.set(key, { value, at: Date.now() });
}
/**
* Инвалидировать кэш для ключа
*/
function invalidateCacheForKey(key) {
try { cache.text.delete(key); } catch {}
try { cache.head.delete(key); } catch {}
}
/**
* Прочитать текстовый объект из S3
*/
async function readS3TextObject(key, s3Duration = null) {
const cached = getCache(cache.text, key);
if (cached) return cached;
const s3Start = Date.now();
const data = await s3.send(new GetObjectCommand({ Bucket: BUCKET_NAME, Key: key }));
if (s3Duration) {
try { s3Duration.labels('getObject').observe((Date.now() - s3Start)/1000); } catch {}
}
const out = {
body: await streamToString(data.Body),
etag: data.ETag || undefined,
lastModified: data.LastModified ? data.LastModified.toISOString() : undefined,
contentLength: typeof data.ContentLength === 'number' ? data.ContentLength : undefined
};
setCache(cache.text, key, out);
return out;
}
/**
* Получить ETag объекта из S3
*/
async function headS3ObjectEtag(key, s3Duration = null) {
const cached = getCache(cache.head, key);
if (cached && cached.etag) return cached.etag;
const s3Start = Date.now();
const head = await s3.send(new HeadObjectCommand({ Bucket: BUCKET_NAME, Key: key }));
if (s3Duration) {
try { s3Duration.labels('headObject').observe((Date.now() - s3Start)/1000); } catch {}
}
setCache(cache.head, key, { etag: head.ETag || undefined });
return head.ETag || undefined;
}
/**
* Получить метаданные объекта (etag, lastModified, contentLength)
*/
async function headMeta(key) {
try {
const h = await s3.send(new HeadObjectCommand({ Bucket: BUCKET_NAME, Key: key }));
return {
etag: h.ETag || null,
lastModified: h.LastModified ? new Date(h.LastModified).toISOString() : null,
contentLength: typeof h.ContentLength === 'number' ? h.ContentLength : null,
};
} catch (e) {
return { etag: null, lastModified: null, contentLength: null };
cache.text.delete(key);
} catch (_) {}
try {
cache.head.delete(key);
} catch (_) {}
}
async function readS3TextObject(key) {
const cached = getCache(cache.text, key);
if (cached) return cached;
const row = blobStorage.readBlob(key);
if (!row) {
const err = new Error('NoSuchKey');
err.code = 'NoSuchKey';
throw err;
}
}
/**
* Записать текстовый объект в S3
*/
async function writeS3TextObject(key, content, contentType = 'text/plain') {
await s3.send(new PutObjectCommand({
Bucket: BUCKET_NAME,
Key: key,
Body: content,
ContentType: contentType,
}));
invalidateCacheForKey(key);
return await headMeta(key);
}
/**
* Записать JSON объект в S3
*/
async function writeS3JsonObject(key, data) {
return await writeS3TextObject(key, JSON.stringify(data, null, 2), 'application/json');
}
/**
* Удалить объект из S3
*/
async function deleteS3Object(key) {
await s3.send(new DeleteObjectCommand({ Bucket: BUCKET_NAME, Key: key }));
invalidateCacheForKey(key);
}
/**
* Список объектов в S3 по префиксу
* Используется для истории/бэкапов (количество ограничено для безопасности)
*/
async function listS3Objects(prefix, { maxKeys = 100 } = {}) {
const out = [];
let continuationToken = undefined;
while (out.length < maxKeys) {
const resp = await s3.send(new ListObjectsV2Command({
Bucket: BUCKET_NAME,
Prefix: prefix,
ContinuationToken: continuationToken,
MaxKeys: Math.min(1000, maxKeys - out.length),
}));
const contents = resp.Contents || [];
for (const obj of contents) {
out.push({
key: obj.Key,
size: typeof obj.Size === 'number' ? obj.Size : null,
lastModified: obj.LastModified ? new Date(obj.LastModified).toISOString() : null,
etag: obj.ETag || null,
});
if (out.length >= maxKeys) break;
}
if (!resp.IsTruncated || !resp.NextContinuationToken || out.length >= maxKeys) {
break;
}
continuationToken = resp.NextContinuationToken;
}
const out = {
body: row.body,
etag: row.etag,
lastModified: row.lastModified,
contentLength: row.contentLength,
};
setCache(cache.text, key, out);
return out;
}
/**
* Потоковое чтение с пагинацией больших текстовых файлов
*/
async function streamPaginatedText({ key, mapLine, q, offset = 0, limit = 0 }) {
return new Promise(async (resolve, reject) => {
let total = 0;
const items = [];
let sent = 0;
let buffered = '';
const matchesQuery = (line) => {
if (!q) return true;
return line.toLowerCase().includes(String(q).toLowerCase());
};
try {
const resp = await s3.send(new GetObjectCommand({ Bucket: BUCKET_NAME, Key: key }));
const stream = resp.Body;
if (!stream || typeof stream.on !== 'function') {
const text = await streamToString(resp.Body);
const lines = text.split('\n');
for (let i = 0; i < lines.length; i++) {
const line = String(lines[i] || '').trim();
if (!line) continue;
if (!matchesQuery(line)) continue;
total++;
const pos = total - 1;
if (limit > 0) {
if (pos >= offset && sent < limit) { items.push(mapLine(line)); sent++; }
} else {
items.push(mapLine(line));
}
}
return resolve({ items, total });
}
stream.on('data', (chunk) => {
buffered += chunk.toString('utf-8');
let lines = buffered.split('\n');
buffered = lines.pop();
for (const lnRaw of lines) {
const line = lnRaw.trim();
if (!line) continue;
if (!matchesQuery(line)) continue;
total++;
const pos = total - 1;
if (limit > 0) {
if (pos >= offset && sent < limit) { items.push(mapLine(line)); sent++; }
} else {
items.push(mapLine(line));
}
}
});
stream.on('end', () => {
const last = (buffered || '').trim();
if (last) {
if (!q || last.toLowerCase().includes(String(q).toLowerCase())) {
total++;
if (limit > 0) {
const pos = total - 1;
if (pos >= offset && items.length < limit) items.push(mapLine(last));
} else {
items.push(mapLine(last));
}
}
}
resolve({ items, total });
});
stream.on('error', reject);
} catch (e) {
reject(e);
}
});
async function headS3ObjectEtag(key) {
const cached = getCache(cache.head, key);
if (cached && cached.etag) return cached.etag;
const etag = blobStorage.headEtag(key);
if (etag) setCache(cache.head, key, { etag });
return etag;
}
async function headMeta(key) {
return blobStorage.headMetaRow(key);
}
async function writeS3TextObject(key, content, contentType = 'text/plain') {
const db = openDatabase();
blobStorage.writeBlobTx(db, key, content, contentType);
invalidateCacheForKey(key);
return headMeta(key);
}
async function writeS3JsonObject(key, data) {
return writeS3TextObject(key, JSON.stringify(data, null, 2), 'application/json');
}
async function deleteS3Object(key) {
blobStorage.deleteBlob(key);
invalidateCacheForKey(key);
}
async function listS3Objects(prefix, { maxKeys = 100 } = {}) {
return blobStorage.listByPrefix(prefix, maxKeys);
}
async function streamPaginatedText({ key, mapLine, q, offset = 0, limit = 0 }) {
let text = '';
try {
const row = await readS3TextObject(key);
text = row.body || '';
} catch (e) {
if (e?.code === 'NoSuchKey') {
return { items: [], total: 0 };
}
throw e;
}
const lines = text.split('\n');
let total = 0;
const items = [];
const qstr = q ? String(q).toLowerCase() : '';
const matchesQuery = (line) => !qstr || line.toLowerCase().includes(qstr);
for (let i = 0; i < lines.length; i++) {
const line = String(lines[i] || '').trim();
if (!line) continue;
if (!matchesQuery(line)) continue;
total++;
const pos = total - 1;
if (limit > 0) {
if (pos >= offset && items.length < limit) items.push(mapLine(line));
} else {
items.push(mapLine(line));
}
}
return { items, total };
}
function listBlobVersionsForKey(key, maxKeys = 50) {
return blobStorage.listVersions(key, maxKeys);
}
function rollbackBlobVersion(key, versionId) {
openDatabase();
return blobStorage.rollbackToVersionId(key, versionId);
}
/** Заглушка: прямой вызов S3 SDK больше не используется. */
const s3 = {
send() {
throw new Error('S3 SDK removed: use readS3TextObject / writeS3TextObject or evobgpClient');
},
};
module.exports = {
s3,
BUCKET_NAME,
@@ -294,5 +170,6 @@ module.exports = {
streamPaginatedText,
invalidateCacheForKey,
listS3Objects,
listBlobVersionsForKey,
rollbackBlobVersion,
};