feat(api, web): implement port ACL and host firewall snapshot features
- 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:
@@ -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' })
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user