feat(api): unify policy handling with default action updates
- 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:
@@ -57,7 +57,12 @@ parse_policy() {
|
||||
local f="$1"
|
||||
if command -v jq >/dev/null 2>&1; then
|
||||
HASH=$(jq -r '.hash // empty' "$f")
|
||||
MODE=$(jq -r '.policy_mode // "blacklist"' "$f")
|
||||
DEFAULT_ACTION=$(jq -r '.default_action // empty' "$f")
|
||||
if [[ -z "$DEFAULT_ACTION" ]]; then
|
||||
local legacy
|
||||
legacy=$(jq -r '.policy_mode // "blacklist"' "$f")
|
||||
if [[ "$legacy" == "whitelist" ]]; then DEFAULT_ACTION=drop; else DEFAULT_ACTION=accept; fi
|
||||
fi
|
||||
mapfile -t DENY < <(jq -r '.deny_cidrs[]? // empty' "$f")
|
||||
mapfile -t ALLOW < <(jq -r '.allow_cidrs[]? // empty' "$f")
|
||||
return 0
|
||||
@@ -67,7 +72,10 @@ parse_policy() {
|
||||
import json,sys
|
||||
d=json.load(open(sys.argv[1],encoding="utf-8"))
|
||||
print(f'HASH={d.get("hash") or ""}')
|
||||
print(f'MODE={d.get("policy_mode") or "blacklist"}')
|
||||
da=d.get("default_action") or ""
|
||||
if not da:
|
||||
da="drop" if d.get("policy_mode")=="whitelist" else "accept"
|
||||
print(f'DEFAULT_ACTION={da}')
|
||||
print("DENY=("+" ".join(json.dumps(x) for x in (d.get("deny_cidrs") or []))+")")
|
||||
print("ALLOW=("+" ".join(json.dumps(x) for x in (d.get("allow_cidrs") or []))+")")
|
||||
PY
|
||||
@@ -78,12 +86,12 @@ PY
|
||||
exit 1
|
||||
}
|
||||
|
||||
HASH=""; MODE=blacklist; DENY=(); ALLOW=()
|
||||
HASH=""; DEFAULT_ACTION=accept; DENY=(); ALLOW=()
|
||||
parse_policy "$POLICY_FILE"
|
||||
# Empty deny/allow is valid — agent may have no rule sets yet.
|
||||
DENY=("${DENY[@]+"${DENY[@]}"}")
|
||||
ALLOW=("${ALLOW[@]+"${ALLOW[@]}"}")
|
||||
log "mode=$MODE deny=${#DENY[@]} allow=${#ALLOW[@]} hash=$HASH"
|
||||
log "default_action=$DEFAULT_ACTION deny=${#DENY[@]} allow=${#ALLOW[@]} hash=$HASH"
|
||||
|
||||
PACKETS_DROPPED=0
|
||||
PACKETS_ACCEPTED=0
|
||||
@@ -148,15 +156,19 @@ apply_nft() {
|
||||
((${#batch[@]})) && nft_add_chunk "$table" "$name" allow_v4 "${batch[@]}"
|
||||
|
||||
nft delete chain "$table" "$name" input 2>/dev/null || true
|
||||
if [[ "$MODE" == "whitelist" ]]; then
|
||||
# Unified chain: deny → allow → default_action
|
||||
if [[ "$DEFAULT_ACTION" == "drop" ]]; then
|
||||
nft add chain "$table" "$name" input '{ type filter hook input priority 0; policy drop; }'
|
||||
nft add rule "$table" "$name" input ct state established,related counter accept
|
||||
nft add rule "$table" "$name" input iif lo counter accept
|
||||
nft add rule "$table" "$name" input ip saddr @allow_v4 counter accept
|
||||
nft add rule "$table" "$name" input counter drop
|
||||
else
|
||||
nft add chain "$table" "$name" input '{ type filter hook input priority 0; policy accept; }'
|
||||
nft add rule "$table" "$name" input ip saddr @deny_v4 counter drop
|
||||
fi
|
||||
nft add rule "$table" "$name" input ct state established,related counter accept
|
||||
nft add rule "$table" "$name" input iif lo counter accept
|
||||
nft add rule "$table" "$name" input ip saddr @deny_v4 counter drop
|
||||
nft add rule "$table" "$name" input ip saddr @allow_v4 counter accept
|
||||
if [[ "$DEFAULT_ACTION" == "drop" ]]; then
|
||||
nft add rule "$table" "$name" input counter drop
|
||||
else
|
||||
nft add rule "$table" "$name" input counter accept
|
||||
fi
|
||||
KERNEL_METHOD=nft
|
||||
@@ -173,11 +185,12 @@ apply_ipset() {
|
||||
for p in "${ALLOW[@]+"${ALLOW[@]}"}"; do [[ "$p" == *:* ]] && continue; ipset add "$aset" "$p" -exist; n=$((n+1)); done
|
||||
iptables -D INPUT -m set --match-set "$dset" src -j DROP 2>/dev/null || true
|
||||
iptables -D INPUT -m set --match-set "$aset" src -j ACCEPT 2>/dev/null || true
|
||||
if [[ "$MODE" == "whitelist" ]]; then
|
||||
iptables -I INPUT -m set --match-set "$aset" src -j ACCEPT
|
||||
iptables -D INPUT -j DROP 2>/dev/null || true
|
||||
# Unified: deny first, then allow, then optional default drop
|
||||
iptables -I INPUT -m set --match-set "$dset" src -j DROP
|
||||
iptables -I INPUT 2 -m set --match-set "$aset" src -j ACCEPT
|
||||
if [[ "$DEFAULT_ACTION" == "drop" ]]; then
|
||||
iptables -A INPUT -j DROP 2>/dev/null || true
|
||||
else
|
||||
iptables -I INPUT -m set --match-set "$dset" src -j DROP
|
||||
fi
|
||||
KERNEL_METHOD=ipset
|
||||
APPLIED=$n
|
||||
|
||||
@@ -122,7 +122,7 @@ export const agentRoutes: FastifyPluginAsync<{ config: AppConfig }> = async (
|
||||
tokenPrefix: body.token.slice(0, 12),
|
||||
tokenHash,
|
||||
status: 'pending',
|
||||
policyMode: 'blacklist',
|
||||
defaultAction: 'accept',
|
||||
policyGeneration: 1,
|
||||
clientVersion: body.client_version ?? null,
|
||||
settingsJson: '{}',
|
||||
@@ -149,17 +149,19 @@ export const agentRoutes: FastifyPluginAsync<{ config: AppConfig }> = async (
|
||||
return {
|
||||
generation: policy.generation,
|
||||
hash: policy.hash,
|
||||
apply_version: policy.applyVersion,
|
||||
default_action: policy.defaultAction,
|
||||
policy_mode: policy.policyMode,
|
||||
deny_cidrs: policy.denyCidrs,
|
||||
allow_cidrs: policy.allowCidrs,
|
||||
sync_interval_sec: policy.syncIntervalSec,
|
||||
// compat aliases for simple clients
|
||||
// compat: prefixes = deny when default accept, else allow (legacy single-bag clients)
|
||||
prefixes:
|
||||
policy.policyMode === 'blacklist'
|
||||
policy.defaultAction === 'accept'
|
||||
? policy.denyCidrs
|
||||
: policy.allowCidrs,
|
||||
total:
|
||||
policy.policyMode === 'blacklist'
|
||||
policy.defaultAction === 'accept'
|
||||
? policy.denyCidrs.length
|
||||
: policy.allowCidrs.length,
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ import {
|
||||
deleteListEntry,
|
||||
mapListDetail,
|
||||
} from '../services/lists/entries.js'
|
||||
import { evaluateAgentPolicy } from '../services/policy/evaluate.js'
|
||||
import { evaluateAgentPolicy, truncateCidrs } from '../services/policy/evaluate.js'
|
||||
import {
|
||||
resolveAndStoreHostnameRule,
|
||||
resolveHostnameToCidrs,
|
||||
@@ -41,6 +41,8 @@ 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,
|
||||
@@ -48,7 +50,8 @@ function mapAgent(
|
||||
platform: a.platform,
|
||||
token_prefix: a.tokenPrefix,
|
||||
status: a.status,
|
||||
policy_mode: a.policyMode,
|
||||
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,
|
||||
@@ -77,8 +80,6 @@ function mapPolicySet(
|
||||
name: s.name,
|
||||
description: s.description,
|
||||
enabled: s.enabled === 1,
|
||||
policy_mode:
|
||||
s.policyMode === 'whitelist' ? ('whitelist' as const) : ('blacklist' as const),
|
||||
rules_count: repos.countRulesInSet(db, s.id),
|
||||
agents_count: repos.countAgentsForSet(db, s.id),
|
||||
created_at: s.createdAt,
|
||||
@@ -185,7 +186,7 @@ export const controlRoutes: FastifyPluginAsync<{ config: AppConfig }> = async (
|
||||
tokenPrefix: inviteToken.slice(0, 12),
|
||||
tokenHash: hashToken(inviteToken),
|
||||
status: 'invited',
|
||||
policyMode: 'blacklist',
|
||||
defaultAction: 'accept',
|
||||
policyGeneration: 1,
|
||||
clientVersion: null,
|
||||
settingsJson: '{}',
|
||||
@@ -252,16 +253,45 @@ export const controlRoutes: FastifyPluginAsync<{ config: AppConfig }> = async (
|
||||
return mapAgent(a)
|
||||
})
|
||||
|
||||
app.get<{ Params: { id: string } }>('/agents/:id/preview', async (req) => {
|
||||
app.get<{
|
||||
Params: { id: string }
|
||||
Querystring: { limit_cidrs?: string }
|
||||
}>('/agents/:id/preview', async (req) => {
|
||||
const a = repos.getAgent(app.db, req.params.id)
|
||||
if (!a) throw new AppError('NOT_FOUND', 'Agent not found', 404)
|
||||
const policy = evaluateAgentPolicy(app.db, a.id)
|
||||
const limitRaw = Number(req.query.limit_cidrs ?? '50')
|
||||
const limit = Number.isFinite(limitRaw)
|
||||
? Math.min(Math.max(0, Math.floor(limitRaw)), 5000)
|
||||
: 50
|
||||
return {
|
||||
...policy,
|
||||
deny_cidrs: policy.denyCidrs,
|
||||
allow_cidrs: policy.allowCidrs,
|
||||
policy_mode: policy.policyMode,
|
||||
default_action: policy.defaultAction,
|
||||
hash: policy.hash,
|
||||
generation: policy.generation,
|
||||
sync_interval_sec: policy.syncIntervalSec,
|
||||
apply_version: policy.applyVersion,
|
||||
summary: {
|
||||
sets: policy.summary.sets,
|
||||
rules_deny: policy.summary.rulesDeny,
|
||||
rules_allow: policy.summary.rulesAllow,
|
||||
cidrs_deny: policy.summary.cidrsDeny,
|
||||
cidrs_allow: policy.summary.cidrsAllow,
|
||||
overrides: policy.summary.overrides,
|
||||
conflicts_dropped: policy.summary.conflictsDropped,
|
||||
},
|
||||
chain: policy.chain.map((s) => ({
|
||||
set_id: s.setId,
|
||||
set_name: s.setName,
|
||||
rule_id: s.ruleId,
|
||||
action: s.action,
|
||||
source_kind: s.sourceKind,
|
||||
source_label: s.sourceLabel,
|
||||
cidr_count: s.cidrCount,
|
||||
})),
|
||||
deny_cidrs: truncateCidrs(policy.denyCidrs, limit),
|
||||
allow_cidrs: truncateCidrs(policy.allowCidrs, limit),
|
||||
deny_cidrs_total: policy.denyCidrs.length,
|
||||
allow_cidrs_total: policy.allowCidrs.length,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -269,14 +299,15 @@ export const controlRoutes: FastifyPluginAsync<{ config: AppConfig }> = async (
|
||||
const body = patchAgentBodySchema.parse(req.body)
|
||||
const a = repos.getAgent(app.db, req.params.id)
|
||||
if (!a) throw new AppError('NOT_FOUND', 'Agent not found', 404)
|
||||
const updated = repos.updateAgent(app.db, a.id, {
|
||||
const nextDefault = body.default_action
|
||||
const updated = repos.updateAgent(app.db, a.id, {
|
||||
name: body.name,
|
||||
policyMode: body.policy_mode,
|
||||
defaultAction: nextDefault,
|
||||
settingsJson: body.settings
|
||||
? JSON.stringify(body.settings)
|
||||
: undefined,
|
||||
policyGeneration:
|
||||
body.policy_mode && body.policy_mode !== a.policyMode
|
||||
nextDefault && nextDefault !== a.defaultAction
|
||||
? a.policyGeneration + 1
|
||||
: a.policyGeneration,
|
||||
})
|
||||
@@ -287,7 +318,7 @@ export const controlRoutes: FastifyPluginAsync<{ config: AppConfig }> = async (
|
||||
summary: `Обновлён агент: ${updated!.name}`,
|
||||
details: {
|
||||
agent_id: a.id,
|
||||
policy_mode: body.policy_mode,
|
||||
default_action: nextDefault,
|
||||
name: body.name,
|
||||
},
|
||||
})
|
||||
@@ -624,7 +655,7 @@ export const controlRoutes: FastifyPluginAsync<{ config: AppConfig }> = async (
|
||||
name: body.name.trim(),
|
||||
description: body.description ?? null,
|
||||
enabled: body.enabled === false ? 0 : 1,
|
||||
policyMode: body.policy_mode === 'whitelist' ? 'whitelist' : 'blacklist',
|
||||
policyMode: 'blacklist',
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
})
|
||||
@@ -633,7 +664,7 @@ export const controlRoutes: FastifyPluginAsync<{ config: AppConfig }> = async (
|
||||
targetType: 'app_resource',
|
||||
targetId: row!.id,
|
||||
summary: `Создан набор политик: ${row!.name}`,
|
||||
details: { set_id: row!.id, policy_mode: row!.policyMode },
|
||||
details: { set_id: row!.id },
|
||||
})
|
||||
return mapPolicySet(row!, app.db)
|
||||
})
|
||||
@@ -646,37 +677,10 @@ export const controlRoutes: FastifyPluginAsync<{ config: AppConfig }> = async (
|
||||
name: body.name?.trim(),
|
||||
description: body.description,
|
||||
enabled: body.enabled === undefined ? undefined : body.enabled ? 1 : 0,
|
||||
policyMode: body.policy_mode,
|
||||
})
|
||||
if (body.enabled !== undefined || body.policy_mode !== undefined) {
|
||||
if (body.enabled !== undefined) {
|
||||
repos.bumpAgentsForSet(app.db, s.id)
|
||||
}
|
||||
// Sync agent.policy_mode cache when set mode changes
|
||||
if (body.policy_mode) {
|
||||
for (const agentId of repos.listAgentIdsForSet(app.db, s.id)) {
|
||||
try {
|
||||
const sets = repos.listSetsForAgent(app.db, agentId)
|
||||
const modes = new Set(
|
||||
sets
|
||||
.filter((x) => x.enabled === 1)
|
||||
.map((x) =>
|
||||
x.policyMode === 'whitelist' ? 'whitelist' : 'blacklist',
|
||||
),
|
||||
)
|
||||
if (modes.size > 1) {
|
||||
throw new AppError(
|
||||
'VALIDATION_ERROR',
|
||||
'агент имеет наборы с разными режимами — выровняйте mode',
|
||||
400,
|
||||
)
|
||||
}
|
||||
const mode = [...modes][0] ?? body.policy_mode
|
||||
repos.updateAgent(app.db, agentId, { policyMode: mode })
|
||||
} catch (err) {
|
||||
if (err instanceof AppError) throw err
|
||||
}
|
||||
}
|
||||
}
|
||||
auditMutation(app, config, req, {
|
||||
action: 'policy_set.update',
|
||||
targetType: 'app_resource',
|
||||
@@ -685,7 +689,6 @@ export const controlRoutes: FastifyPluginAsync<{ config: AppConfig }> = async (
|
||||
details: {
|
||||
set_id: s.id,
|
||||
enabled: body.enabled,
|
||||
policy_mode: body.policy_mode,
|
||||
name: body.name,
|
||||
},
|
||||
})
|
||||
@@ -765,8 +768,6 @@ export const controlRoutes: FastifyPluginAsync<{ config: AppConfig }> = async (
|
||||
name: s.name,
|
||||
description: s.description,
|
||||
enabled: s.enabled === 1,
|
||||
policy_mode:
|
||||
s.policyMode === 'whitelist' ? 'whitelist' : 'blacklist',
|
||||
})),
|
||||
}
|
||||
},
|
||||
@@ -784,8 +785,6 @@ export const controlRoutes: FastifyPluginAsync<{ config: AppConfig }> = async (
|
||||
name: s.name,
|
||||
description: s.description,
|
||||
enabled: s.enabled === 1,
|
||||
policy_mode:
|
||||
s.policyMode === 'whitelist' ? 'whitelist' : 'blacklist',
|
||||
})),
|
||||
}
|
||||
},
|
||||
@@ -981,6 +980,49 @@ export const controlRoutes: FastifyPluginAsync<{ config: AppConfig }> = async (
|
||||
})),
|
||||
}))
|
||||
|
||||
/** Proxy EvoBGP communities for UI autocomplete. */
|
||||
app.get('/integrations/evobgp/communities', async () => {
|
||||
const apiUrl = repos.getSetting(app.db, 'evobgp_api_url')
|
||||
const token = repos.getSetting(app.db, 'evobgp_api_token')
|
||||
if (!apiUrl || !token) {
|
||||
throw new AppError(
|
||||
'VALIDATION_ERROR',
|
||||
'Настройте evobgp_api_url и evobgp_api_token',
|
||||
400,
|
||||
)
|
||||
}
|
||||
const base = apiUrl.replace(/\/$/, '')
|
||||
const res = await fetch(`${base}/v1/communities?limit=200`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
Accept: 'application/json',
|
||||
},
|
||||
signal: AbortSignal.timeout(20_000),
|
||||
})
|
||||
if (!res.ok) {
|
||||
throw new AppError(
|
||||
'UPSTREAM_ERROR',
|
||||
`EvoBGP communities HTTP ${res.status}`,
|
||||
502,
|
||||
)
|
||||
}
|
||||
const data = (await res.json()) as {
|
||||
items?: {
|
||||
id?: string
|
||||
community?: string
|
||||
title?: string | null
|
||||
}[]
|
||||
}
|
||||
const items = (data.items ?? [])
|
||||
.filter((x) => x.id && x.community)
|
||||
.map((x) => ({
|
||||
id: x.id!,
|
||||
community: x.community!,
|
||||
title: x.title ?? null,
|
||||
}))
|
||||
return { items }
|
||||
})
|
||||
|
||||
// Settings
|
||||
app.get('/settings', async () => {
|
||||
const rows = repos.listSettings(app.db)
|
||||
|
||||
@@ -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' })
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user