feat(api, web): implement port ACL and host firewall snapshot features
Build and Push EvoFirewall Docker Image / build-and-push (push) Successful in 1m43s
Build and Push EvoFirewall Docker Image / create-release (push) Skipped

- Added support for managing desired L4 port ACL rules for Linux agents, allowing for open/close actions on specified ports.
- Introduced a new endpoint for CRUD operations on port rules, enhancing the API's capabilities for agent management.
- Implemented functionality to collect and report host firewall snapshots, capturing observed rules and listeners for better monitoring.
- Updated the agent detail view to include tabs for managing port ACLs and viewing host firewall data, improving user experience.
- Enhanced documentation to reflect the new features and API changes, ensuring clarity for users and developers.

These changes significantly improve the management and visibility of firewall rules and port access control for agents.
This commit is contained in:
Denozordec
2026-08-11 15:08:00 +07:00
parent c5069fbdaf
commit 43f5ac2525
22 changed files with 2853 additions and 17 deletions
+1 -1
View File
@@ -198,7 +198,7 @@ describe('install-links', () => {
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.apply_version).toBe(3)
expect(body.hash).toMatch(/^sha256:/)
const agents = await app.inject({ method: 'GET', url: '/api/v1/agents' })
+64 -1
View File
@@ -8,7 +8,7 @@ import {
} from '@evofw/shared'
import { uniqCidrs } from '../uniq.js'
export const POLICY_APPLY_VERSION = 2 as const
export const POLICY_APPLY_VERSION = 3 as const
export type PolicyChainStep = {
setId: string | null
@@ -20,6 +20,15 @@ export type PolicyChainStep = {
cidrCount: number
}
export type EvaluatedPortRule = {
id: string
action: 'open' | 'close'
protocol: 'tcp' | 'udp'
portStart: number
portEnd: number
srcCidrs: string[]
}
export type EvaluatedPolicy = {
generation: number
hash: string
@@ -29,6 +38,7 @@ export type EvaluatedPolicy = {
policyMode: 'blacklist' | 'whitelist'
denyCidrs: string[]
allowCidrs: string[]
portRules: EvaluatedPortRule[]
conflictsDropped: number
syncIntervalSec: number
chain: PolicyChainStep[]
@@ -40,6 +50,7 @@ export type EvaluatedPolicy = {
cidrsAllow: number
overrides: number
conflictsDropped: number
portRules: number
}
}
@@ -90,6 +101,54 @@ function sourceMeta(
return { kind: 'list', label: name || listId || 'list' }
}
function expandPortSrcCidrs(
db: Db,
row: {
srcKind: string
srcCidr: string | null
listId: string | null
},
): string[] {
if (row.srcKind === 'all') return ['0.0.0.0/0']
if (row.srcKind === 'cidr' && row.srcCidr?.trim()) {
return [row.srcCidr.trim()]
}
if (row.srcKind === 'list') {
const cidrs = expandList(db, row.listId)
return cidrs.length ? uniqCidrs(cidrs) : []
}
return []
}
function expandPortRules(db: Db, agentId: string): EvaluatedPortRule[] {
const rows = repos.listEnabledAgentPortRules(db, agentId)
const out: EvaluatedPortRule[] = []
for (const row of rows) {
const action = row.action === 'close' ? 'close' : 'open'
const srcCidrs = expandPortSrcCidrs(db, row)
if (!srcCidrs.length) continue
const portStart = Math.max(1, Math.min(65535, row.portStart))
const portEnd = Math.max(portStart, Math.min(65535, row.portEnd))
const protocols: Array<'tcp' | 'udp'> =
row.protocol === 'udp'
? ['udp']
: row.protocol === 'both'
? ['tcp', 'udp']
: ['tcp']
for (const protocol of protocols) {
out.push({
id: row.id,
action,
protocol,
portStart,
portEnd,
srcCidrs,
})
}
}
return out
}
/** Evaluate allow/deny sets for an agent from assigned policy sets. */
export function evaluateAgentPolicy(db: Db, agentId: string): EvaluatedPolicy {
const agent = repos.getAgent(db, agentId)
@@ -155,6 +214,7 @@ export function evaluateAgentPolicy(db: Db, agentId: string): EvaluatedPolicy {
const conflictsDropped = allowRaw.length - allowCidrs.length
const defaultAction = resolveDefaultAction(agent.defaultAction)
const policyMode = legacyModeFromDefaultAction(defaultAction)
const portRules = expandPortRules(db, agentId)
const payload = JSON.stringify({
apply_version: POLICY_APPLY_VERSION,
@@ -162,6 +222,7 @@ export function evaluateAgentPolicy(db: Db, agentId: string): EvaluatedPolicy {
defaultAction,
denyCidrs,
allowCidrs,
portRules,
})
const hash = `sha256:${createHash('sha256').update(payload).digest('hex')}`
@@ -176,6 +237,7 @@ export function evaluateAgentPolicy(db: Db, agentId: string): EvaluatedPolicy {
policyMode,
denyCidrs,
allowCidrs,
portRules,
conflictsDropped,
syncIntervalSec,
chain,
@@ -187,6 +249,7 @@ export function evaluateAgentPolicy(db: Db, agentId: string): EvaluatedPolicy {
cidrsAllow: allowCidrs.length,
overrides: overrides.length,
conflictsDropped,
portRules: portRules.length,
},
}
}
@@ -16,6 +16,7 @@ function basePolicy(
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'],
portRules: [],
conflictsDropped: 0,
syncIntervalSec: 60,
chain: [],
@@ -27,6 +28,7 @@ function basePolicy(
cidrsAllow: 1,
overrides: 0,
conflictsDropped: 0,
portRules: 0,
},
...patch,
}
@@ -36,7 +38,7 @@ describe('renderMikrotikPolicyRsc', () => {
it('renders accept default: lists + default-drop disabled', () => {
const rsc = renderMikrotikPolicyRsc(basePolicy())
expect(rsc).toContain(
'# evofw hash=sha256:abc default_action=accept apply_version=2 gen=3',
`# evofw hash=sha256:abc default_action=accept apply_version=${POLICY_APPLY_VERSION} gen=3`,
)
expect(rsc).toContain('list=EVOFW_DENY')
expect(rsc).toContain('list=EVOFW_ALLOW')
@@ -100,7 +100,7 @@ describe('classic policy default_action', () => {
chain: unknown[]
}
expect(body.default_action).toBe('drop')
expect(body.apply_version).toBe(2)
expect(body.apply_version).toBe(3)
expect(body.deny_cidrs).toContain('10.0.0.1/32')
expect(body.allow_cidrs).not.toContain('10.0.0.1/32')
expect(body.allow_cidrs).toContain('10.0.0.2/32')
+304
View File
@@ -0,0 +1,304 @@
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',
}
async function enrollApprovedLinux(
app: Awaited<ReturnType<typeof buildApp>>,
name: string,
token: string,
) {
const created = await app.inject({
method: 'POST',
url: '/api/v1/install-links',
payload: { name, platform: 'linux' },
})
expect(created.statusCode).toBe(201)
const link = created.json() as { id: string; agent_id: string }
const enroll = await app.inject({
method: 'POST',
url: '/v1/agent/enroll',
headers: {
'content-type': 'application/json',
'x-evofw-seed': 'test-seed',
},
payload: {
name,
platform: 'linux',
token,
install_link_id: link.id,
},
})
expect(enroll.statusCode).toBe(201)
await app.inject({
method: 'POST',
url: `/api/v1/agents/${link.agent_id}/approve`,
})
return { agentId: link.agent_id, token }
}
describe('port ACL + host firewall snapshot', () => {
const appPromise = buildApp({ memory: true, config: testConfig })
afterAll(async () => {
const app = await appPromise
await app.close()
})
it('CRUD port-rules bumps generation and expands in policy', async () => {
const app = await appPromise
await app.ready()
const { agentId, token } = await enrollApprovedLinux(
app,
'port-acl-01',
'evofw_port_acl_token_abcdefghij',
)
const before = await app.inject({
method: 'GET',
url: `/api/v1/agents/${agentId}`,
})
const genBefore = (before.json() as { policy_generation: number })
.policy_generation
const create = await app.inject({
method: 'POST',
url: `/api/v1/agents/${agentId}/port-rules`,
payload: {
action: 'open',
protocol: 'tcp',
port_start: 443,
src_kind: 'cidr',
src_cidr: '10.0.0.0/8',
},
})
expect(create.statusCode).toBe(200)
const rule = create.json() as { id: string; action: string; port_start: number }
expect(rule.action).toBe('open')
expect(rule.port_start).toBe(443)
const after = await app.inject({
method: 'GET',
url: `/api/v1/agents/${agentId}`,
})
expect(
(after.json() as { policy_generation: number }).policy_generation,
).toBe(genBefore + 1)
const list = await app.inject({
method: 'GET',
url: `/api/v1/agents/${agentId}/port-rules`,
})
expect(list.statusCode).toBe(200)
expect((list.json() as { items: unknown[] }).items).toHaveLength(1)
const policy = await app.inject({
method: 'GET',
url: '/v1/agent/policy',
headers: { authorization: `Bearer ${token}` },
})
expect(policy.statusCode).toBe(200)
const body = policy.json() as {
apply_version: number
port_rules: {
action: string
protocol: string
port_start: number
src_cidrs: string[]
}[]
}
expect(body.apply_version).toBe(3)
expect(body.port_rules).toHaveLength(1)
expect(body.port_rules[0]?.src_cidrs).toEqual(['10.0.0.0/8'])
expect(body.port_rules[0]?.protocol).toBe('tcp')
const patch = await app.inject({
method: 'PATCH',
url: `/api/v1/agents/${agentId}/port-rules/${rule.id}`,
payload: { enabled: false },
})
expect(patch.statusCode).toBe(200)
expect((patch.json() as { enabled: boolean }).enabled).toBe(false)
const policyOff = await app.inject({
method: 'GET',
url: '/v1/agent/policy',
headers: { authorization: `Bearer ${token}` },
})
expect(
(policyOff.json() as { port_rules: unknown[] }).port_rules,
).toHaveLength(0)
const del = await app.inject({
method: 'DELETE',
url: `/api/v1/agents/${agentId}/port-rules/${rule.id}`,
})
expect(del.statusCode).toBe(200)
})
it('imports port-rules from IP list', async () => {
const app = await appPromise
await app.ready()
const { agentId } = await enrollApprovedLinux(
app,
'port-acl-import',
'evofw_port_import_token_abcdefgh',
)
const listRes = await app.inject({
method: 'POST',
url: '/api/v1/lists',
payload: {
name: 'port-src-list',
type: 'static',
entries: ['203.0.113.0/24'],
},
})
expect(listRes.statusCode).toBe(200)
const listId = (listRes.json() as { id: string }).id
const before = await app.inject({
method: 'GET',
url: `/api/v1/agents/${agentId}`,
})
const genBefore = (before.json() as { policy_generation: number })
.policy_generation
const imp = await app.inject({
method: 'POST',
url: `/api/v1/agents/${agentId}/port-rules/import`,
payload: {
from: 'list',
list_id: listId,
action: 'close',
protocol: 'both',
ports: [{ port_start: 22 }, { port_start: 80, port_end: 81 }],
},
})
expect(imp.statusCode).toBe(200)
const items = (imp.json() as { items: { src_kind: string; list_id: string }[] })
.items
expect(items).toHaveLength(2)
expect(items.every((i) => i.src_kind === 'list' && i.list_id === listId)).toBe(
true,
)
const after = await app.inject({
method: 'GET',
url: `/api/v1/agents/${agentId}`,
})
expect(
(after.json() as { policy_generation: number }).policy_generation,
).toBe(genBefore + 1)
})
it('upserts host_firewall snapshot from apply-report', async () => {
const app = await appPromise
await app.ready()
const { agentId, token } = await enrollApprovedLinux(
app,
'host-fw-snap',
'evofw_host_fw_token_abcdefghij',
)
const report = await app.inject({
method: 'POST',
url: '/v1/agent/apply-report',
headers: {
authorization: `Bearer ${token}`,
'content-type': 'application/json',
},
payload: {
status: 'ok',
kernel_method: 'nft',
host_firewall: {
rules: [
{
ownership: 'evofw',
backend: 'nft',
table: 'evofw',
chain: 'input',
action: 'drop',
protocol: 'tcp',
dport: '22',
raw: 'tcp dport 22 drop comment "evofw-port-x"',
},
{
ownership: 'foreign',
backend: 'iptables',
chain: 'INPUT',
action: 'ACCEPT',
raw: '-A INPUT -p tcp --dport 80 -j ACCEPT',
},
],
listeners: [
{ protocol: 'tcp', port: 22, address: '0.0.0.0' },
],
},
},
})
expect(report.statusCode).toBe(200)
const snap = await app.inject({
method: 'GET',
url: `/api/v1/agents/${agentId}/host-firewall`,
})
expect(snap.statusCode).toBe(200)
const body = snap.json() as {
collected_at: string | null
rules: { ownership: string }[]
listeners: { port: number }[]
}
expect(body.collected_at).toBeTruthy()
expect(body.rules).toHaveLength(2)
expect(body.rules.some((r) => r.ownership === 'evofw')).toBe(true)
expect(body.listeners[0]?.port).toBe(22)
})
it('rejects port ACL on non-linux agents', async () => {
const app = await appPromise
await app.ready()
const created = await app.inject({
method: 'POST',
url: '/api/v1/install-links',
payload: { name: 'mt-no-acl', platform: 'mikrotik' },
})
const agentId = (created.json() as { agent_id: string }).agent_id
await app.inject({
method: 'POST',
url: `/api/v1/agents/${agentId}/approve`,
})
const create = await app.inject({
method: 'POST',
url: `/api/v1/agents/${agentId}/port-rules`,
payload: {
action: 'open',
protocol: 'tcp',
port_start: 443,
src_kind: 'all',
},
})
expect(create.statusCode).toBe(400)
})
})