feat: реализовать EvoFirewall V1 control plane
Build and Push EvoFirewall Docker Image / build-and-push (push) Failing after 25s
Build and Push EvoFirewall Docker Image / create-release (push) Skipped

API, UI, Linux/MikroTik agents, IP lists, политики, stats, CI и интеграция с auth-portal/EvoBGP.

Co-authored-by: Cursor <[email protected]>
This commit is contained in:
Denozordec
2026-07-20 19:50:54 +07:00
co-authored by Cursor
parent d71b45d86f
commit ebadf70e2b
107 changed files with 15196 additions and 99 deletions
+174
View File
@@ -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)
}
}
+85
View File
@@ -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,
}
}