feat(api, web): enhance agent management and linting capabilities
Build and Push EvoFirewall Docker Image / build-and-push (push) Successful in 2m17s
Build and Push EvoFirewall Docker Image / create-release (push) Skipped

- Added a new linting command for OpenAPI specifications in the package.json, improving code quality checks.
- Updated frontend documentation to clarify component usage and structure, including detailed descriptions for `SettingsShell` and `Auth callback`.
- Refactored agent-related API routes to streamline control-plane functionalities, consolidating multiple routes for better organization.
- Improved error handling in the API to provide more informative responses for validation errors, enhancing user feedback during interactions.

These changes enhance the overall development experience and improve the management of agents within the application.
This commit is contained in:
Denozordec
2026-07-30 14:13:05 +07:00
parent fb95ef22b3
commit f160992d94
68 changed files with 3155 additions and 6899 deletions
+106
View File
@@ -0,0 +1,106 @@
import { describe, it, expect, afterAll } from 'vitest'
import { buildApp } from '../app.js'
import type { AppConfig } from '../config.js'
const testConfig: AppConfig = {
databaseUrl: 'sqlite::memory:',
jwtSecret: 'test',
jwtTtlHours: 24,
serverPort: 8080,
staticDir: null,
logLevel: 'error',
authRequired: false,
authIssuer: 'https://auth.test',
authPortalUrl: 'http://localhost:5175',
publicBaseUrl: 'https://fw.example.com',
enrollSeed: 'test-seed',
}
describe('agents CRUD critical paths', () => {
const appPromise = buildApp({ memory: true, config: testConfig })
afterAll(async () => {
const app = await appPromise
await app.close()
})
it('creates invite, approves, lists with install curl', async () => {
const app = await appPromise
await app.ready()
const created = await app.inject({
method: 'POST',
url: '/api/v1/install-links',
payload: { name: 'ops-01', platform: 'linux' },
})
expect(created.statusCode).toBe(201)
const link = created.json() as { agent_id: string }
const approve = await app.inject({
method: 'POST',
url: `/api/v1/agents/${link.agent_id}/approve`,
})
expect(approve.statusCode).toBe(200)
const list = await app.inject({ method: 'GET', url: '/api/v1/agents' })
expect(list.statusCode).toBe(200)
const items = (list.json() as { items: { id: string; status: string }[] })
.items
const agent = items.find((a) => a.id === link.agent_id)
expect(agent?.status).toBe('approved')
})
it('creates policy set + rule and reorders', async () => {
const app = await appPromise
await app.ready()
const setRes = await app.inject({
method: 'POST',
url: '/api/v1/policy-sets',
payload: { name: 'test-set' },
})
expect(setRes.statusCode).toBe(200)
const set = setRes.json() as { id: string }
const r1 = await app.inject({
method: 'POST',
url: '/api/v1/rules',
payload: {
set_id: set.id,
action: 'deny',
cidr: '1.1.1.1/32',
},
})
expect(r1.statusCode).toBe(200)
const rule1 = r1.json() as { id: string }
const r2 = await app.inject({
method: 'POST',
url: '/api/v1/rules',
payload: {
set_id: set.id,
action: 'allow',
cidr: '8.8.8.8/32',
},
})
expect(r2.statusCode).toBe(200)
const rule2 = r2.json() as { id: string }
const reorder = await app.inject({
method: 'PUT',
url: `/api/v1/policy-sets/${set.id}/rules/reorder`,
payload: {
ordered_ids: [rule2.id, rule1.id],
},
})
expect(reorder.statusCode).toBe(200)
const rules = await app.inject({
method: 'GET',
url: `/api/v1/policy-sets/${set.id}/rules`,
})
expect(rules.statusCode).toBe(200)
const items = (rules.json() as { items: { id: string }[] }).items
expect(items[0]?.id).toBe(rule2.id)
})
})
+2 -5
View File
@@ -11,10 +11,7 @@ import {
type ListEntryInput,
} from '@evofw/shared'
import { resolveHostnameToCidrs } from '../policy/resolve-hostname.js'
function uniq(cidrs: string[]): string[] {
return [...new Set(cidrs.map((c) => c.trim()).filter(Boolean))].sort()
}
import { uniqCidrs } from '../uniq.js'
export function getListConfig(list: {
configJson: string
@@ -129,7 +126,7 @@ export async function rebuildManualListEntries(
delete config.domains
repos.updateIpList(db, listId, { configJson: JSON.stringify(config) })
const cidrs = uniq(all)
const cidrs = uniqCidrs(all)
repos.replaceIpListEntries(db, listId, cidrs)
return cidrs
} finally {
+4 -7
View File
@@ -7,10 +7,7 @@ import {
rebuildListCascade,
rebuildManualListEntries,
} from './entries.js'
function uniq(cidrs: string[]): string[] {
return [...new Set(cidrs.map((c) => c.trim()).filter(Boolean))].sort()
}
import { uniqCidrs } from '../uniq.js'
function hashCidrs(cidrs: string[]): string {
return `sha256:${createHash('sha256').update(cidrs.join('\n')).digest('hex')}`
@@ -48,7 +45,7 @@ async function fetchJsonUrl(url: string): Promise<string[]> {
}
}
}
return uniq(out)
return uniqCidrs(out)
}
const UUID_RE =
@@ -113,9 +110,9 @@ async function fetchEvobgpCommunity(
}
let cidrs: string[] = []
if (Array.isArray(data.prefixes) && data.prefixes.length > 0) {
cidrs = uniq(data.prefixes)
cidrs = uniqCidrs(data.prefixes)
} else if (Array.isArray(data.items)) {
cidrs = uniq(data.items.map((i) => i.prefix ?? '').filter(Boolean))
cidrs = uniqCidrs(data.items.map((i) => i.prefix ?? '').filter(Boolean))
}
return { cidrs, resolvedId }
}
+3 -14
View File
@@ -6,6 +6,7 @@ import {
legacyModeFromDefaultAction,
type DefaultAction,
} from '@evofw/shared'
import { uniqCidrs } from '../uniq.js'
export const POLICY_APPLY_VERSION = 2 as const
@@ -42,18 +43,6 @@ export type EvaluatedPolicy = {
}
}
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)
@@ -159,9 +148,9 @@ export function evaluateAgentPolicy(db: Db, agentId: string): EvaluatedPolicy {
})
}
const denyCidrs = uniq(deny)
const denyCidrs = uniqCidrs(deny)
const denySet = new Set(denyCidrs)
const allowRaw = uniq(allow)
const allowRaw = uniqCidrs(allow)
const allowCidrs = allowRaw.filter((c) => !denySet.has(c))
const conflictsDropped = allowRaw.length - allowCidrs.length
const defaultAction = resolveDefaultAction(agent.defaultAction)
@@ -2,10 +2,7 @@ import { resolve4, resolve6 } from 'node:dns/promises'
import { createHash } from 'node:crypto'
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()
}
import { uniqCidrs } from '../uniq.js'
function hashCidrs(cidrs: string[]): string {
return `sha256:${createHash('sha256').update(cidrs.join('\n')).digest('hex')}`
@@ -30,7 +27,7 @@ export async function resolveHostnameToCidrs(hostname: string): Promise<string[]
/* ignore AAAA failures */
}
const cidrs = uniq(out)
const cidrs = uniqCidrs(out)
if (cidrs.length === 0) {
throw new Error(`DNS resolve failed for ${host}: no A/AAAA records`)
}
+87
View File
@@ -0,0 +1,87 @@
import { repos } from '@evofw/db'
export function mapAgent(
a: NonNullable<ReturnType<typeof repos.getAgent>>,
opts?: { installCurl?: string | null; installLinkId?: string | null },
) {
const defaultAction =
a.defaultAction === 'drop' ? ('drop' as const) : ('accept' as const)
return {
id: a.id,
name: a.name,
hostname: a.hostname,
platform: a.platform,
token_prefix: a.tokenPrefix,
status: a.status,
default_action: defaultAction,
policy_mode: defaultAction === 'drop' ? ('whitelist' as const) : ('blacklist' as const),
policy_generation: a.policyGeneration,
last_seen_at: a.lastSeenAt,
last_seen_ip: a.lastSeenIp,
last_apply_at: a.lastApplyAt,
last_apply_status: a.lastApplyStatus,
last_apply_error: a.lastApplyError,
last_apply_prefix_count: a.lastApplyPrefixCount,
last_apply_packets_dropped: a.lastApplyPacketsDropped,
last_apply_packets_accepted: a.lastApplyPacketsAccepted,
total_packets_dropped: a.totalPacketsDropped ?? 0,
total_packets_accepted: a.totalPacketsAccepted ?? 0,
last_apply_kernel_method: a.lastApplyKernelMethod,
client_version: a.clientVersion,
created_at: a.createdAt,
approved_at: a.approvedAt,
revoked_at: a.revokedAt,
install_curl: opts?.installCurl ?? null,
install_link_id: opts?.installLinkId ?? null,
}
}
export function mapPolicySet(
s: NonNullable<ReturnType<typeof repos.getPolicySet>>,
db: Parameters<typeof repos.countRulesInSet>[0],
counts?: { rules: number; agents: number },
) {
return {
id: s.id,
name: s.name,
description: s.description,
enabled: s.enabled === 1,
rules_count: counts?.rules ?? repos.countRulesInSet(db, s.id),
agents_count: counts?.agents ?? repos.countAgentsForSet(db, s.id),
created_at: s.createdAt,
updated_at: s.updatedAt,
}
}
export function mapPolicySets(
db: Parameters<typeof repos.countRulesInSet>[0],
sets: NonNullable<ReturnType<typeof repos.getPolicySet>>[],
) {
const counts = repos.countRulesAndAgentsBySetIds(
db,
sets.map((s) => s.id),
)
return sets.map((s) => mapPolicySet(s, db, counts.get(s.id)))
}
export function mapPolicyRule(
r: NonNullable<ReturnType<typeof repos.getPolicyRule>>,
db: Parameters<typeof repos.listResolvedForRule>[0],
) {
return {
id: r.id,
set_id: r.setId,
priority: r.priority,
action: r.action,
enabled: r.enabled !== 0,
list_id: r.listId,
cidr: r.cidr,
hostname: r.hostname,
resolved_count: r.hostname
? repos.listResolvedForRule(db, r.id).length
: undefined,
comment: r.comment,
created_at: r.createdAt,
updated_at: r.updatedAt,
}
}
@@ -0,0 +1,82 @@
import { describe, it, expect, afterAll } from 'vitest'
import { buildApp } from '../app.js'
import type { AppConfig } from '../config.js'
import { repos } from '@evofw/db'
const testConfig: AppConfig = {
databaseUrl: 'sqlite::memory:',
jwtSecret: 'test',
jwtTtlHours: 24,
serverPort: 8080,
staticDir: null,
logLevel: 'error',
authRequired: false,
authIssuer: 'https://auth.test',
authPortalUrl: 'http://localhost:5175',
publicBaseUrl: 'https://fw.example.com',
enrollSeed: 'test-seed',
}
describe('settings + bumpAgentsForList', () => {
const appPromise = buildApp({ memory: true, config: testConfig })
afterAll(async () => {
const app = await appPromise
await app.close()
})
it('rejects unknown settings keys', async () => {
const app = await appPromise
await app.ready()
const res = await app.inject({
method: 'PUT',
url: '/api/v1/settings',
payload: { unknown_key: 'x' },
})
expect(res.statusCode).toBeGreaterThanOrEqual(400)
})
it('accepts show_quick_actions', async () => {
const app = await appPromise
await app.ready()
const res = await app.inject({
method: 'PUT',
url: '/api/v1/settings',
payload: { show_quick_actions: 'false' },
})
expect(res.statusCode).toBe(200)
const get = await app.inject({ method: 'GET', url: '/api/v1/settings' })
expect(get.json().show_quick_actions).toBe('false')
})
it('bumpAgentsForList no-ops when list has no rules', async () => {
const app = await appPromise
await app.ready()
const db = app.db
const agent = repos.insertAgent(db, {
id: 'ag-bump-1',
name: 'bump-test',
platform: 'linux',
tokenPrefix: 'tok',
tokenHash: 'hash-bump-1',
status: 'approved',
defaultAction: 'accept',
policyGeneration: 1,
settingsJson: '{}',
})
expect(agent?.policyGeneration).toBe(1)
const list = repos.insertIpList(db, {
id: 'list-unused',
name: 'unused',
type: 'static',
configJson: '{}',
})
expect(list).toBeTruthy()
repos.bumpAgentsForList(db, 'list-unused')
const after = repos.getAgent(db, 'ag-bump-1')
expect(after?.policyGeneration).toBe(1)
})
})
+17
View File
@@ -0,0 +1,17 @@
/** Trim, drop empty, dedupe (order not guaranteed — sorted for stable hashes). */
export function uniqCidrs(cidrs: readonly string[]): string[] {
return [...new Set(cidrs.map((c) => c.trim()).filter(Boolean))].sort()
}
/** Trim, drop empty, dedupe preserving first-seen order. */
export function uniqCidrsPreserveOrder(cidrs: readonly 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
}