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
@@ -0,0 +1,31 @@
-- Per-agent L4 port ACL (desired state) + host firewall snapshot (observed).
CREATE TABLE IF NOT EXISTS agent_port_rules (
id TEXT PRIMARY KEY NOT NULL,
agent_id TEXT NOT NULL REFERENCES agents(id) ON DELETE CASCADE,
action TEXT NOT NULL,
protocol TEXT NOT NULL,
port_start INTEGER NOT NULL,
port_end INTEGER NOT NULL,
src_kind TEXT NOT NULL,
src_cidr TEXT,
list_id TEXT REFERENCES ip_lists(id) ON DELETE SET NULL,
enabled INTEGER NOT NULL DEFAULT 1,
comment TEXT,
priority INTEGER NOT NULL DEFAULT 100,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
);
CREATE INDEX IF NOT EXISTS idx_agent_port_rules_agent_priority
ON agent_port_rules(agent_id, priority);
CREATE INDEX IF NOT EXISTS idx_agent_port_rules_agent_enabled
ON agent_port_rules(agent_id, enabled);
CREATE TABLE IF NOT EXISTS agent_host_firewall_snapshots (
agent_id TEXT PRIMARY KEY NOT NULL REFERENCES agents(id) ON DELETE CASCADE,
collected_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
payload_json TEXT NOT NULL,
raw_digest TEXT
);
+30
View File
@@ -77,6 +77,18 @@ export type {
PortBlockPerIpRow,
} from './stats.js'
export {
listAgentPortRules,
listEnabledAgentPortRules,
getAgentPortRule,
insertAgentPortRule,
updateAgentPortRule,
deleteAgentPortRule,
upsertHostFirewallSnapshot,
getHostFirewallSnapshot,
} from './port-acl.js'
export type { AgentPortRuleRow, AgentPortRuleInsert } from './port-acl.js'
export {
getSetting,
setSetting,
@@ -161,6 +173,16 @@ import {
deletePortBlockStatsForAgent,
resetPortBlockStatsBaselines,
} from './stats.js'
import {
listAgentPortRules,
listEnabledAgentPortRules,
getAgentPortRule,
insertAgentPortRule,
updateAgentPortRule,
deleteAgentPortRule,
upsertHostFirewallSnapshot,
getHostFirewallSnapshot,
} from './port-acl.js'
import {
getSetting,
setSetting,
@@ -236,6 +258,14 @@ export const repos = {
mapTopPortsByIp,
deletePortBlockStatsForAgent,
resetPortBlockStatsBaselines,
listAgentPortRules,
listEnabledAgentPortRules,
getAgentPortRule,
insertAgentPortRule,
updateAgentPortRule,
deleteAgentPortRule,
upsertHostFirewallSnapshot,
getHostFirewallSnapshot,
getSetting,
setSetting,
listSettings,
+93
View File
@@ -0,0 +1,93 @@
import { and, asc, eq } from 'drizzle-orm'
import type { Db } from '../client.js'
import { agentHostFirewallSnapshots, agentPortRules } from '../schema.js'
export type AgentPortRuleRow = typeof agentPortRules.$inferSelect
export type AgentPortRuleInsert = typeof agentPortRules.$inferInsert
export function listAgentPortRules(db: Db, agentId: string) {
return db
.select()
.from(agentPortRules)
.where(eq(agentPortRules.agentId, agentId))
.orderBy(asc(agentPortRules.priority), asc(agentPortRules.createdAt))
.all()
}
export function listEnabledAgentPortRules(db: Db, agentId: string) {
return db
.select()
.from(agentPortRules)
.where(
and(eq(agentPortRules.agentId, agentId), eq(agentPortRules.enabled, 1)),
)
.orderBy(asc(agentPortRules.priority), asc(agentPortRules.createdAt))
.all()
}
export function getAgentPortRule(db: Db, id: string) {
return db.select().from(agentPortRules).where(eq(agentPortRules.id, id)).get()
}
export function insertAgentPortRule(db: Db, row: AgentPortRuleInsert) {
db.insert(agentPortRules).values(row).run()
return getAgentPortRule(db, row.id)
}
export function updateAgentPortRule(
db: Db,
id: string,
patch: Partial<
Omit<AgentPortRuleInsert, 'id' | 'agentId' | 'createdAt'>
>,
) {
db.update(agentPortRules)
.set({
...patch,
updatedAt: new Date().toISOString(),
})
.where(eq(agentPortRules.id, id))
.run()
return getAgentPortRule(db, id)
}
export function deleteAgentPortRule(db: Db, id: string) {
db.delete(agentPortRules).where(eq(agentPortRules.id, id)).run()
}
export function upsertHostFirewallSnapshot(
db: Db,
agentId: string,
payloadJson: string,
collectedAt = new Date().toISOString(),
rawDigest: string | null = null,
) {
const existing = db
.select()
.from(agentHostFirewallSnapshots)
.where(eq(agentHostFirewallSnapshots.agentId, agentId))
.get()
if (existing) {
db.update(agentHostFirewallSnapshots)
.set({ payloadJson, collectedAt, rawDigest })
.where(eq(agentHostFirewallSnapshots.agentId, agentId))
.run()
} else {
db.insert(agentHostFirewallSnapshots)
.values({ agentId, payloadJson, collectedAt, rawDigest })
.run()
}
return db
.select()
.from(agentHostFirewallSnapshots)
.where(eq(agentHostFirewallSnapshots.agentId, agentId))
.get()
}
export function getHostFirewallSnapshot(db: Db, agentId: string) {
return db
.select()
.from(agentHostFirewallSnapshots)
.where(eq(agentHostFirewallSnapshots.agentId, agentId))
.get()
}
+56
View File
@@ -264,6 +264,60 @@ export const agentPortBlockStats = sqliteTable(
}),
)
/** Desired L4 port ACL per Linux agent (open/close). */
export const agentPortRules = sqliteTable(
'agent_port_rules',
{
id: text('id').primaryKey(),
agentId: text('agent_id')
.notNull()
.references(() => agents.id, { onDelete: 'cascade' }),
action: text('action').notNull(), // open | close
protocol: text('protocol').notNull(), // tcp | udp | both
portStart: integer('port_start').notNull(),
portEnd: integer('port_end').notNull(),
srcKind: text('src_kind').notNull(), // all | cidr | list
srcCidr: text('src_cidr'),
listId: text('list_id').references(() => ipLists.id, {
onDelete: 'set null',
}),
enabled: integer('enabled').notNull().default(1),
comment: text('comment'),
priority: integer('priority').notNull().default(100),
createdAt: text('created_at')
.notNull()
.default(sql`(strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))`),
updatedAt: text('updated_at')
.notNull()
.default(sql`(strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))`),
},
(t) => ({
agentPriority: index('idx_agent_port_rules_agent_priority').on(
t.agentId,
t.priority,
),
agentEnabled: index('idx_agent_port_rules_agent_enabled').on(
t.agentId,
t.enabled,
),
}),
)
/** Latest observed host firewall + listeners snapshot from Linux agent. */
export const agentHostFirewallSnapshots = sqliteTable(
'agent_host_firewall_snapshots',
{
agentId: text('agent_id')
.primaryKey()
.references(() => agents.id, { onDelete: 'cascade' }),
collectedAt: text('collected_at')
.notNull()
.default(sql`(strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))`),
payloadJson: text('payload_json').notNull(),
rawDigest: text('raw_digest'),
},
)
/** Short install invite links (`/agent-install/:id` and `/:slug`). */
export const agentInstallLinks = sqliteTable(
'agent_install_links',
@@ -328,6 +382,8 @@ export const schema = {
agentStatsSamples,
agentIpBlockStats,
agentPortBlockStats,
agentPortRules,
agentHostFirewallSnapshots,
agentInstallLinks,
auditLog,
}
+168 -1
View File
@@ -233,6 +233,42 @@ export const applyReportPortHitSchema = z.object({
packets: z.number().int().nonnegative(),
})
export const hostFwOwnershipSchema = z.enum(['evofw', 'foreign'])
export const hostFwBackendSchema = z.enum([
'nft',
'iptables',
'ufw',
'firewalld',
'listener',
])
export const hostFwRuleSchema = z.object({
ownership: hostFwOwnershipSchema,
backend: hostFwBackendSchema,
table: z.string().max(128).optional(),
chain: z.string().max(128).optional(),
action: z.string().max(64).optional(),
protocol: z.string().max(16).optional(),
dport: z.string().max(64).optional(),
sport: z.string().max(64).optional(),
saddr: z.string().max(128).optional(),
daddr: z.string().max(128).optional(),
comment: z.string().max(256).optional(),
raw: z.string().max(512),
})
export const hostListenerSchema = z.object({
protocol: z.string().max(16),
port: z.number().int().min(0).max(65535),
address: z.string().max(128),
process: z.string().max(128).optional(),
})
export const hostFirewallPayloadSchema = z.object({
rules: z.array(hostFwRuleSchema).max(500).default([]),
listeners: z.array(hostListenerSchema).max(200).default([]),
})
export const applyReportBodySchema = z.object({
status: z.string(),
prefix_count: z.number().int().optional(),
@@ -245,6 +281,129 @@ export const applyReportBodySchema = z.object({
ip_hits: z.array(applyReportIpHitSchema).max(200).optional(),
/** Linux nft dynamic set per-(ip, proto, dport) deny hits (top-N). */
port_hits: z.array(applyReportPortHitSchema).max(500).optional(),
/** Observed host firewall + listeners (Linux). */
host_firewall: hostFirewallPayloadSchema.optional(),
})
export const agentPortRuleActionSchema = z.enum(['open', 'close'])
export const agentPortRuleProtocolSchema = z.enum(['tcp', 'udp', 'both'])
export const agentPortRuleSrcKindSchema = z.enum(['all', 'cidr', 'list'])
export const agentPortRuleSchema = z.object({
id: z.string(),
agent_id: z.string(),
action: agentPortRuleActionSchema,
protocol: agentPortRuleProtocolSchema,
port_start: z.number().int().min(1).max(65535),
port_end: z.number().int().min(1).max(65535),
src_kind: agentPortRuleSrcKindSchema,
src_cidr: z.string().nullable().optional(),
list_id: z.string().nullable().optional(),
list_name: z.string().nullable().optional(),
enabled: z.boolean(),
comment: z.string().nullable().optional(),
priority: z.number().int(),
created_at: z.string(),
updated_at: z.string(),
})
export const createAgentPortRuleBodySchema = z
.object({
action: agentPortRuleActionSchema,
protocol: agentPortRuleProtocolSchema.default('tcp'),
port_start: z.number().int().min(1).max(65535),
port_end: z.number().int().min(1).max(65535).optional(),
src_kind: agentPortRuleSrcKindSchema.default('all'),
src_cidr: z.string().min(1).max(64).optional(),
list_id: z.string().min(1).optional(),
enabled: z.boolean().optional().default(true),
comment: z.string().max(500).optional(),
priority: z.number().int().optional().default(100),
})
.superRefine((v, ctx) => {
const end = v.port_end ?? v.port_start
if (end < v.port_start) {
ctx.addIssue({
code: 'custom',
message: 'port_end must be >= port_start',
path: ['port_end'],
})
}
if (v.src_kind === 'cidr' && !v.src_cidr?.trim()) {
ctx.addIssue({
code: 'custom',
message: 'src_cidr required when src_kind=cidr',
path: ['src_cidr'],
})
}
if (v.src_kind === 'list' && !v.list_id?.trim()) {
ctx.addIssue({
code: 'custom',
message: 'list_id required when src_kind=list',
path: ['list_id'],
})
}
})
export const updateAgentPortRuleBodySchema = z
.object({
action: agentPortRuleActionSchema.optional(),
protocol: agentPortRuleProtocolSchema.optional(),
port_start: z.number().int().min(1).max(65535).optional(),
port_end: z.number().int().min(1).max(65535).optional(),
src_kind: agentPortRuleSrcKindSchema.optional(),
src_cidr: z.string().min(1).max(64).nullable().optional(),
list_id: z.string().min(1).nullable().optional(),
enabled: z.boolean().optional(),
comment: z.string().max(500).nullable().optional(),
priority: z.number().int().optional(),
})
.refine((o) => Object.keys(o).length > 0, { message: 'empty update' })
export const importAgentPortRulesBodySchema = z
.object({
from: z.enum(['list', 'set']),
list_id: z.string().min(1).optional(),
set_id: z.string().min(1).optional(),
action: agentPortRuleActionSchema,
protocol: agentPortRuleProtocolSchema.default('tcp'),
ports: z
.array(
z.object({
port_start: z.number().int().min(1).max(65535),
port_end: z.number().int().min(1).max(65535).optional(),
}),
)
.min(1)
.max(50),
enabled: z.boolean().optional().default(true),
comment: z.string().max(500).optional(),
})
.superRefine((v, ctx) => {
if (v.from === 'list' && !v.list_id?.trim()) {
ctx.addIssue({
code: 'custom',
message: 'list_id required when from=list',
path: ['list_id'],
})
}
if (v.from === 'set' && !v.set_id?.trim()) {
ctx.addIssue({
code: 'custom',
message: 'set_id required when from=set',
path: ['set_id'],
})
}
})
/** Expanded port rule for agent policy apply_version >= 3. */
export const agentPolicyPortRuleSchema = z.object({
id: z.string(),
action: agentPortRuleActionSchema,
protocol: z.enum(['tcp', 'udp']),
port_start: z.number().int(),
port_end: z.number().int(),
src_cidrs: z.array(z.string()),
})
export const agentIpPortStatSchema = z.object({
@@ -277,6 +436,7 @@ export const agentPolicySchema = z.object({
policy_mode: policyModeSchema.optional(),
deny_cidrs: z.array(z.string()),
allow_cidrs: z.array(z.string()),
port_rules: z.array(agentPolicyPortRuleSchema).optional(),
sync_interval_sec: z.number().int(),
})
@@ -285,7 +445,7 @@ export const agentPolicyPreviewSchema = z.object({
hash: z.string(),
generation: z.number().int(),
sync_interval_sec: z.number().int(),
apply_version: z.literal(2),
apply_version: z.literal(3),
summary: z.object({
sets: z.number().int(),
rules_deny: z.number().int(),
@@ -294,6 +454,7 @@ export const agentPolicyPreviewSchema = z.object({
cidrs_allow: z.number().int(),
overrides: z.number().int(),
conflicts_dropped: z.number().int(),
port_rules: z.number().int().optional(),
}),
chain: z.array(
z.object({
@@ -310,6 +471,7 @@ export const agentPolicyPreviewSchema = z.object({
allow_cidrs: z.array(z.string()),
deny_cidrs_total: z.number().int(),
allow_cidrs_total: z.number().int(),
port_rules: z.array(agentPolicyPortRuleSchema).optional(),
})
export const dashboardStatsSchema = z.object({
@@ -387,6 +549,11 @@ export type PolicySet = z.infer<typeof policySetSchema>
export type IpOverride = z.infer<typeof ipOverrideSchema>
export type AgentPolicy = z.infer<typeof agentPolicySchema>
export type AgentPolicyPreview = z.infer<typeof agentPolicyPreviewSchema>
export type AgentPortRule = z.infer<typeof agentPortRuleSchema>
export type CreateAgentPortRuleBody = z.infer<typeof createAgentPortRuleBodySchema>
export type UpdateAgentPortRuleBody = z.infer<typeof updateAgentPortRuleBodySchema>
export type ImportAgentPortRulesBody = z.infer<typeof importAgentPortRulesBodySchema>
export type HostFirewallPayload = z.infer<typeof hostFirewallPayloadSchema>
export type DashboardStats = z.infer<typeof dashboardStatsSchema>
export type InstallLink = z.infer<typeof installLinkSchema>
export type EvobgpCommunity = z.infer<typeof evobgpCommunitySchema>