- прод-режим отказывается стартовать без AUTH_REQUIRED и реальных секретов (opt-out через EVOFW_ALLOW_UNSAFE) - CORS: whitelist через CORS_ORIGINS вместо origin:true; CSP для раздаваемого SPA - транзакции для setAgentPolicySets, reorderPolicyRules, replaceResolvedForRule, replaceIpListEntries - install-скрипты: Zod-валидация имени ссылки, экранирование $ и контрольных символов в RouterOS-рендере - constant-time сравнение enroll-seed - опциональное шифрование токена EvoBGP в БД (EVOFW_SECRET_KEY, AES-256-GCM) и маскирование per-list api_token в ответах - graceful shutdown (SIGTERM/SIGINT) + тесты
198 lines
6.3 KiB
TypeScript
198 lines
6.3 KiB
TypeScript
import { createHash } from 'node:crypto'
|
|
import type { Db } from '@evofw/db'
|
|
import { repos } from '@evofw/db'
|
|
import { refreshAllHostnameRules } from '../policy/resolve-hostname.js'
|
|
import {
|
|
findParentListIds,
|
|
rebuildListCascade,
|
|
rebuildManualListEntries,
|
|
} from './entries.js'
|
|
import { uniqCidrs } from '../uniq.js'
|
|
import { decryptSecret } from '../secret-cipher.js'
|
|
|
|
function hashCidrs(cidrs: string[]): string {
|
|
return `sha256:${createHash('sha256').update(cidrs.join('\n')).digest('hex')}`
|
|
}
|
|
|
|
async function fetchJsonUrl(url: string): Promise<string[]> {
|
|
const res = await fetch(url, {
|
|
headers: { Accept: 'application/json' },
|
|
signal: AbortSignal.timeout(30_000),
|
|
})
|
|
if (!res.ok) throw new Error(`JSON URL HTTP ${res.status}`)
|
|
const data = (await res.json()) as unknown
|
|
const out: string[] = []
|
|
const push = (v: unknown) => {
|
|
if (typeof v === 'string' && v.trim()) out.push(v.trim())
|
|
}
|
|
if (Array.isArray(data)) {
|
|
for (const item of data) {
|
|
if (typeof item === 'string') push(item)
|
|
else if (item && typeof item === 'object') {
|
|
const o = item as Record<string, unknown>
|
|
push(o.cidr ?? o.prefix ?? o.ip ?? o.network)
|
|
}
|
|
}
|
|
} else if (data && typeof data === 'object') {
|
|
const o = data as Record<string, unknown>
|
|
const arr = (o.prefixes ?? o.cidrs ?? o.ips ?? o.items) as unknown
|
|
if (Array.isArray(arr)) {
|
|
for (const item of arr) {
|
|
if (typeof item === 'string') push(item)
|
|
else if (item && typeof item === 'object') {
|
|
const x = item as Record<string, unknown>
|
|
push(x.cidr ?? x.prefix ?? x.ip)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return uniqCidrs(out)
|
|
}
|
|
|
|
const UUID_RE =
|
|
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
|
|
|
|
async function resolveEvobgpCommunityId(
|
|
base: string,
|
|
token: string,
|
|
communityIdOrLabel: string,
|
|
): Promise<string> {
|
|
const key = communityIdOrLabel.trim()
|
|
if (!key) throw new Error('community_id required')
|
|
if (UUID_RE.test(key)) return key
|
|
|
|
// Heal lists created when Autocomplete stored label instead of UUID.
|
|
const res = await fetch(`${base}/v1/communities?limit=200`, {
|
|
headers: {
|
|
Authorization: `Bearer ${token}`,
|
|
Accept: 'application/json',
|
|
},
|
|
signal: AbortSignal.timeout(20_000),
|
|
})
|
|
if (!res.ok) {
|
|
throw new Error(`EvoBGP communities HTTP ${res.status}`)
|
|
}
|
|
const data = (await res.json()) as {
|
|
items?: { id?: string; community?: string; title?: string | null }[]
|
|
}
|
|
const hit = (data.items ?? []).find((c) => {
|
|
if (!c.id || !c.community) return false
|
|
if (c.id === key || c.community === key) return true
|
|
if (c.title && `${c.community} · ${c.title}` === key) return true
|
|
return false
|
|
})
|
|
if (!hit?.id) {
|
|
throw new Error(`EvoBGP community not found: ${key}`)
|
|
}
|
|
return hit.id
|
|
}
|
|
|
|
async function fetchEvobgpCommunity(
|
|
apiUrl: string,
|
|
token: string,
|
|
communityId: string,
|
|
): Promise<{ cidrs: string[]; resolvedId: string }> {
|
|
const base = apiUrl.replace(/\/$/, '')
|
|
const resolvedId = await resolveEvobgpCommunityId(base, token, communityId)
|
|
const url = `${base}/v1/communities/${encodeURIComponent(resolvedId)}/prefixes?limit=5000`
|
|
const res = await fetch(url, {
|
|
headers: {
|
|
Authorization: `Bearer ${token}`,
|
|
Accept: 'application/json',
|
|
},
|
|
signal: AbortSignal.timeout(45_000),
|
|
})
|
|
if (!res.ok) {
|
|
throw new Error(`EvoBGP community prefixes HTTP ${res.status}`)
|
|
}
|
|
const data = (await res.json()) as {
|
|
items?: { prefix?: string }[]
|
|
prefixes?: string[]
|
|
}
|
|
let cidrs: string[] = []
|
|
if (Array.isArray(data.prefixes) && data.prefixes.length > 0) {
|
|
cidrs = uniqCidrs(data.prefixes)
|
|
} else if (Array.isArray(data.items)) {
|
|
cidrs = uniqCidrs(data.items.map((i) => i.prefix ?? '').filter(Boolean))
|
|
}
|
|
return { cidrs, resolvedId }
|
|
}
|
|
|
|
export async function refreshIpList(db: Db, listId: string): Promise<void> {
|
|
const list = repos.getIpList(db, listId)
|
|
if (!list) return
|
|
|
|
let config: Record<string, unknown> = {}
|
|
try {
|
|
config = JSON.parse(list.configJson || '{}') as Record<string, unknown>
|
|
} catch {
|
|
config = {}
|
|
}
|
|
|
|
try {
|
|
let cidrs: string[] = []
|
|
if (list.type === 'static' || list.type === 'domains') {
|
|
cidrs = await rebuildManualListEntries(db, listId)
|
|
} else if (list.type === 'json_url') {
|
|
const url = String(config.url ?? '')
|
|
if (!url) throw new Error('config.url required')
|
|
cidrs = await fetchJsonUrl(url)
|
|
repos.replaceIpListEntries(db, listId, cidrs)
|
|
} else if (list.type === 'evobgp_community') {
|
|
const apiUrl =
|
|
String(config.api_url ?? '') ||
|
|
repos.getSetting(db, 'evobgp_api_url') ||
|
|
''
|
|
const token =
|
|
decryptSecret(String(config.api_token ?? '') || null) ??
|
|
decryptSecret(repos.getSetting(db, 'evobgp_api_token'))
|
|
const communityId = String(config.community_id ?? '')
|
|
if (!apiUrl || !token || !communityId) {
|
|
throw new Error('evobgp_api_url, token and community_id required')
|
|
}
|
|
const fetched = await fetchEvobgpCommunity(apiUrl, token, communityId)
|
|
cidrs = fetched.cidrs
|
|
repos.replaceIpListEntries(db, listId, cidrs)
|
|
// Persist resolved UUID if list was saved with autocomplete label.
|
|
if (fetched.resolvedId !== communityId) {
|
|
repos.updateIpList(db, listId, {
|
|
configJson: JSON.stringify({
|
|
...config,
|
|
community_id: fetched.resolvedId,
|
|
}),
|
|
})
|
|
}
|
|
}
|
|
|
|
const contentHash = hashCidrs(cidrs)
|
|
repos.updateIpList(db, listId, {
|
|
contentHash,
|
|
refreshedAt: new Date().toISOString(),
|
|
lastError: null,
|
|
})
|
|
|
|
// Cascade to parents that nest this list (skip self rebuild for non-manual —
|
|
// CIDRs already replaced above).
|
|
if (list.type === 'static' || list.type === 'domains') {
|
|
await rebuildListCascade(db, listId)
|
|
} else {
|
|
repos.bumpAgentsForList(db, listId)
|
|
for (const parentId of findParentListIds(db, listId)) {
|
|
await rebuildListCascade(db, parentId)
|
|
}
|
|
}
|
|
} catch (err) {
|
|
repos.updateIpList(db, listId, {
|
|
lastError: err instanceof Error ? err.message : String(err),
|
|
refreshedAt: new Date().toISOString(),
|
|
})
|
|
}
|
|
}
|
|
|
|
export async function refreshAllLists(db: Db): Promise<void> {
|
|
for (const list of repos.listIpLists(db)) {
|
|
await refreshIpList(db, list.id)
|
|
}
|
|
await refreshAllHostnameRules(db)
|
|
}
|