import type { Db } from '@evofw/db' import { repos } from '@evofw/db' import { refreshAllHostnameRules } from '../policy/resolve-hostname.js' import { rebuildListCascade, publishListCascade } from './entries.js' import { uniqCidrs } from '../uniq.js' import { decryptSecret } from '../secret-cipher.js' import { fetchEvobgpPages } from '../evobgp.js' import { safeFetchJson, SOURCE_LIMITS } from '../safe-fetch.js' async function fetchJsonUrl(url: string): Promise { const { data } = await safeFetchJson(url) const out: string[] = [] let recognized = false const push = (v: unknown) => { if (out.length >= SOURCE_LIMITS.elements) throw new Error('JSON prefix element limit exceeded') if (typeof v !== 'string' || !v.trim()) throw new Error('Invalid JSON prefix item') out.push(v.trim()) } if (Array.isArray(data)) { recognized = true 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 throw new Error('Invalid JSON prefix item') } } 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)) { recognized = true 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) } else throw new Error('Invalid JSON prefix item') } } } if (!recognized) throw new Error('Invalid JSON prefix response') 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 pages = await fetchEvobgpPages(base, token, '/v1/communities', 200) const items = pages.flatMap((page) => { if (!Array.isArray(page.items)) throw new Error('Invalid EvoBGP communities') return page.items as { id?: string; community?: string; title?: string | null }[] }) const hit = 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 pages = await fetchEvobgpPages(base, token, `/v1/communities/${encodeURIComponent(resolvedId)}/prefixes`, 5000) const cidrs = pages.flatMap((page) => { if (Array.isArray(page.prefixes)) return page.prefixes as string[] if (Array.isArray(page.items)) return page.items.map((item) => { if (!item || typeof item !== 'object' || typeof item.prefix !== 'string') throw new Error('Invalid EvoBGP prefix item') return item.prefix as string }) throw new Error('Invalid EvoBGP prefix page') }) return { cidrs: uniqCidrs(cidrs), resolvedId } } let activeRefreshes = 0 const waiting: (() => void)[] = [] const refreshing = new WeakMap>>() async function withRefreshSlot(work: () => Promise): Promise { if (activeRefreshes >= 2) await new Promise(resolve => waiting.push(resolve)) else activeRefreshes++ try { await work() } finally { const next = waiting.shift() if (next) next() else activeRefreshes-- } } /** Coalesce duplicate triggers per DB/list; at most two refreshes process-wide. */ export function refreshIpList(db: Db, listId: string): Promise { let running = refreshing.get(db) if (!running) { running = new Map(); refreshing.set(db, running) } const existing = running.get(listId) if (existing) return existing const task = withRefreshSlot(() => performRefresh(db, listId)).finally(() => running!.delete(listId)) running.set(listId, task) return task } async function performRefresh(db: Db, listId: string): Promise { const list = repos.getIpList(db, listId) if (!list) return const sourceSettings = [repos.getSetting(db, 'evobgp_api_url'), repos.getSetting(db, 'evobgp_api_token')] let config: Record = {} try { config = JSON.parse(list.configJson || '{}') as Record } catch { config = {} } try { let cidrs: string[] = [] if (list.type === 'static' || list.type === 'domains') { await rebuildListCascade(db, listId) repos.updateIpList(db, listId, { refreshedAt: new Date().toISOString(), lastError: null }) return } else if (list.type === 'json_url') { const url = String(config.url ?? '') if (!url) throw new Error('config.url required') cidrs = await fetchJsonUrl(url) } 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 // Persist resolved UUID if list was saved with autocomplete label. if (fetched.resolvedId !== communityId) { config.community_id = fetched.resolvedId } } else throw new Error('Unsupported list source type') if (!cidrs.length && config.allow_empty !== true) throw new Error('Empty source snapshot quarantined; set config.allow_empty=true to publish it') if (list.type === 'evobgp_community' && sourceSettings.some((value, index) => value !== repos.getSetting(db, index === 0 ? 'evobgp_api_url' : 'evobgp_api_token'))) { throw new Error('EvoBGP settings changed during refresh; retry refresh') } await publishListCascade(db, listId, { cidrs, configJson: JSON.stringify(config), expected: list }) } catch (err) { const current = repos.getIpList(db, listId) if (!current || current.configJson !== list.configJson || current.type !== list.type || current.contentHash !== list.contentHash || current.updatedAt !== list.updatedAt) return repos.updateIpList(db, listId, { lastError: (err instanceof Error ? err.message : String(err)).slice(0, 512), }) } } export async function refreshAllLists(db: Db): Promise { for (const list of repos.listIpLists(db)) { await refreshIpList(db, list.id) } await refreshAllHostnameRules(db) }