feat(api): unify policy handling with default action updates
Build and Push EvoFirewall Docker Image / build-and-push (push) Successful in 1m53s
Build and Push EvoFirewall Docker Image / create-release (push) Skipped

- Updated `evofw-firewall.sh` and related scripts to replace `policy_mode` with `default_action`, enhancing clarity and consistency in policy management.
- Adjusted agent routes and evaluation logic to accommodate the new default action structure, ensuring backward compatibility with legacy modes.
- Enhanced tests to validate the new default action behavior and its integration within the agent policy framework.
- Refactored related components in the web interface to align with the updated policy handling, improving user experience and reducing confusion around policy modes.
This commit is contained in:
Denozordec
2026-07-23 10:52:28 +07:00
parent a6eb21a10d
commit 1f7273f38d
30 changed files with 1469 additions and 621 deletions
@@ -186,12 +186,16 @@ describe('install-links', () => {
const body = policy.json() as {
deny_cidrs: string[]
allow_cidrs: string[]
default_action: string
policy_mode: string
apply_version: number
hash: string
}
expect(body.deny_cidrs).toEqual([])
expect(body.allow_cidrs).toEqual([])
expect(body.default_action).toBe('accept')
expect(body.policy_mode).toBe('blacklist')
expect(body.apply_version).toBe(2)
expect(body.hash).toMatch(/^sha256:/)
const agents = await app.inject({ method: 'GET', url: '/api/v1/agents' })
+13 -21
View File
@@ -57,8 +57,7 @@ async function fetchEvobgpCommunity(
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 url = `${base}/v1/communities/${encodeURIComponent(communityId)}/prefixes?limit=5000`
const res = await fetch(url, {
headers: {
Authorization: `Bearer ${token}`,
@@ -66,27 +65,20 @@ async function fetchEvobgpCommunity(
},
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))
}
if (!res.ok) {
throw new Error(`EvoBGP community prefixes HTTP ${res.status}`)
}
// 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 data = (await res.json()) as {
items?: { prefix?: string }[]
prefixes?: string[]
}
const data2 = (await res2.json()) as { prefixes?: string[] }
return uniq(data2.prefixes ?? [])
if (Array.isArray(data.prefixes) && data.prefixes.length > 0) {
return uniq(data.prefixes)
}
if (Array.isArray(data.items)) {
return uniq(data.items.map((i) => i.prefix ?? '').filter(Boolean))
}
return []
}
export async function refreshIpList(db: Db, listId: string): Promise<void> {
+115 -23
View File
@@ -1,14 +1,45 @@
import { createHash } from 'node:crypto'
import type { Db } from '@evofw/db'
import { repos } from '@evofw/db'
import {
defaultActionFromLegacyMode,
legacyModeFromDefaultAction,
type DefaultAction,
} from '@evofw/shared'
export const POLICY_APPLY_VERSION = 2 as const
export type PolicyChainStep = {
setId: string | null
setName: string | null
ruleId: string | null
action: 'allow' | 'deny'
sourceKind: 'list' | 'cidr' | 'hostname' | 'override'
sourceLabel: string
cidrCount: number
}
export type EvaluatedPolicy = {
generation: number
hash: string
applyVersion: typeof POLICY_APPLY_VERSION
defaultAction: DefaultAction
/** @deprecated mirror for old agents */
policyMode: 'blacklist' | 'whitelist'
denyCidrs: string[]
allowCidrs: string[]
conflictsDropped: number
syncIntervalSec: number
chain: PolicyChainStep[]
summary: {
sets: number
rulesDeny: number
rulesAllow: number
cidrsDeny: number
cidrsAllow: number
overrides: number
conflictsDropped: number
}
}
function uniq(cidrs: string[]): string[] {
@@ -44,17 +75,25 @@ function expandRule(
return expandList(db, rule.listId)
}
/** Effective mode = first enabled assigned set (by sort); default blacklist. */
export function resolveAgentPolicyMode(
db: Db,
agentId: string,
): 'blacklist' | 'whitelist' {
const sets = repos
.listSetsForAgent(db, agentId)
.filter((s) => s.enabled === 1)
if (sets.length === 0) return 'blacklist'
const mode = sets[0]?.policyMode
return mode === 'whitelist' ? 'whitelist' : 'blacklist'
function resolveDefaultAction(agentDefaultAction: string | null | undefined): DefaultAction {
if (agentDefaultAction === 'drop' || agentDefaultAction === 'accept') {
return agentDefaultAction
}
return defaultActionFromLegacyMode(agentDefaultAction)
}
function sourceMeta(rule: {
cidr: string | null
listId: string | null
hostname: string | null
}): { kind: 'list' | 'cidr' | 'hostname'; label: string } {
if (rule.cidr?.trim()) {
return { kind: 'cidr', label: rule.cidr.trim() }
}
if (rule.hostname?.trim()) {
return { kind: 'hostname', label: rule.hostname.trim() }
}
return { kind: 'list', label: rule.listId ?? 'list' }
}
/** Evaluate allow/deny sets for an agent from assigned policy sets. */
@@ -64,34 +103,69 @@ export function evaluateAgentPolicy(db: Db, agentId: string): EvaluatedPolicy {
throw new Error(`agent not found: ${agentId}`)
}
const assignedSets = repos
.listSetsForAgent(db, agentId)
.filter((s) => s.enabled === 1)
const ordered = repos.listPolicyRulesForAgent(db, agentId)
const overrides = repos.listOverrides(db, agentId)
const deny: string[] = []
const allow: string[] = []
const chain: PolicyChainStep[] = []
let rulesDeny = 0
let rulesAllow = 0
for (const rule of ordered) {
const cidrs = expandRule(db, rule)
if (rule.action === 'deny') deny.push(...cidrs)
else allow.push(...cidrs)
const action = rule.action === 'deny' ? 'deny' : 'allow'
if (action === 'deny') {
deny.push(...cidrs)
rulesDeny += 1
} else {
allow.push(...cidrs)
rulesAllow += 1
}
const src = sourceMeta(rule)
const setName =
assignedSets.find((s) => s.setId === rule.setId)?.name ?? null
chain.push({
setId: rule.setId,
setName,
ruleId: rule.id,
action,
sourceKind: src.kind,
sourceLabel: src.label,
cidrCount: cidrs.length,
})
}
for (const o of repos.listOverrides(db, agentId)) {
if (o.action === 'deny') deny.push(o.cidr)
for (const o of overrides) {
const action = o.action === 'deny' ? 'deny' : 'allow'
if (action === 'deny') deny.push(o.cidr)
else allow.push(o.cidr)
chain.push({
setId: null,
setName: null,
ruleId: null,
action,
sourceKind: 'override',
sourceLabel: o.cidr,
cidrCount: 1,
})
}
const denyCidrs = uniq(deny)
const allowCidrs = uniq(allow)
const policyMode = resolveAgentPolicyMode(db, agentId)
// Keep agent.policy_mode cache in sync for list/API compat
if (agent.policyMode !== policyMode) {
repos.updateAgent(db, agentId, { policyMode })
}
const denySet = new Set(denyCidrs)
const allowRaw = uniq(allow)
const allowCidrs = allowRaw.filter((c) => !denySet.has(c))
const conflictsDropped = allowRaw.length - allowCidrs.length
const defaultAction = resolveDefaultAction(agent.defaultAction)
const policyMode = legacyModeFromDefaultAction(defaultAction)
const payload = JSON.stringify({
apply_version: POLICY_APPLY_VERSION,
generation: agent.policyGeneration,
policyMode,
defaultAction,
denyCidrs,
allowCidrs,
})
@@ -103,9 +177,27 @@ export function evaluateAgentPolicy(db: Db, agentId: string): EvaluatedPolicy {
return {
generation: agent.policyGeneration,
hash,
applyVersion: POLICY_APPLY_VERSION,
defaultAction,
policyMode,
denyCidrs,
allowCidrs,
conflictsDropped,
syncIntervalSec,
chain,
summary: {
sets: assignedSets.length,
rulesDeny,
rulesAllow,
cidrsDeny: denyCidrs.length,
cidrsAllow: allowCidrs.length,
overrides: overrides.length,
conflictsDropped,
},
}
}
export function truncateCidrs(cidrs: string[], limit: number): string[] {
if (limit <= 0) return []
return cidrs.slice(0, limit)
}
@@ -1,64 +1,60 @@
import { describe, it, expect } from 'vitest'
import { renderMikrotikPolicyRsc, isIpv4Cidr } from './mikrotik-rsc.js'
import type { EvaluatedPolicy } from './evaluate.js'
import { describe, expect, it } from 'vitest'
import {
POLICY_APPLY_VERSION,
type EvaluatedPolicy,
} from './evaluate.js'
import { renderMikrotikPolicyRsc } from './mikrotik-rsc.js'
function basePolicy(
overrides: Partial<EvaluatedPolicy> = {},
patch: Partial<EvaluatedPolicy> = {},
): EvaluatedPolicy {
return {
generation: 3,
hash: 'sha256:abc',
applyVersion: POLICY_APPLY_VERSION,
defaultAction: 'accept',
policyMode: 'blacklist',
denyCidrs: ['1.2.3.0/24', '2001:db8::/32', '10.0.0.1/32'],
allowCidrs: ['8.8.8.8/32', 'fe80::1/128'],
conflictsDropped: 0,
syncIntervalSec: 60,
...overrides,
chain: [],
summary: {
sets: 1,
rulesDeny: 1,
rulesAllow: 1,
cidrsDeny: 2,
cidrsAllow: 1,
overrides: 0,
conflictsDropped: 0,
},
...patch,
}
}
describe('mikrotik-rsc', () => {
it('isIpv4Cidr skips IPv6', () => {
expect(isIpv4Cidr('1.2.3.0/24')).toBe(true)
expect(isIpv4Cidr('2001:db8::/32')).toBe(false)
})
it('renders blacklist: lists + BL enabled / WL disabled', () => {
describe('renderMikrotikPolicyRsc', () => {
it('renders accept default: lists + default-drop disabled', () => {
const rsc = renderMikrotikPolicyRsc(basePolicy())
expect(rsc).toContain('# evofw hash=sha256:abc mode=blacklist gen=3')
expect(rsc).toContain(
'/ip firewall address-list remove [find list=EVOFW_DENY]',
)
expect(rsc).toContain(
'/ip firewall address-list remove [find list=EVOFW_ALLOW]',
)
expect(rsc).toContain(
'add list=EVOFW_DENY address=1.2.3.0/24 comment=evofw',
)
expect(rsc).toContain(
'add list=EVOFW_DENY address=10.0.0.1/32 comment=evofw',
'# evofw hash=sha256:abc default_action=accept apply_version=2 gen=3',
)
expect(rsc).toContain('list=EVOFW_DENY')
expect(rsc).toContain('list=EVOFW_ALLOW')
expect(rsc).toContain('address=1.2.3.0/24')
expect(rsc).not.toContain('2001:db8')
expect(rsc).toContain(
'add list=EVOFW_ALLOW address=8.8.8.8/32 comment=evofw',
)
expect(rsc).toContain(
'set [find comment=evofw-bl-drop-input] disabled=no',
)
expect(rsc).toContain(
'set [find comment=evofw-wl-accept-forward] disabled=yes',
'evofw-default-drop-forward] disabled=yes',
)
expect(rsc).toContain('evofw-deny-drop-input] disabled=no')
})
it('renders whitelist: WL enabled / BL disabled', () => {
it('renders drop default: default-drop enabled', () => {
const rsc = renderMikrotikPolicyRsc(
basePolicy({ policyMode: 'whitelist' }),
basePolicy({ defaultAction: 'drop', policyMode: 'whitelist' }),
)
expect(rsc).toContain('mode=whitelist')
expect(rsc).toContain('default_action=drop')
expect(rsc).toContain(
'set [find comment=evofw-bl-drop-forward] disabled=yes',
)
expect(rsc).toContain(
'set [find comment=evofw-wl-drop-forward] disabled=no',
'evofw-default-drop-forward] disabled=no',
)
})
})
+18 -11
View File
@@ -7,23 +7,25 @@ export function isIpv4Cidr(cidr: string): boolean {
}
function escAddress(cidr: string): string {
// CIDRs are alphanumeric + . / - ; quote if anything odd
const t = cidr.trim()
if (/^[0-9./-]+$/.test(t)) return t
return `"${t.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`
}
/**
* RouterOS 7.x script: rebuild EVOFW_* address-lists and toggle filter mode.
* Device: /tool fetch → /import (no JSON parse on router).
* RouterOS 7.x script: rebuild EVOFW_* address-lists.
* Unified chain: deny drop → allow accept → default (accept|drop).
* Expects permanent filter rules with comments:
* evofw-deny-drop-input / evofw-deny-drop-forward (always on)
* evofw-allow-accept-forward (always on for forward path)
* evofw-default-drop-forward (enabled when default_action=drop)
*/
export function renderMikrotikPolicyRsc(policy: EvaluatedPolicy): string {
const isBl = policy.policyMode === 'blacklist'
const blDisabled = isBl ? 'no' : 'yes'
const wlDisabled = isBl ? 'yes' : 'no'
const defaultDrop = policy.defaultAction === 'drop'
const defaultDropDisabled = defaultDrop ? 'no' : 'yes'
const lines: string[] = [
`# evofw hash=${policy.hash} mode=${policy.policyMode} gen=${policy.generation}`,
`# evofw hash=${policy.hash} default_action=${policy.defaultAction} apply_version=${policy.applyVersion} gen=${policy.generation}`,
'/ip firewall address-list remove [find list=EVOFW_DENY]',
'/ip firewall address-list remove [find list=EVOFW_ALLOW]',
]
@@ -42,10 +44,15 @@ export function renderMikrotikPolicyRsc(policy: EvaluatedPolicy): string {
}
lines.push(
`:do { /ip firewall filter set [find comment=evofw-bl-drop-input] disabled=${blDisabled} } on-error={}`,
`:do { /ip firewall filter set [find comment=evofw-bl-drop-forward] disabled=${blDisabled} } on-error={}`,
`:do { /ip firewall filter set [find comment=evofw-wl-accept-forward] disabled=${wlDisabled} } on-error={}`,
`:do { /ip firewall filter set [find comment=evofw-wl-drop-forward] disabled=${wlDisabled} } on-error={}`,
`:do { /ip firewall filter set [find comment=evofw-deny-drop-input] disabled=no } on-error={}`,
`:do { /ip firewall filter set [find comment=evofw-deny-drop-forward] disabled=no } on-error={}`,
`:do { /ip firewall filter set [find comment=evofw-allow-accept-forward] disabled=no } on-error={}`,
`:do { /ip firewall filter set [find comment=evofw-default-drop-forward] disabled=${defaultDropDisabled} } on-error={}`,
// Legacy comments from bl/wl toggle era — keep disabled
`:do { /ip firewall filter set [find comment=evofw-bl-drop-input] disabled=yes } on-error={}`,
`:do { /ip firewall filter set [find comment=evofw-bl-drop-forward] disabled=yes } on-error={}`,
`:do { /ip firewall filter set [find comment=evofw-wl-accept-forward] disabled=yes } on-error={}`,
`:do { /ip firewall filter set [find comment=evofw-wl-drop-forward] disabled=yes } on-error={}`,
)
return `${lines.join('\n')}\n`
@@ -16,7 +16,25 @@ const testConfig: AppConfig = {
enrollSeed: 'test-seed',
}
describe('policy set mode + rules', () => {
async function createAgent(
app: Awaited<ReturnType<typeof buildApp>>,
name: string,
) {
const link = await app.inject({
method: 'POST',
url: '/api/v1/install-links',
payload: { name, platform: 'linux' },
})
expect(link.statusCode).toBe(201)
const agentId = (link.json() as { agent_id: string }).agent_id
await app.inject({
method: 'POST',
url: `/api/v1/agents/${agentId}/approve`,
})
return agentId
}
describe('classic policy default_action', () => {
const appPromise = buildApp({ memory: true, config: testConfig })
afterAll(async () => {
@@ -24,104 +42,99 @@ describe('policy set mode + rules', () => {
await app.close()
})
it('set policy_mode and reorder; disabled rules skipped in policy', async () => {
it('mixed deny/allow; deny wins exact; preview has default_action', async () => {
const app = await appPromise
await app.ready()
const created = await app.inject({
method: 'POST',
url: '/api/v1/policy-sets',
payload: {
name: 'WL set',
policy_mode: 'whitelist',
},
payload: { name: 'mixed-set' },
})
expect(created.statusCode).toBe(200)
const set = created.json() as { id: string; policy_mode: string }
expect(set.policy_mode).toBe('whitelist')
const setId = (created.json() as { id: string }).id
const r1 = await app.inject({
method: 'POST',
url: '/api/v1/rules',
payload: {
set_id: set.id,
action: 'allow',
cidr: '10.0.0.1/32',
},
})
expect(r1.statusCode).toBe(200)
const rule1 = r1.json() as { id: string; enabled: boolean; priority: number }
for (const payload of [
{ set_id: setId, action: 'deny', cidr: '10.0.0.1/32' },
{ set_id: setId, action: 'allow', cidr: '10.0.0.1/32' },
{ set_id: setId, action: 'allow', cidr: '10.0.0.2/32' },
]) {
const r = await app.inject({
method: 'POST',
url: '/api/v1/rules',
payload,
})
expect(r.statusCode).toBe(200)
}
const r2 = await app.inject({
method: 'POST',
url: '/api/v1/rules',
payload: {
set_id: set.id,
action: 'allow',
cidr: '10.0.0.2/32',
},
})
const rule2 = r2.json() as { id: string }
const agentId = await createAgent(app, 'pol-agent')
const reordered = await app.inject({
method: 'PUT',
url: `/api/v1/policy-sets/${set.id}/rules/reorder`,
payload: { ordered_ids: [rule2.id, rule1.id] },
})
expect(reordered.statusCode).toBe(200)
const items = (
reordered.json() as { items: { id: string; priority: number }[] }
).items
expect(items[0]?.id).toBe(rule2.id)
expect(items[0]!.priority).toBeLessThan(items[1]!.priority)
await app.inject({
method: 'PATCH',
url: `/api/v1/rules/${rule1.id}`,
payload: { enabled: false },
})
// enroll + approve agent, assign set
const enroll = await app.inject({
method: 'POST',
url: '/v1/agent/enroll',
headers: {
'content-type': 'application/json',
'x-evofw-seed': 'test-seed',
},
payload: {
name: 'mt-wl',
platform: 'linux',
token: 'evofw_policy_mode_token_abcdef12',
},
})
const agent = enroll.json() as { id: string }
await app.inject({
method: 'POST',
url: `/api/v1/agents/${agent.id}/approve`,
})
const assign = await app.inject({
method: 'PUT',
url: `/api/v1/agents/${agent.id}/policy-sets`,
payload: { set_ids: [set.id] },
url: `/api/v1/agents/${agentId}/policy-sets`,
payload: { set_ids: [setId] },
})
expect(assign.statusCode).toBe(200)
const policy = await app.inject({
method: 'GET',
url: '/v1/agent/policy',
headers: {
authorization: 'Bearer evofw_policy_mode_token_abcdef12',
},
const patch = await app.inject({
method: 'PATCH',
url: `/api/v1/agents/${agentId}`,
payload: { default_action: 'drop' },
})
expect(policy.statusCode).toBe(200)
const body = policy.json() as {
policy_mode: string
expect(patch.statusCode).toBe(200)
expect((patch.json() as { default_action: string }).default_action).toBe(
'drop',
)
const preview = await app.inject({
method: 'GET',
url: `/api/v1/agents/${agentId}/preview`,
})
expect(preview.statusCode).toBe(200)
const body = preview.json() as {
default_action: string
apply_version: number
deny_cidrs: string[]
allow_cidrs: string[]
summary: { conflicts_dropped: number }
chain: unknown[]
}
expect(body.policy_mode).toBe('whitelist')
expect(body.allow_cidrs).toContain('10.0.0.2/32')
expect(body.default_action).toBe('drop')
expect(body.apply_version).toBe(2)
expect(body.deny_cidrs).toContain('10.0.0.1/32')
expect(body.allow_cidrs).not.toContain('10.0.0.1/32')
expect(rule1.enabled).toBe(true)
expect(body.allow_cidrs).toContain('10.0.0.2/32')
expect(body.summary.conflicts_dropped).toBeGreaterThanOrEqual(1)
expect(body.chain.length).toBeGreaterThanOrEqual(3)
})
it('allows assigning sets without same-mode lock', async () => {
const app = await appPromise
await app.ready()
const a = await app.inject({
method: 'POST',
url: '/api/v1/policy-sets',
payload: { name: 'set-a', policy_mode: 'blacklist' },
})
const b = await app.inject({
method: 'POST',
url: '/api/v1/policy-sets',
payload: { name: 'set-b', policy_mode: 'whitelist' },
})
expect(a.statusCode).toBe(200)
expect(b.statusCode).toBe(200)
const setA = (a.json() as { id: string }).id
const setB = (b.json() as { id: string }).id
const agentId = await createAgent(app, 'multi-mode-agent')
const assign = await app.inject({
method: 'PUT',
url: `/api/v1/agents/${agentId}/policy-sets`,
payload: { set_ids: [setA, setB] },
})
expect(assign.statusCode).toBe(200)
expect((assign.json() as { items: unknown[] }).items).toHaveLength(2)
})
})