feat: реализовать EvoFirewall V1 control plane
API, UI, Linux/MikroTik agents, IP lists, политики, stats, CI и интеграция с auth-portal/EvoBGP. Co-authored-by: Cursor <[email protected]>
This commit is contained in:
@@ -0,0 +1,174 @@
|
||||
import { createHash } from 'node:crypto'
|
||||
import { resolve4, resolve6 } from 'node:dns/promises'
|
||||
import type { Db } from '@evofw/db'
|
||||
import { repos } from '@evofw/db'
|
||||
|
||||
function uniq(cidrs: string[]): string[] {
|
||||
return [...new Set(cidrs.map((c) => c.trim()).filter(Boolean))].sort()
|
||||
}
|
||||
|
||||
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 uniq(out)
|
||||
}
|
||||
|
||||
async function resolveDomains(domains: string[]): Promise<string[]> {
|
||||
const out: string[] = []
|
||||
for (const d of domains) {
|
||||
const host = d.trim().replace(/\.$/, '')
|
||||
if (!host) continue
|
||||
try {
|
||||
const a = await resolve4(host)
|
||||
out.push(...a.map((ip) => `${ip}/32`))
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
try {
|
||||
const aaaa = await resolve6(host)
|
||||
out.push(...aaaa.map((ip) => `${ip}/128`))
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
return uniq(out)
|
||||
}
|
||||
|
||||
async function fetchEvobgpCommunity(
|
||||
apiUrl: string,
|
||||
token: string,
|
||||
communityId: string,
|
||||
): Promise<string[]> {
|
||||
const base = apiUrl.replace(/\/$/, '')
|
||||
// Prefer published revision prefixes filtered by community when available.
|
||||
const url = `${base}/v1/directories/communities/${encodeURIComponent(communityId)}/prefixes`
|
||||
const res = await fetch(url, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
Accept: 'application/json',
|
||||
},
|
||||
signal: AbortSignal.timeout(45_000),
|
||||
})
|
||||
if (res.ok) {
|
||||
const data = (await res.json()) as { items?: { prefix?: string }[]; prefixes?: string[] }
|
||||
if (Array.isArray(data.prefixes)) return uniq(data.prefixes)
|
||||
if (Array.isArray(data.items)) {
|
||||
return uniq(data.items.map((i) => i.prefix ?? '').filter(Boolean))
|
||||
}
|
||||
}
|
||||
// Fallback: modules lookup / openapi-compatible list
|
||||
const alt = `${base}/v1/lookup?q=${encodeURIComponent(communityId)}`
|
||||
const res2 = await fetch(alt, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
Accept: 'application/json',
|
||||
},
|
||||
signal: AbortSignal.timeout(45_000),
|
||||
})
|
||||
if (!res2.ok) {
|
||||
throw new Error(`EvoBGP community fetch failed: ${res.status}/${res2.status}`)
|
||||
}
|
||||
const data2 = (await res2.json()) as { prefixes?: string[] }
|
||||
return uniq(data2.prefixes ?? [])
|
||||
}
|
||||
|
||||
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') {
|
||||
cidrs = repos.listIpListEntries(db, listId).map((e) => e.cidr)
|
||||
} 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 === 'domains') {
|
||||
const domains = Array.isArray(config.domains)
|
||||
? (config.domains as string[])
|
||||
: String(config.domains ?? '')
|
||||
.split(/[\s,]+/)
|
||||
.filter(Boolean)
|
||||
cidrs = await resolveDomains(domains)
|
||||
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 =
|
||||
String(config.api_token ?? '') ||
|
||||
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')
|
||||
}
|
||||
cidrs = await fetchEvobgpCommunity(apiUrl, token, communityId)
|
||||
repos.replaceIpListEntries(db, listId, cidrs)
|
||||
}
|
||||
|
||||
const contentHash = hashCidrs(cidrs)
|
||||
repos.updateIpList(db, listId, {
|
||||
contentHash,
|
||||
refreshedAt: new Date().toISOString(),
|
||||
lastError: null,
|
||||
})
|
||||
|
||||
// Bump all agents so they re-fetch policy
|
||||
for (const a of repos.listAgents(db)) {
|
||||
if (a.status === 'approved') repos.bumpAgentGeneration(db, a.id)
|
||||
}
|
||||
} 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)) {
|
||||
if (list.type === 'static') continue
|
||||
await refreshIpList(db, list.id)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import { createHash } from 'node:crypto'
|
||||
import type { Db } from '@evofw/db'
|
||||
import { repos } from '@evofw/db'
|
||||
|
||||
export type EvaluatedPolicy = {
|
||||
generation: number
|
||||
hash: string
|
||||
policyMode: 'blacklist' | 'whitelist'
|
||||
denyCidrs: string[]
|
||||
allowCidrs: string[]
|
||||
syncIntervalSec: number
|
||||
}
|
||||
|
||||
function uniq(cidrs: string[]): string[] {
|
||||
const seen = new Set<string>()
|
||||
const out: string[] = []
|
||||
for (const c of cidrs) {
|
||||
const t = c.trim()
|
||||
if (!t || seen.has(t)) continue
|
||||
seen.add(t)
|
||||
out.push(t)
|
||||
}
|
||||
return out.sort()
|
||||
}
|
||||
|
||||
function expandList(db: Db, listId: string | null | undefined): string[] {
|
||||
if (!listId) return []
|
||||
return repos.listIpListEntries(db, listId).map((e) => e.cidr)
|
||||
}
|
||||
|
||||
/** Evaluate allow/deny sets for an agent. */
|
||||
export function evaluateAgentPolicy(db: Db, agentId: string): EvaluatedPolicy {
|
||||
const agent = repos.getAgent(db, agentId)
|
||||
if (!agent) {
|
||||
throw new Error(`agent not found: ${agentId}`)
|
||||
}
|
||||
|
||||
const agentRules = repos.listPolicyRules(db, agentId)
|
||||
const tenantRules = repos.listPolicyRules(db, null)
|
||||
const ordered = [...agentRules, ...tenantRules].sort(
|
||||
(a, b) => a.priority - b.priority,
|
||||
)
|
||||
|
||||
const deny: string[] = []
|
||||
const allow: string[] = []
|
||||
|
||||
for (const rule of ordered) {
|
||||
const cidrs = rule.cidr
|
||||
? [rule.cidr]
|
||||
: expandList(db, rule.listId)
|
||||
if (rule.action === 'deny') deny.push(...cidrs)
|
||||
else allow.push(...cidrs)
|
||||
}
|
||||
|
||||
for (const o of repos.listOverrides(db, agentId)) {
|
||||
if (o.action === 'deny') deny.push(o.cidr)
|
||||
else allow.push(o.cidr)
|
||||
}
|
||||
|
||||
const denyCidrs = uniq(deny)
|
||||
const allowCidrs = uniq(allow)
|
||||
const policyMode = (agent.policyMode === 'whitelist'
|
||||
? 'whitelist'
|
||||
: 'blacklist') as 'blacklist' | 'whitelist'
|
||||
|
||||
const payload = JSON.stringify({
|
||||
generation: agent.policyGeneration,
|
||||
policyMode,
|
||||
denyCidrs,
|
||||
allowCidrs,
|
||||
})
|
||||
const hash = `sha256:${createHash('sha256').update(payload).digest('hex')}`
|
||||
|
||||
const syncIntervalSec =
|
||||
Number(repos.getSetting(db, 'agent_sync_interval_sec') || '60') || 60
|
||||
|
||||
return {
|
||||
generation: agent.policyGeneration,
|
||||
hash,
|
||||
policyMode,
|
||||
denyCidrs,
|
||||
allowCidrs,
|
||||
syncIntervalSec,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user