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 { 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 push(o.cidr ?? o.prefix ?? o.ip ?? o.network) } } } else if (data && typeof data === 'object') { const o = data as Record 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 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 { 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 { const list = repos.getIpList(db, listId) if (!list) return let config: Record = {} try { config = JSON.parse(list.configJson || '{}') as Record } 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 { for (const list of repos.listIpLists(db)) { await refreshIpList(db, list.id) } await refreshAllHostnameRules(db) }