quality / changes (push) Successful in 7s
quality / commitlint (push) Skipped
quality / openapi (push) Successful in 30s
quality / web (push) Successful in 1m3s
quality / api (push) Successful in 1m15s
quality / linux-agent-smoke (push) Failing after 27s
quality / docker-check (push) Failing after 2m28s
CD / quality (push) Failing after 5m52s
CD / publish (push) Skipped
Implement reliable agent apply, telemetry v2, analytics retention, guarded rollouts, enrollment, labels and webhooks. Improve ReUI frontend data safety and bounded list loading, and add Docker smoke, recovery and resource checks. Document local verification and pending native acceptance.
182 lines
7.2 KiB
TypeScript
182 lines
7.2 KiB
TypeScript
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<string[]> {
|
|
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<string, unknown>
|
|
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<string, unknown>
|
|
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<string, unknown>
|
|
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<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 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<Db, Map<string, Promise<void>>>()
|
|
|
|
async function withRefreshSlot(work: () => Promise<void>): Promise<void> {
|
|
if (activeRefreshes >= 2) await new Promise<void>(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<void> {
|
|
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<void> {
|
|
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<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') {
|
|
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<void> {
|
|
for (const list of repos.listIpLists(db)) {
|
|
await refreshIpList(db, list.id)
|
|
}
|
|
await refreshAllHostnameRules(db)
|
|
}
|