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"
|
local f="$1"
|
||||||
if command -v jq >/dev/null 2>&1; then
|
if command -v jq >/dev/null 2>&1; then
|
||||||
HASH=$(jq -r '.hash // empty' "$f")
|
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 DENY < <(jq -r '.deny_cidrs[]? // empty' "$f")
|
||||||
mapfile -t ALLOW < <(jq -r '.allow_cidrs[]? // empty' "$f")
|
mapfile -t ALLOW < <(jq -r '.allow_cidrs[]? // empty' "$f")
|
||||||
return 0
|
return 0
|
||||||
@@ -67,7 +72,10 @@ parse_policy() {
|
|||||||
import json,sys
|
import json,sys
|
||||||
d=json.load(open(sys.argv[1],encoding="utf-8"))
|
d=json.load(open(sys.argv[1],encoding="utf-8"))
|
||||||
print(f'HASH={d.get("hash") or ""}')
|
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("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 []))+")")
|
print("ALLOW=("+" ".join(json.dumps(x) for x in (d.get("allow_cidrs") or []))+")")
|
||||||
PY
|
PY
|
||||||
@@ -78,12 +86,12 @@ PY
|
|||||||
exit 1
|
exit 1
|
||||||
}
|
}
|
||||||
|
|
||||||
HASH=""; MODE=blacklist; DENY=(); ALLOW=()
|
HASH=""; DEFAULT_ACTION=accept; DENY=(); ALLOW=()
|
||||||
parse_policy "$POLICY_FILE"
|
parse_policy "$POLICY_FILE"
|
||||||
# Empty deny/allow is valid — agent may have no rule sets yet.
|
# Empty deny/allow is valid — agent may have no rule sets yet.
|
||||||
DENY=("${DENY[@]+"${DENY[@]}"}")
|
DENY=("${DENY[@]+"${DENY[@]}"}")
|
||||||
ALLOW=("${ALLOW[@]+"${ALLOW[@]}"}")
|
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_DROPPED=0
|
||||||
PACKETS_ACCEPTED=0
|
PACKETS_ACCEPTED=0
|
||||||
@@ -148,15 +156,19 @@ apply_nft() {
|
|||||||
((${#batch[@]})) && nft_add_chunk "$table" "$name" allow_v4 "${batch[@]}"
|
((${#batch[@]})) && nft_add_chunk "$table" "$name" allow_v4 "${batch[@]}"
|
||||||
|
|
||||||
nft delete chain "$table" "$name" input 2>/dev/null || true
|
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 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
|
else
|
||||||
nft add chain "$table" "$name" input '{ type filter hook input priority 0; policy accept; }'
|
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
|
nft add rule "$table" "$name" input counter accept
|
||||||
fi
|
fi
|
||||||
KERNEL_METHOD=nft
|
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
|
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 "$dset" src -j DROP 2>/dev/null || true
|
||||||
iptables -D INPUT -m set --match-set "$aset" src -j ACCEPT 2>/dev/null || true
|
iptables -D INPUT -m set --match-set "$aset" src -j ACCEPT 2>/dev/null || true
|
||||||
if [[ "$MODE" == "whitelist" ]]; then
|
iptables -D INPUT -j DROP 2>/dev/null || true
|
||||||
iptables -I INPUT -m set --match-set "$aset" src -j ACCEPT
|
# 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
|
iptables -A INPUT -j DROP 2>/dev/null || true
|
||||||
else
|
|
||||||
iptables -I INPUT -m set --match-set "$dset" src -j DROP
|
|
||||||
fi
|
fi
|
||||||
KERNEL_METHOD=ipset
|
KERNEL_METHOD=ipset
|
||||||
APPLIED=$n
|
APPLIED=$n
|
||||||
|
|||||||
@@ -122,7 +122,7 @@ export const agentRoutes: FastifyPluginAsync<{ config: AppConfig }> = async (
|
|||||||
tokenPrefix: body.token.slice(0, 12),
|
tokenPrefix: body.token.slice(0, 12),
|
||||||
tokenHash,
|
tokenHash,
|
||||||
status: 'pending',
|
status: 'pending',
|
||||||
policyMode: 'blacklist',
|
defaultAction: 'accept',
|
||||||
policyGeneration: 1,
|
policyGeneration: 1,
|
||||||
clientVersion: body.client_version ?? null,
|
clientVersion: body.client_version ?? null,
|
||||||
settingsJson: '{}',
|
settingsJson: '{}',
|
||||||
@@ -149,17 +149,19 @@ export const agentRoutes: FastifyPluginAsync<{ config: AppConfig }> = async (
|
|||||||
return {
|
return {
|
||||||
generation: policy.generation,
|
generation: policy.generation,
|
||||||
hash: policy.hash,
|
hash: policy.hash,
|
||||||
|
apply_version: policy.applyVersion,
|
||||||
|
default_action: policy.defaultAction,
|
||||||
policy_mode: policy.policyMode,
|
policy_mode: policy.policyMode,
|
||||||
deny_cidrs: policy.denyCidrs,
|
deny_cidrs: policy.denyCidrs,
|
||||||
allow_cidrs: policy.allowCidrs,
|
allow_cidrs: policy.allowCidrs,
|
||||||
sync_interval_sec: policy.syncIntervalSec,
|
sync_interval_sec: policy.syncIntervalSec,
|
||||||
// compat aliases for simple clients
|
// compat: prefixes = deny when default accept, else allow (legacy single-bag clients)
|
||||||
prefixes:
|
prefixes:
|
||||||
policy.policyMode === 'blacklist'
|
policy.defaultAction === 'accept'
|
||||||
? policy.denyCidrs
|
? policy.denyCidrs
|
||||||
: policy.allowCidrs,
|
: policy.allowCidrs,
|
||||||
total:
|
total:
|
||||||
policy.policyMode === 'blacklist'
|
policy.defaultAction === 'accept'
|
||||||
? policy.denyCidrs.length
|
? policy.denyCidrs.length
|
||||||
: policy.allowCidrs.length,
|
: policy.allowCidrs.length,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ import {
|
|||||||
deleteListEntry,
|
deleteListEntry,
|
||||||
mapListDetail,
|
mapListDetail,
|
||||||
} from '../services/lists/entries.js'
|
} from '../services/lists/entries.js'
|
||||||
import { evaluateAgentPolicy } from '../services/policy/evaluate.js'
|
import { evaluateAgentPolicy, truncateCidrs } from '../services/policy/evaluate.js'
|
||||||
import {
|
import {
|
||||||
resolveAndStoreHostnameRule,
|
resolveAndStoreHostnameRule,
|
||||||
resolveHostnameToCidrs,
|
resolveHostnameToCidrs,
|
||||||
@@ -41,6 +41,8 @@ function mapAgent(
|
|||||||
a: NonNullable<ReturnType<typeof repos.getAgent>>,
|
a: NonNullable<ReturnType<typeof repos.getAgent>>,
|
||||||
opts?: { installCurl?: string | null; installLinkId?: string | null },
|
opts?: { installCurl?: string | null; installLinkId?: string | null },
|
||||||
) {
|
) {
|
||||||
|
const defaultAction =
|
||||||
|
a.defaultAction === 'drop' ? ('drop' as const) : ('accept' as const)
|
||||||
return {
|
return {
|
||||||
id: a.id,
|
id: a.id,
|
||||||
name: a.name,
|
name: a.name,
|
||||||
@@ -48,7 +50,8 @@ function mapAgent(
|
|||||||
platform: a.platform,
|
platform: a.platform,
|
||||||
token_prefix: a.tokenPrefix,
|
token_prefix: a.tokenPrefix,
|
||||||
status: a.status,
|
status: a.status,
|
||||||
policy_mode: a.policyMode,
|
default_action: defaultAction,
|
||||||
|
policy_mode: defaultAction === 'drop' ? ('whitelist' as const) : ('blacklist' as const),
|
||||||
policy_generation: a.policyGeneration,
|
policy_generation: a.policyGeneration,
|
||||||
last_seen_at: a.lastSeenAt,
|
last_seen_at: a.lastSeenAt,
|
||||||
last_seen_ip: a.lastSeenIp,
|
last_seen_ip: a.lastSeenIp,
|
||||||
@@ -77,8 +80,6 @@ function mapPolicySet(
|
|||||||
name: s.name,
|
name: s.name,
|
||||||
description: s.description,
|
description: s.description,
|
||||||
enabled: s.enabled === 1,
|
enabled: s.enabled === 1,
|
||||||
policy_mode:
|
|
||||||
s.policyMode === 'whitelist' ? ('whitelist' as const) : ('blacklist' as const),
|
|
||||||
rules_count: repos.countRulesInSet(db, s.id),
|
rules_count: repos.countRulesInSet(db, s.id),
|
||||||
agents_count: repos.countAgentsForSet(db, s.id),
|
agents_count: repos.countAgentsForSet(db, s.id),
|
||||||
created_at: s.createdAt,
|
created_at: s.createdAt,
|
||||||
@@ -185,7 +186,7 @@ export const controlRoutes: FastifyPluginAsync<{ config: AppConfig }> = async (
|
|||||||
tokenPrefix: inviteToken.slice(0, 12),
|
tokenPrefix: inviteToken.slice(0, 12),
|
||||||
tokenHash: hashToken(inviteToken),
|
tokenHash: hashToken(inviteToken),
|
||||||
status: 'invited',
|
status: 'invited',
|
||||||
policyMode: 'blacklist',
|
defaultAction: 'accept',
|
||||||
policyGeneration: 1,
|
policyGeneration: 1,
|
||||||
clientVersion: null,
|
clientVersion: null,
|
||||||
settingsJson: '{}',
|
settingsJson: '{}',
|
||||||
@@ -252,16 +253,45 @@ export const controlRoutes: FastifyPluginAsync<{ config: AppConfig }> = async (
|
|||||||
return mapAgent(a)
|
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)
|
const a = repos.getAgent(app.db, req.params.id)
|
||||||
if (!a) throw new AppError('NOT_FOUND', 'Agent not found', 404)
|
if (!a) throw new AppError('NOT_FOUND', 'Agent not found', 404)
|
||||||
const policy = evaluateAgentPolicy(app.db, a.id)
|
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 {
|
return {
|
||||||
...policy,
|
default_action: policy.defaultAction,
|
||||||
deny_cidrs: policy.denyCidrs,
|
hash: policy.hash,
|
||||||
allow_cidrs: policy.allowCidrs,
|
generation: policy.generation,
|
||||||
policy_mode: policy.policyMode,
|
|
||||||
sync_interval_sec: policy.syncIntervalSec,
|
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 body = patchAgentBodySchema.parse(req.body)
|
||||||
const a = repos.getAgent(app.db, req.params.id)
|
const a = repos.getAgent(app.db, req.params.id)
|
||||||
if (!a) throw new AppError('NOT_FOUND', 'Agent not found', 404)
|
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,
|
name: body.name,
|
||||||
policyMode: body.policy_mode,
|
defaultAction: nextDefault,
|
||||||
settingsJson: body.settings
|
settingsJson: body.settings
|
||||||
? JSON.stringify(body.settings)
|
? JSON.stringify(body.settings)
|
||||||
: undefined,
|
: undefined,
|
||||||
policyGeneration:
|
policyGeneration:
|
||||||
body.policy_mode && body.policy_mode !== a.policyMode
|
nextDefault && nextDefault !== a.defaultAction
|
||||||
? a.policyGeneration + 1
|
? a.policyGeneration + 1
|
||||||
: a.policyGeneration,
|
: a.policyGeneration,
|
||||||
})
|
})
|
||||||
@@ -287,7 +318,7 @@ export const controlRoutes: FastifyPluginAsync<{ config: AppConfig }> = async (
|
|||||||
summary: `Обновлён агент: ${updated!.name}`,
|
summary: `Обновлён агент: ${updated!.name}`,
|
||||||
details: {
|
details: {
|
||||||
agent_id: a.id,
|
agent_id: a.id,
|
||||||
policy_mode: body.policy_mode,
|
default_action: nextDefault,
|
||||||
name: body.name,
|
name: body.name,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -624,7 +655,7 @@ export const controlRoutes: FastifyPluginAsync<{ config: AppConfig }> = async (
|
|||||||
name: body.name.trim(),
|
name: body.name.trim(),
|
||||||
description: body.description ?? null,
|
description: body.description ?? null,
|
||||||
enabled: body.enabled === false ? 0 : 1,
|
enabled: body.enabled === false ? 0 : 1,
|
||||||
policyMode: body.policy_mode === 'whitelist' ? 'whitelist' : 'blacklist',
|
policyMode: 'blacklist',
|
||||||
createdAt: new Date().toISOString(),
|
createdAt: new Date().toISOString(),
|
||||||
updatedAt: new Date().toISOString(),
|
updatedAt: new Date().toISOString(),
|
||||||
})
|
})
|
||||||
@@ -633,7 +664,7 @@ export const controlRoutes: FastifyPluginAsync<{ config: AppConfig }> = async (
|
|||||||
targetType: 'app_resource',
|
targetType: 'app_resource',
|
||||||
targetId: row!.id,
|
targetId: row!.id,
|
||||||
summary: `Создан набор политик: ${row!.name}`,
|
summary: `Создан набор политик: ${row!.name}`,
|
||||||
details: { set_id: row!.id, policy_mode: row!.policyMode },
|
details: { set_id: row!.id },
|
||||||
})
|
})
|
||||||
return mapPolicySet(row!, app.db)
|
return mapPolicySet(row!, app.db)
|
||||||
})
|
})
|
||||||
@@ -646,37 +677,10 @@ export const controlRoutes: FastifyPluginAsync<{ config: AppConfig }> = async (
|
|||||||
name: body.name?.trim(),
|
name: body.name?.trim(),
|
||||||
description: body.description,
|
description: body.description,
|
||||||
enabled: body.enabled === undefined ? undefined : body.enabled ? 1 : 0,
|
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)
|
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, {
|
auditMutation(app, config, req, {
|
||||||
action: 'policy_set.update',
|
action: 'policy_set.update',
|
||||||
targetType: 'app_resource',
|
targetType: 'app_resource',
|
||||||
@@ -685,7 +689,6 @@ export const controlRoutes: FastifyPluginAsync<{ config: AppConfig }> = async (
|
|||||||
details: {
|
details: {
|
||||||
set_id: s.id,
|
set_id: s.id,
|
||||||
enabled: body.enabled,
|
enabled: body.enabled,
|
||||||
policy_mode: body.policy_mode,
|
|
||||||
name: body.name,
|
name: body.name,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -765,8 +768,6 @@ export const controlRoutes: FastifyPluginAsync<{ config: AppConfig }> = async (
|
|||||||
name: s.name,
|
name: s.name,
|
||||||
description: s.description,
|
description: s.description,
|
||||||
enabled: s.enabled === 1,
|
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,
|
name: s.name,
|
||||||
description: s.description,
|
description: s.description,
|
||||||
enabled: s.enabled === 1,
|
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
|
// Settings
|
||||||
app.get('/settings', async () => {
|
app.get('/settings', async () => {
|
||||||
const rows = repos.listSettings(app.db)
|
const rows = repos.listSettings(app.db)
|
||||||
|
|||||||
@@ -186,12 +186,16 @@ describe('install-links', () => {
|
|||||||
const body = policy.json() as {
|
const body = policy.json() as {
|
||||||
deny_cidrs: string[]
|
deny_cidrs: string[]
|
||||||
allow_cidrs: string[]
|
allow_cidrs: string[]
|
||||||
|
default_action: string
|
||||||
policy_mode: string
|
policy_mode: string
|
||||||
|
apply_version: number
|
||||||
hash: string
|
hash: string
|
||||||
}
|
}
|
||||||
expect(body.deny_cidrs).toEqual([])
|
expect(body.deny_cidrs).toEqual([])
|
||||||
expect(body.allow_cidrs).toEqual([])
|
expect(body.allow_cidrs).toEqual([])
|
||||||
|
expect(body.default_action).toBe('accept')
|
||||||
expect(body.policy_mode).toBe('blacklist')
|
expect(body.policy_mode).toBe('blacklist')
|
||||||
|
expect(body.apply_version).toBe(2)
|
||||||
expect(body.hash).toMatch(/^sha256:/)
|
expect(body.hash).toMatch(/^sha256:/)
|
||||||
|
|
||||||
const agents = await app.inject({ method: 'GET', url: '/api/v1/agents' })
|
const agents = await app.inject({ method: 'GET', url: '/api/v1/agents' })
|
||||||
|
|||||||
@@ -57,8 +57,7 @@ async function fetchEvobgpCommunity(
|
|||||||
communityId: string,
|
communityId: string,
|
||||||
): Promise<string[]> {
|
): Promise<string[]> {
|
||||||
const base = apiUrl.replace(/\/$/, '')
|
const base = apiUrl.replace(/\/$/, '')
|
||||||
// Prefer published revision prefixes filtered by community when available.
|
const url = `${base}/v1/communities/${encodeURIComponent(communityId)}/prefixes?limit=5000`
|
||||||
const url = `${base}/v1/directories/communities/${encodeURIComponent(communityId)}/prefixes`
|
|
||||||
const res = await fetch(url, {
|
const res = await fetch(url, {
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${token}`,
|
Authorization: `Bearer ${token}`,
|
||||||
@@ -66,27 +65,20 @@ async function fetchEvobgpCommunity(
|
|||||||
},
|
},
|
||||||
signal: AbortSignal.timeout(45_000),
|
signal: AbortSignal.timeout(45_000),
|
||||||
})
|
})
|
||||||
if (res.ok) {
|
if (!res.ok) {
|
||||||
const data = (await res.json()) as { items?: { prefix?: string }[]; prefixes?: string[] }
|
throw new Error(`EvoBGP community prefixes HTTP ${res.status}`)
|
||||||
if (Array.isArray(data.prefixes)) return uniq(data.prefixes)
|
|
||||||
if (Array.isArray(data.items)) {
|
|
||||||
return uniq(data.items.map((i) => i.prefix ?? '').filter(Boolean))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
// Fallback: modules lookup / openapi-compatible list
|
const data = (await res.json()) as {
|
||||||
const alt = `${base}/v1/lookup?q=${encodeURIComponent(communityId)}`
|
items?: { prefix?: string }[]
|
||||||
const res2 = await fetch(alt, {
|
prefixes?: string[]
|
||||||
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 data2 = (await res2.json()) as { prefixes?: string[] }
|
if (Array.isArray(data.prefixes) && data.prefixes.length > 0) {
|
||||||
return uniq(data2.prefixes ?? [])
|
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> {
|
export async function refreshIpList(db: Db, listId: string): Promise<void> {
|
||||||
|
|||||||
@@ -1,14 +1,45 @@
|
|||||||
import { createHash } from 'node:crypto'
|
import { createHash } from 'node:crypto'
|
||||||
import type { Db } from '@evofw/db'
|
import type { Db } from '@evofw/db'
|
||||||
import { repos } 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 = {
|
export type EvaluatedPolicy = {
|
||||||
generation: number
|
generation: number
|
||||||
hash: string
|
hash: string
|
||||||
|
applyVersion: typeof POLICY_APPLY_VERSION
|
||||||
|
defaultAction: DefaultAction
|
||||||
|
/** @deprecated mirror for old agents */
|
||||||
policyMode: 'blacklist' | 'whitelist'
|
policyMode: 'blacklist' | 'whitelist'
|
||||||
denyCidrs: string[]
|
denyCidrs: string[]
|
||||||
allowCidrs: string[]
|
allowCidrs: string[]
|
||||||
|
conflictsDropped: number
|
||||||
syncIntervalSec: 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[] {
|
function uniq(cidrs: string[]): string[] {
|
||||||
@@ -44,17 +75,25 @@ function expandRule(
|
|||||||
return expandList(db, rule.listId)
|
return expandList(db, rule.listId)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Effective mode = first enabled assigned set (by sort); default blacklist. */
|
function resolveDefaultAction(agentDefaultAction: string | null | undefined): DefaultAction {
|
||||||
export function resolveAgentPolicyMode(
|
if (agentDefaultAction === 'drop' || agentDefaultAction === 'accept') {
|
||||||
db: Db,
|
return agentDefaultAction
|
||||||
agentId: string,
|
}
|
||||||
): 'blacklist' | 'whitelist' {
|
return defaultActionFromLegacyMode(agentDefaultAction)
|
||||||
const sets = repos
|
}
|
||||||
.listSetsForAgent(db, agentId)
|
|
||||||
.filter((s) => s.enabled === 1)
|
function sourceMeta(rule: {
|
||||||
if (sets.length === 0) return 'blacklist'
|
cidr: string | null
|
||||||
const mode = sets[0]?.policyMode
|
listId: string | null
|
||||||
return mode === 'whitelist' ? 'whitelist' : 'blacklist'
|
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. */
|
/** 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}`)
|
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 ordered = repos.listPolicyRulesForAgent(db, agentId)
|
||||||
|
const overrides = repos.listOverrides(db, agentId)
|
||||||
|
|
||||||
const deny: string[] = []
|
const deny: string[] = []
|
||||||
const allow: string[] = []
|
const allow: string[] = []
|
||||||
|
const chain: PolicyChainStep[] = []
|
||||||
|
let rulesDeny = 0
|
||||||
|
let rulesAllow = 0
|
||||||
|
|
||||||
for (const rule of ordered) {
|
for (const rule of ordered) {
|
||||||
const cidrs = expandRule(db, rule)
|
const cidrs = expandRule(db, rule)
|
||||||
if (rule.action === 'deny') deny.push(...cidrs)
|
const action = rule.action === 'deny' ? 'deny' : 'allow'
|
||||||
else allow.push(...cidrs)
|
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)) {
|
for (const o of overrides) {
|
||||||
if (o.action === 'deny') deny.push(o.cidr)
|
const action = o.action === 'deny' ? 'deny' : 'allow'
|
||||||
|
if (action === 'deny') deny.push(o.cidr)
|
||||||
else allow.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 denyCidrs = uniq(deny)
|
||||||
const allowCidrs = uniq(allow)
|
const denySet = new Set(denyCidrs)
|
||||||
const policyMode = resolveAgentPolicyMode(db, agentId)
|
const allowRaw = uniq(allow)
|
||||||
|
const allowCidrs = allowRaw.filter((c) => !denySet.has(c))
|
||||||
// Keep agent.policy_mode cache in sync for list/API compat
|
const conflictsDropped = allowRaw.length - allowCidrs.length
|
||||||
if (agent.policyMode !== policyMode) {
|
const defaultAction = resolveDefaultAction(agent.defaultAction)
|
||||||
repos.updateAgent(db, agentId, { policyMode })
|
const policyMode = legacyModeFromDefaultAction(defaultAction)
|
||||||
}
|
|
||||||
|
|
||||||
const payload = JSON.stringify({
|
const payload = JSON.stringify({
|
||||||
|
apply_version: POLICY_APPLY_VERSION,
|
||||||
generation: agent.policyGeneration,
|
generation: agent.policyGeneration,
|
||||||
policyMode,
|
defaultAction,
|
||||||
denyCidrs,
|
denyCidrs,
|
||||||
allowCidrs,
|
allowCidrs,
|
||||||
})
|
})
|
||||||
@@ -103,9 +177,27 @@ export function evaluateAgentPolicy(db: Db, agentId: string): EvaluatedPolicy {
|
|||||||
return {
|
return {
|
||||||
generation: agent.policyGeneration,
|
generation: agent.policyGeneration,
|
||||||
hash,
|
hash,
|
||||||
|
applyVersion: POLICY_APPLY_VERSION,
|
||||||
|
defaultAction,
|
||||||
policyMode,
|
policyMode,
|
||||||
denyCidrs,
|
denyCidrs,
|
||||||
allowCidrs,
|
allowCidrs,
|
||||||
|
conflictsDropped,
|
||||||
syncIntervalSec,
|
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 { describe, expect, it } from 'vitest'
|
||||||
import { renderMikrotikPolicyRsc, isIpv4Cidr } from './mikrotik-rsc.js'
|
import {
|
||||||
import type { EvaluatedPolicy } from './evaluate.js'
|
POLICY_APPLY_VERSION,
|
||||||
|
type EvaluatedPolicy,
|
||||||
|
} from './evaluate.js'
|
||||||
|
import { renderMikrotikPolicyRsc } from './mikrotik-rsc.js'
|
||||||
|
|
||||||
function basePolicy(
|
function basePolicy(
|
||||||
overrides: Partial<EvaluatedPolicy> = {},
|
patch: Partial<EvaluatedPolicy> = {},
|
||||||
): EvaluatedPolicy {
|
): EvaluatedPolicy {
|
||||||
return {
|
return {
|
||||||
generation: 3,
|
generation: 3,
|
||||||
hash: 'sha256:abc',
|
hash: 'sha256:abc',
|
||||||
|
applyVersion: POLICY_APPLY_VERSION,
|
||||||
|
defaultAction: 'accept',
|
||||||
policyMode: 'blacklist',
|
policyMode: 'blacklist',
|
||||||
denyCidrs: ['1.2.3.0/24', '2001:db8::/32', '10.0.0.1/32'],
|
denyCidrs: ['1.2.3.0/24', '2001:db8::/32', '10.0.0.1/32'],
|
||||||
allowCidrs: ['8.8.8.8/32', 'fe80::1/128'],
|
allowCidrs: ['8.8.8.8/32', 'fe80::1/128'],
|
||||||
|
conflictsDropped: 0,
|
||||||
syncIntervalSec: 60,
|
syncIntervalSec: 60,
|
||||||
...overrides,
|
chain: [],
|
||||||
|
summary: {
|
||||||
|
sets: 1,
|
||||||
|
rulesDeny: 1,
|
||||||
|
rulesAllow: 1,
|
||||||
|
cidrsDeny: 2,
|
||||||
|
cidrsAllow: 1,
|
||||||
|
overrides: 0,
|
||||||
|
conflictsDropped: 0,
|
||||||
|
},
|
||||||
|
...patch,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('mikrotik-rsc', () => {
|
describe('renderMikrotikPolicyRsc', () => {
|
||||||
it('isIpv4Cidr skips IPv6', () => {
|
it('renders accept default: lists + default-drop disabled', () => {
|
||||||
expect(isIpv4Cidr('1.2.3.0/24')).toBe(true)
|
|
||||||
expect(isIpv4Cidr('2001:db8::/32')).toBe(false)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('renders blacklist: lists + BL enabled / WL disabled', () => {
|
|
||||||
const rsc = renderMikrotikPolicyRsc(basePolicy())
|
const rsc = renderMikrotikPolicyRsc(basePolicy())
|
||||||
expect(rsc).toContain('# evofw hash=sha256:abc mode=blacklist gen=3')
|
|
||||||
expect(rsc).toContain(
|
expect(rsc).toContain(
|
||||||
'/ip firewall address-list remove [find list=EVOFW_DENY]',
|
'# evofw hash=sha256:abc default_action=accept apply_version=2 gen=3',
|
||||||
)
|
|
||||||
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',
|
|
||||||
)
|
)
|
||||||
|
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).not.toContain('2001:db8')
|
||||||
expect(rsc).toContain(
|
expect(rsc).toContain(
|
||||||
'add list=EVOFW_ALLOW address=8.8.8.8/32 comment=evofw',
|
'evofw-default-drop-forward] disabled=yes',
|
||||||
)
|
|
||||||
expect(rsc).toContain(
|
|
||||||
'set [find comment=evofw-bl-drop-input] disabled=no',
|
|
||||||
)
|
|
||||||
expect(rsc).toContain(
|
|
||||||
'set [find comment=evofw-wl-accept-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(
|
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(
|
expect(rsc).toContain(
|
||||||
'set [find comment=evofw-bl-drop-forward] disabled=yes',
|
'evofw-default-drop-forward] disabled=no',
|
||||||
)
|
|
||||||
expect(rsc).toContain(
|
|
||||||
'set [find comment=evofw-wl-drop-forward] disabled=no',
|
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -7,23 +7,25 @@ export function isIpv4Cidr(cidr: string): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function escAddress(cidr: string): string {
|
function escAddress(cidr: string): string {
|
||||||
// CIDRs are alphanumeric + . / - ; quote if anything odd
|
|
||||||
const t = cidr.trim()
|
const t = cidr.trim()
|
||||||
if (/^[0-9./-]+$/.test(t)) return t
|
if (/^[0-9./-]+$/.test(t)) return t
|
||||||
return `"${t.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`
|
return `"${t.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* RouterOS 7.x script: rebuild EVOFW_* address-lists and toggle filter mode.
|
* RouterOS 7.x script: rebuild EVOFW_* address-lists.
|
||||||
* Device: /tool fetch → /import (no JSON parse on router).
|
* 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 {
|
export function renderMikrotikPolicyRsc(policy: EvaluatedPolicy): string {
|
||||||
const isBl = policy.policyMode === 'blacklist'
|
const defaultDrop = policy.defaultAction === 'drop'
|
||||||
const blDisabled = isBl ? 'no' : 'yes'
|
const defaultDropDisabled = defaultDrop ? 'no' : 'yes'
|
||||||
const wlDisabled = isBl ? 'yes' : 'no'
|
|
||||||
|
|
||||||
const lines: string[] = [
|
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_DENY]',
|
||||||
'/ip firewall address-list remove [find list=EVOFW_ALLOW]',
|
'/ip firewall address-list remove [find list=EVOFW_ALLOW]',
|
||||||
]
|
]
|
||||||
@@ -42,10 +44,15 @@ export function renderMikrotikPolicyRsc(policy: EvaluatedPolicy): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
lines.push(
|
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-deny-drop-input] disabled=no } 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-deny-drop-forward] disabled=no } 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-allow-accept-forward] disabled=no } 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-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`
|
return `${lines.join('\n')}\n`
|
||||||
|
|||||||
@@ -16,7 +16,25 @@ const testConfig: AppConfig = {
|
|||||||
enrollSeed: 'test-seed',
|
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 })
|
const appPromise = buildApp({ memory: true, config: testConfig })
|
||||||
|
|
||||||
afterAll(async () => {
|
afterAll(async () => {
|
||||||
@@ -24,104 +42,99 @@ describe('policy set mode + rules', () => {
|
|||||||
await app.close()
|
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
|
const app = await appPromise
|
||||||
await app.ready()
|
await app.ready()
|
||||||
|
|
||||||
const created = await app.inject({
|
const created = await app.inject({
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
url: '/api/v1/policy-sets',
|
url: '/api/v1/policy-sets',
|
||||||
payload: {
|
payload: { name: 'mixed-set' },
|
||||||
name: 'WL set',
|
|
||||||
policy_mode: 'whitelist',
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
expect(created.statusCode).toBe(200)
|
expect(created.statusCode).toBe(200)
|
||||||
const set = created.json() as { id: string; policy_mode: string }
|
const setId = (created.json() as { id: string }).id
|
||||||
expect(set.policy_mode).toBe('whitelist')
|
|
||||||
|
|
||||||
const r1 = await app.inject({
|
for (const payload of [
|
||||||
method: 'POST',
|
{ set_id: setId, action: 'deny', cidr: '10.0.0.1/32' },
|
||||||
url: '/api/v1/rules',
|
{ set_id: setId, action: 'allow', cidr: '10.0.0.1/32' },
|
||||||
payload: {
|
{ set_id: setId, action: 'allow', cidr: '10.0.0.2/32' },
|
||||||
set_id: set.id,
|
]) {
|
||||||
action: 'allow',
|
const r = await app.inject({
|
||||||
cidr: '10.0.0.1/32',
|
method: 'POST',
|
||||||
},
|
url: '/api/v1/rules',
|
||||||
})
|
payload,
|
||||||
expect(r1.statusCode).toBe(200)
|
})
|
||||||
const rule1 = r1.json() as { id: string; enabled: boolean; priority: number }
|
expect(r.statusCode).toBe(200)
|
||||||
|
}
|
||||||
|
|
||||||
const r2 = await app.inject({
|
const agentId = await createAgent(app, 'pol-agent')
|
||||||
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 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({
|
const assign = await app.inject({
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
url: `/api/v1/agents/${agent.id}/policy-sets`,
|
url: `/api/v1/agents/${agentId}/policy-sets`,
|
||||||
payload: { set_ids: [set.id] },
|
payload: { set_ids: [setId] },
|
||||||
})
|
})
|
||||||
expect(assign.statusCode).toBe(200)
|
expect(assign.statusCode).toBe(200)
|
||||||
|
|
||||||
const policy = await app.inject({
|
const patch = await app.inject({
|
||||||
method: 'GET',
|
method: 'PATCH',
|
||||||
url: '/v1/agent/policy',
|
url: `/api/v1/agents/${agentId}`,
|
||||||
headers: {
|
payload: { default_action: 'drop' },
|
||||||
authorization: 'Bearer evofw_policy_mode_token_abcdef12',
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
expect(policy.statusCode).toBe(200)
|
expect(patch.statusCode).toBe(200)
|
||||||
const body = policy.json() as {
|
expect((patch.json() as { default_action: string }).default_action).toBe(
|
||||||
policy_mode: string
|
'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[]
|
allow_cidrs: string[]
|
||||||
|
summary: { conflicts_dropped: number }
|
||||||
|
chain: unknown[]
|
||||||
}
|
}
|
||||||
expect(body.policy_mode).toBe('whitelist')
|
expect(body.default_action).toBe('drop')
|
||||||
expect(body.allow_cidrs).toContain('10.0.0.2/32')
|
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(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)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,115 @@
|
|||||||
|
import type { AgentPolicyPreview } from '@evofw/shared'
|
||||||
|
import {
|
||||||
|
Frame,
|
||||||
|
FrameDescription,
|
||||||
|
FrameHeader,
|
||||||
|
FramePanel,
|
||||||
|
FrameTitle,
|
||||||
|
} from '@/components/reui/frame'
|
||||||
|
import { Badge } from '@/components/reui/badge'
|
||||||
|
import { EmptyState } from '@/components/empty-state'
|
||||||
|
import {
|
||||||
|
Tabs,
|
||||||
|
TabsContent,
|
||||||
|
TabsList,
|
||||||
|
TabsTrigger,
|
||||||
|
} from '@evofw/ui/components/tabs'
|
||||||
|
import { Skeleton } from '@evofw/ui/components/skeleton'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Effective CIDR bags — tabs Блок / Accept.
|
||||||
|
* Preview: https://reui.io/preview/base/components/c-tabs-2
|
||||||
|
* · https://reui.io/preview/base/data-grid-filtering-2
|
||||||
|
*/
|
||||||
|
|
||||||
|
type AgentEffectiveCidrsProps = {
|
||||||
|
preview?: AgentPolicyPreview
|
||||||
|
isLoading?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
function CidrList({
|
||||||
|
items,
|
||||||
|
total,
|
||||||
|
emptyTitle,
|
||||||
|
}: {
|
||||||
|
items: string[]
|
||||||
|
total: number
|
||||||
|
emptyTitle: string
|
||||||
|
}) {
|
||||||
|
if (items.length === 0) {
|
||||||
|
return (
|
||||||
|
<EmptyState
|
||||||
|
title={emptyTitle}
|
||||||
|
description={total === 0 ? undefined : `Всего ${total} (обрезано)`}
|
||||||
|
centered={false}
|
||||||
|
className="py-8"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<ul className="flex max-h-64 flex-col gap-1 overflow-y-auto font-mono text-xs">
|
||||||
|
{items.map((c) => (
|
||||||
|
<li key={c} className="bg-muted/40 rounded px-2 py-1">
|
||||||
|
{c}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
{total > items.length ? (
|
||||||
|
<li className="text-muted-foreground px-2 py-1">
|
||||||
|
… и ещё {total - items.length}
|
||||||
|
</li>
|
||||||
|
) : null}
|
||||||
|
</ul>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AgentEffectiveCidrs({
|
||||||
|
preview,
|
||||||
|
isLoading,
|
||||||
|
}: AgentEffectiveCidrsProps) {
|
||||||
|
if (isLoading) {
|
||||||
|
return <Skeleton className="h-48 w-full rounded-xl" />
|
||||||
|
}
|
||||||
|
|
||||||
|
const deny = preview?.deny_cidrs ?? []
|
||||||
|
const allow = preview?.allow_cidrs ?? []
|
||||||
|
const denyTotal = preview?.deny_cidrs_total ?? deny.length
|
||||||
|
const allowTotal = preview?.allow_cidrs_total ?? allow.length
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Frame dense spacing="sm">
|
||||||
|
<FrameHeader>
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<FrameTitle>Effective CIDR</FrameTitle>
|
||||||
|
<Badge variant="secondary" size="sm">
|
||||||
|
apply v{preview?.apply_version ?? 2}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<FrameDescription>
|
||||||
|
После deny-wins · hash {preview?.hash?.slice(0, 18) ?? '—'}…
|
||||||
|
</FrameDescription>
|
||||||
|
</FrameHeader>
|
||||||
|
<FramePanel>
|
||||||
|
<Tabs defaultValue="deny">
|
||||||
|
<TabsList>
|
||||||
|
<TabsTrigger value="deny">Блок ({denyTotal})</TabsTrigger>
|
||||||
|
<TabsTrigger value="allow">Accept ({allowTotal})</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
<TabsContent value="deny" className="mt-3">
|
||||||
|
<CidrList
|
||||||
|
items={deny}
|
||||||
|
total={denyTotal}
|
||||||
|
emptyTitle="Нет deny CIDR"
|
||||||
|
/>
|
||||||
|
</TabsContent>
|
||||||
|
<TabsContent value="allow" className="mt-3">
|
||||||
|
<CidrList
|
||||||
|
items={allow}
|
||||||
|
total={allowTotal}
|
||||||
|
emptyTitle="Нет allow CIDR"
|
||||||
|
/>
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
</FramePanel>
|
||||||
|
</Frame>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
import type { Agent, DefaultAction } from '@evofw/shared'
|
||||||
|
import {
|
||||||
|
Frame,
|
||||||
|
FrameDescription,
|
||||||
|
FrameHeader,
|
||||||
|
FramePanel,
|
||||||
|
FrameTitle,
|
||||||
|
} from '@/components/reui/frame'
|
||||||
|
import { apiFetch } from '@/lib/api'
|
||||||
|
import { Field, FieldLabel } from '@evofw/ui/components/field'
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@evofw/ui/components/select'
|
||||||
|
import { Separator } from '@evofw/ui/components/separator'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Agent facts panel — SA3 RunFacts DNA (editable default_action).
|
||||||
|
* Preview: https://reui.io/preview/base/solution-agents-3
|
||||||
|
* · https://reui.io/preview/base/settings-3
|
||||||
|
*/
|
||||||
|
|
||||||
|
type AgentFactsPanelProps = {
|
||||||
|
agent: Agent
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatWhen(iso?: string | null): string {
|
||||||
|
if (!iso) return '—'
|
||||||
|
const d = new Date(iso)
|
||||||
|
if (Number.isNaN(d.getTime())) return iso
|
||||||
|
return d.toLocaleString('ru-RU')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AgentFactsPanel({ agent }: AgentFactsPanelProps) {
|
||||||
|
const qc = useQueryClient()
|
||||||
|
const defaultAction: DefaultAction =
|
||||||
|
agent.default_action === 'drop' ? 'drop' : 'accept'
|
||||||
|
|
||||||
|
const patch = useMutation({
|
||||||
|
mutationFn: (default_action: DefaultAction) =>
|
||||||
|
apiFetch<Agent>(`/api/v1/agents/${agent.id}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: JSON.stringify({ default_action }),
|
||||||
|
}),
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success('Default action обновлён')
|
||||||
|
void qc.invalidateQueries({ queryKey: ['agents'] })
|
||||||
|
void qc.invalidateQueries({ queryKey: ['agents', agent.id, 'preview'] })
|
||||||
|
},
|
||||||
|
onError: (e: Error) => toast.error(e.message),
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Frame dense spacing="sm">
|
||||||
|
<FrameHeader>
|
||||||
|
<FrameTitle>Параметры</FrameTitle>
|
||||||
|
<FrameDescription>Default + identity</FrameDescription>
|
||||||
|
</FrameHeader>
|
||||||
|
<FramePanel className="flex flex-col gap-4">
|
||||||
|
<Field>
|
||||||
|
<FieldLabel>Если не совпало</FieldLabel>
|
||||||
|
<Select
|
||||||
|
value={defaultAction}
|
||||||
|
onValueChange={(v) => {
|
||||||
|
if (v === 'accept' || v === 'drop') patch.mutate(v)
|
||||||
|
}}
|
||||||
|
disabled={patch.isPending}
|
||||||
|
items={[
|
||||||
|
{ value: 'accept', label: 'Accept' },
|
||||||
|
{ value: 'drop', label: 'Drop' },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-full">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="accept">Accept</SelectItem>
|
||||||
|
<SelectItem value="drop">Drop</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<p className="text-muted-foreground text-xs">
|
||||||
|
Пакет вне deny/allow → {defaultAction === 'drop' ? 'DROP' : 'ACCEPT'}
|
||||||
|
</p>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
<dl className="grid gap-2 text-sm">
|
||||||
|
<div className="flex justify-between gap-3">
|
||||||
|
<dt className="text-muted-foreground">Hostname</dt>
|
||||||
|
<dd className="truncate font-medium">{agent.hostname ?? '—'}</dd>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between gap-3">
|
||||||
|
<dt className="text-muted-foreground">Token</dt>
|
||||||
|
<dd className="font-mono text-xs">{agent.token_prefix}</dd>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between gap-3">
|
||||||
|
<dt className="text-muted-foreground">Client</dt>
|
||||||
|
<dd>{agent.client_version ?? '—'}</dd>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between gap-3">
|
||||||
|
<dt className="text-muted-foreground">Last seen IP</dt>
|
||||||
|
<dd>{agent.last_seen_ip ?? '—'}</dd>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between gap-3">
|
||||||
|
<dt className="text-muted-foreground">Created</dt>
|
||||||
|
<dd className="text-right text-xs">{formatWhen(agent.created_at)}</dd>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between gap-3">
|
||||||
|
<dt className="text-muted-foreground">Approved</dt>
|
||||||
|
<dd className="text-right text-xs">
|
||||||
|
{formatWhen(agent.approved_at)}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between gap-3">
|
||||||
|
<dt className="text-muted-foreground">Generation</dt>
|
||||||
|
<dd className="tabular-nums">{agent.policy_generation}</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
</FramePanel>
|
||||||
|
</Frame>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -8,7 +8,6 @@ import {
|
|||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
import type { PolicySet } from '@evofw/shared'
|
|
||||||
import {
|
import {
|
||||||
Sortable,
|
Sortable,
|
||||||
SortableItem,
|
SortableItem,
|
||||||
@@ -42,7 +41,6 @@ import {
|
|||||||
/**
|
/**
|
||||||
* Agent-assigned policy sets — ReUI Sortable (c-sortable-5 DNA).
|
* Agent-assigned policy sets — ReUI Sortable (c-sortable-5 DNA).
|
||||||
* Preview: https://reui.io/preview/base/components/c-sortable-5
|
* Preview: https://reui.io/preview/base/components/c-sortable-5
|
||||||
* · https://reui.io/preview/base/settings-8
|
|
||||||
* Docs: https://reui.io/docs/components/base/sortable
|
* Docs: https://reui.io/docs/components/base/sortable
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@@ -52,7 +50,6 @@ export type AgentPolicySetRow = {
|
|||||||
name: string
|
name: string
|
||||||
description?: string | null
|
description?: string | null
|
||||||
enabled: boolean
|
enabled: boolean
|
||||||
policy_mode: 'blacklist' | 'whitelist'
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type AgentPolicySetsSortableProps = {
|
type AgentPolicySetsSortableProps = {
|
||||||
@@ -72,24 +69,10 @@ export function AgentPolicySetsSortable({
|
|||||||
setItems(assignedQ.data?.items ?? [])
|
setItems(assignedQ.data?.items ?? [])
|
||||||
}, [assignedQ.data])
|
}, [assignedQ.data])
|
||||||
|
|
||||||
const assignedMode = items[0]?.policy_mode
|
|
||||||
|
|
||||||
const availableSets = useMemo(() => {
|
const availableSets = useMemo(() => {
|
||||||
const assigned = new Set(items.map((i) => i.set_id))
|
const assigned = new Set(items.map((i) => i.set_id))
|
||||||
return (catalogQ.data?.items ?? []).filter((s) => {
|
return (catalogQ.data?.items ?? []).filter((s) => !assigned.has(s.id))
|
||||||
if (assigned.has(s.id)) return false
|
}, [catalogQ.data?.items, items])
|
||||||
if (assignedMode && s.policy_mode !== assignedMode) return false
|
|
||||||
return true
|
|
||||||
})
|
|
||||||
}, [catalogQ.data?.items, items, assignedMode])
|
|
||||||
|
|
||||||
const conflictSets = useMemo(() => {
|
|
||||||
if (!assignedMode) return [] as PolicySet[]
|
|
||||||
const assigned = new Set(items.map((i) => i.set_id))
|
|
||||||
return (catalogQ.data?.items ?? []).filter(
|
|
||||||
(s) => !assigned.has(s.id) && s.policy_mode !== assignedMode,
|
|
||||||
)
|
|
||||||
}, [catalogQ.data?.items, items, assignedMode])
|
|
||||||
|
|
||||||
const persist = useMutation({
|
const persist = useMutation({
|
||||||
mutationFn: (set_ids: string[]) =>
|
mutationFn: (set_ids: string[]) =>
|
||||||
@@ -103,6 +86,7 @@ export function AgentPolicySetsSortable({
|
|||||||
onSuccess: (res) => {
|
onSuccess: (res) => {
|
||||||
setItems(res.items)
|
setItems(res.items)
|
||||||
void qc.invalidateQueries({ queryKey: ['agents', agentId] })
|
void qc.invalidateQueries({ queryKey: ['agents', agentId] })
|
||||||
|
void qc.invalidateQueries({ queryKey: ['agents', agentId, 'preview'] })
|
||||||
void qc.invalidateQueries({ queryKey: ['policy-sets'] })
|
void qc.invalidateQueries({ queryKey: ['policy-sets'] })
|
||||||
},
|
},
|
||||||
onError: (e: Error) => {
|
onError: (e: Error) => {
|
||||||
@@ -127,12 +111,6 @@ export function AgentPolicySetsSortable({
|
|||||||
if (!addId) return
|
if (!addId) return
|
||||||
const set = (catalogQ.data?.items ?? []).find((s) => s.id === addId)
|
const set = (catalogQ.data?.items ?? []).find((s) => s.id === addId)
|
||||||
if (!set) return
|
if (!set) return
|
||||||
if (assignedMode && set.policy_mode !== assignedMode) {
|
|
||||||
toast.error(
|
|
||||||
`Режим набора (${set.policy_mode}) не совпадает с текущим (${assignedMode})`,
|
|
||||||
)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const next: AgentPolicySetRow[] = [
|
const next: AgentPolicySetRow[] = [
|
||||||
...items,
|
...items,
|
||||||
{
|
{
|
||||||
@@ -141,7 +119,6 @@ export function AgentPolicySetsSortable({
|
|||||||
name: set.name,
|
name: set.name,
|
||||||
description: set.description,
|
description: set.description,
|
||||||
enabled: set.enabled,
|
enabled: set.enabled,
|
||||||
policy_mode: set.policy_mode,
|
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
const prev = items
|
const prev = items
|
||||||
@@ -168,7 +145,7 @@ export function AgentPolicySetsSortable({
|
|||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
<FrameDescription>
|
<FrameDescription>
|
||||||
Перетащите для приоритета · один режим на агента
|
Порядок = приоритет merge · deny → allow → default
|
||||||
</FrameDescription>
|
</FrameDescription>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex shrink-0 flex-wrap items-center gap-2">
|
<div className="flex shrink-0 flex-wrap items-center gap-2">
|
||||||
@@ -191,9 +168,7 @@ export function AgentPolicySetsSortable({
|
|||||||
))}
|
))}
|
||||||
{availableSets.length === 0 ? (
|
{availableSets.length === 0 ? (
|
||||||
<div className="text-muted-foreground px-2 py-1.5 text-xs">
|
<div className="text-muted-foreground px-2 py-1.5 text-xs">
|
||||||
{conflictSets.length > 0
|
Все наборы уже назначены
|
||||||
? 'Нет совместимых наборов'
|
|
||||||
: 'Все наборы уже назначены'}
|
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
@@ -242,23 +217,13 @@ export function AgentPolicySetsSortable({
|
|||||||
<GripVerticalIcon className="size-4" />
|
<GripVerticalIcon className="size-4" />
|
||||||
</SortableItemHandle>
|
</SortableItemHandle>
|
||||||
|
|
||||||
<PolicySetIcon mode={row.policy_mode} className="size-9" />
|
<PolicySetIcon className="size-9" />
|
||||||
|
|
||||||
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
|
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<span className="truncate text-sm font-medium">
|
<span className="truncate text-sm font-medium">
|
||||||
{row.name}
|
{row.name}
|
||||||
</span>
|
</span>
|
||||||
<Badge
|
|
||||||
variant={
|
|
||||||
row.policy_mode === 'whitelist'
|
|
||||||
? 'warning-light'
|
|
||||||
: 'secondary'
|
|
||||||
}
|
|
||||||
size="xs"
|
|
||||||
>
|
|
||||||
{row.policy_mode}
|
|
||||||
</Badge>
|
|
||||||
<StatusBadge
|
<StatusBadge
|
||||||
status={row.enabled ? 'enabled' : 'disabled'}
|
status={row.enabled ? 'enabled' : 'disabled'}
|
||||||
/>
|
/>
|
||||||
@@ -300,13 +265,6 @@ export function AgentPolicySetsSortable({
|
|||||||
</FramePanel>
|
</FramePanel>
|
||||||
)}
|
)}
|
||||||
</Frame>
|
</Frame>
|
||||||
|
|
||||||
{conflictSets.length > 0 && items.length > 0 ? (
|
|
||||||
<p className="text-muted-foreground text-xs">
|
|
||||||
{conflictSets.length} набор(ов) скрыты из‑за другого режима (
|
|
||||||
{assignedMode}).
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,126 @@
|
|||||||
|
import { BanIcon, ShieldCheckIcon } from 'lucide-react'
|
||||||
|
import type { AgentPolicyPreview } from '@evofw/shared'
|
||||||
|
import {
|
||||||
|
Timeline,
|
||||||
|
TimelineContent,
|
||||||
|
TimelineHeader,
|
||||||
|
TimelineIndicator,
|
||||||
|
TimelineItem,
|
||||||
|
TimelineSeparator,
|
||||||
|
TimelineTitle,
|
||||||
|
} from '@/components/reui/timeline'
|
||||||
|
import {
|
||||||
|
Frame,
|
||||||
|
FrameDescription,
|
||||||
|
FrameHeader,
|
||||||
|
FramePanel,
|
||||||
|
FrameTitle,
|
||||||
|
} from '@/components/reui/frame'
|
||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
AlertDescription,
|
||||||
|
AlertTitle,
|
||||||
|
} from '@/components/reui/alert'
|
||||||
|
import { Badge } from '@/components/reui/badge'
|
||||||
|
import { EmptyState } from '@/components/empty-state'
|
||||||
|
import { Skeleton } from '@evofw/ui/components/skeleton'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Policy chain trace — SA3 Timeline DNA.
|
||||||
|
* Preview: https://reui.io/preview/base/solution-agents-3
|
||||||
|
* · https://reui.io/preview/base/components/c-timeline-6
|
||||||
|
* Docs: https://reui.io/docs/components/base/timeline
|
||||||
|
*/
|
||||||
|
|
||||||
|
type AgentPolicyTraceProps = {
|
||||||
|
preview?: AgentPolicyPreview
|
||||||
|
isLoading?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AgentPolicyTrace({
|
||||||
|
preview,
|
||||||
|
isLoading,
|
||||||
|
}: AgentPolicyTraceProps) {
|
||||||
|
if (isLoading) {
|
||||||
|
return <Skeleton className="h-64 w-full rounded-xl" />
|
||||||
|
}
|
||||||
|
|
||||||
|
const chain = preview?.chain ?? []
|
||||||
|
const conflicts = preview?.summary.conflicts_dropped ?? 0
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Frame dense spacing="sm">
|
||||||
|
<FrameHeader>
|
||||||
|
<FrameTitle>Цепочка политики</FrameTitle>
|
||||||
|
<FrameDescription>
|
||||||
|
deny → allow → default (
|
||||||
|
{preview?.default_action === 'drop' ? 'Drop' : 'Accept'})
|
||||||
|
</FrameDescription>
|
||||||
|
</FrameHeader>
|
||||||
|
<FramePanel className="flex flex-col gap-3">
|
||||||
|
{conflicts > 0 ? (
|
||||||
|
<Alert variant="warning">
|
||||||
|
<BanIcon />
|
||||||
|
<AlertTitle>Конфликты</AlertTitle>
|
||||||
|
<AlertDescription>
|
||||||
|
{conflicts} CIDR исключены из allow (deny wins, exact match)
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{chain.length === 0 ? (
|
||||||
|
<EmptyState
|
||||||
|
title="Нет правил"
|
||||||
|
description="Назначьте наборы или добавьте overrides."
|
||||||
|
centered={false}
|
||||||
|
className="py-8"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Timeline defaultValue={chain.length} className="px-1">
|
||||||
|
{chain.map((step, i) => {
|
||||||
|
const isDeny = step.action === 'deny'
|
||||||
|
return (
|
||||||
|
<TimelineItem key={`${step.rule_id ?? 'ov'}-${i}`} step={i + 1}>
|
||||||
|
<TimelineHeader>
|
||||||
|
<TimelineSeparator />
|
||||||
|
<TimelineIndicator />
|
||||||
|
<TimelineTitle className="flex flex-wrap items-center gap-2 text-sm">
|
||||||
|
{isDeny ? (
|
||||||
|
<BanIcon className="text-destructive size-3.5" />
|
||||||
|
) : (
|
||||||
|
<ShieldCheckIcon className="text-success size-3.5" />
|
||||||
|
)}
|
||||||
|
<Badge
|
||||||
|
variant={isDeny ? 'destructive-light' : 'success-light'}
|
||||||
|
size="xs"
|
||||||
|
>
|
||||||
|
{isDeny ? 'Блок' : 'Accept'}
|
||||||
|
</Badge>
|
||||||
|
<span className="font-medium">{step.source_label}</span>
|
||||||
|
</TimelineTitle>
|
||||||
|
</TimelineHeader>
|
||||||
|
<TimelineContent className="text-muted-foreground text-xs">
|
||||||
|
{[
|
||||||
|
step.set_name,
|
||||||
|
step.source_kind,
|
||||||
|
`${step.cidr_count} CIDR`,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' · ')}
|
||||||
|
</TimelineContent>
|
||||||
|
</TimelineItem>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</Timeline>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{preview ? (
|
||||||
|
<p className="text-muted-foreground text-xs">
|
||||||
|
Блок: {preview.deny_cidrs_total} · Accept:{' '}
|
||||||
|
{preview.allow_cidrs_total} · gen {preview.generation}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</FramePanel>
|
||||||
|
</Frame>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,346 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { Autocomplete as AutocompletePrimitive } from "@base-ui/react/autocomplete"
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
|
||||||
|
import { cn } from "@evofw/ui/lib/utils"
|
||||||
|
import { ScrollArea } from "@evofw/ui/components/scroll-area"
|
||||||
|
import { XIcon, ChevronsUpDownIcon } from "lucide-react"
|
||||||
|
|
||||||
|
const inputVariants = cva(
|
||||||
|
"outline-none flex w-full text-foreground placeholder:text-muted-foreground disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 [[readonly]]:bg-muted/80 [[readonly]]:cursor-not-allowed border border-input focus-visible:border-ring aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 rounded-lg bg-transparent dark:bg-input/30 text-sm transition-colors focus-visible:ring-ring/50 focus-visible:ring-3 aria-invalid:ring-3",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
size: {
|
||||||
|
sm: "h-7 px-2 [&~[data-slot=autocomplete-clear]]:end-1.5 [&~[data-slot=autocomplete-trigger]]:end-1.5",
|
||||||
|
default:
|
||||||
|
"h-8 px-2.5 [&~[data-slot=autocomplete-clear]]:end-1.75 [&~[data-slot=autocomplete-trigger]]:end-1.75",
|
||||||
|
lg: "h-9 px-2.5 [&~[data-slot=autocomplete-clear]]:end-2 [&~[data-slot=autocomplete-trigger]]:end-2",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
size: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
const Autocomplete = AutocompletePrimitive.Root
|
||||||
|
|
||||||
|
function AutocompleteValue({ ...props }: AutocompletePrimitive.Value.Props) {
|
||||||
|
return (
|
||||||
|
<AutocompletePrimitive.Value data-slot="autocomplete-value" {...props} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AutocompleteInput({
|
||||||
|
className,
|
||||||
|
size = "default",
|
||||||
|
showClear = false,
|
||||||
|
showTrigger = false,
|
||||||
|
...props
|
||||||
|
}: Omit<AutocompletePrimitive.Input.Props, "size"> &
|
||||||
|
VariantProps<typeof inputVariants> & {
|
||||||
|
showClear?: boolean
|
||||||
|
showTrigger?: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="relative w-full">
|
||||||
|
<AutocompletePrimitive.Input
|
||||||
|
data-slot="autocomplete-input"
|
||||||
|
data-size={size}
|
||||||
|
className={cn(inputVariants({ size }), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
{showTrigger && <AutocompleteTrigger />}
|
||||||
|
{showClear && <AutocompleteClear />}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AutocompleteStatus({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: AutocompletePrimitive.Status.Props) {
|
||||||
|
return (
|
||||||
|
<AutocompletePrimitive.Status
|
||||||
|
data-slot="autocomplete-status"
|
||||||
|
className={cn(
|
||||||
|
"text-muted-foreground px-2 py-1.5 text-sm empty:m-0 empty:p-0",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AutocompletePortal({ ...props }: AutocompletePrimitive.Portal.Props) {
|
||||||
|
return (
|
||||||
|
<AutocompletePrimitive.Portal data-slot="autocomplete-portal" {...props} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AutocompleteBackdrop({
|
||||||
|
...props
|
||||||
|
}: AutocompletePrimitive.Backdrop.Props) {
|
||||||
|
return (
|
||||||
|
<AutocompletePrimitive.Backdrop
|
||||||
|
data-slot="autocomplete-backdrop"
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AutocompletePositioner({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: AutocompletePrimitive.Positioner.Props) {
|
||||||
|
return (
|
||||||
|
<AutocompletePrimitive.Positioner
|
||||||
|
data-slot="autocomplete-positioner"
|
||||||
|
className={cn("z-50 outline-none", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AutocompleteList({
|
||||||
|
className,
|
||||||
|
scrollAreaClassName,
|
||||||
|
...props
|
||||||
|
}: AutocompletePrimitive.List.Props & {
|
||||||
|
scrollAreaClassName?: string
|
||||||
|
scrollFade?: boolean
|
||||||
|
scrollbarGutter?: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<ScrollArea
|
||||||
|
className={cn(
|
||||||
|
"size-full min-h-0 **:data-[slot=scroll-area-viewport]:h-full **:data-[slot=scroll-area-viewport]:overscroll-contain",
|
||||||
|
scrollAreaClassName
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<AutocompletePrimitive.List
|
||||||
|
data-slot="autocomplete-list"
|
||||||
|
className={cn(
|
||||||
|
"not-empty:px-1 not-empty:py-1 not-empty:scroll-py-1 in-data-has-overflow-y:me-3",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</ScrollArea>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AutocompleteCollection({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AutocompletePrimitive.Collection>) {
|
||||||
|
return (
|
||||||
|
<AutocompletePrimitive.Collection
|
||||||
|
data-slot="autocomplete-collection"
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AutocompleteRow({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AutocompletePrimitive.Row>) {
|
||||||
|
return (
|
||||||
|
<AutocompletePrimitive.Row
|
||||||
|
data-slot="autocomplete-row"
|
||||||
|
className={cn("flex items-center gap-2", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AutocompleteItem({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AutocompletePrimitive.Item>) {
|
||||||
|
return (
|
||||||
|
<AutocompletePrimitive.Item
|
||||||
|
data-slot="autocomplete-item"
|
||||||
|
className={cn(
|
||||||
|
"text-foreground data-highlighted:text-foreground data-highlighted:before:bg-accent gap-1.5",
|
||||||
|
"rounded-md",
|
||||||
|
"data-highlighted:before:rounded-md",
|
||||||
|
"px-1.5 py-1 text-sm ([class*='size-'])]:size-4 ([class*='size-'])]:size-4 [&_svg:not([class*='size-'])]:size-4 ([class*='size-'])]:size-4 ([class*='size-'])]:size-3.5 ([class*='size-'])]:size-4 ([class*='size-'])]:size-3.5 relative flex cursor-default items-center outline-hidden transition-colors select-none data-disabled:pointer-events-none data-disabled:opacity-50 data-highlighted:relative data-highlighted:z-0 data-highlighted:before:absolute data-highlighted:before:inset-x-0 data-highlighted:before:inset-y-0 data-highlighted:before:z-[-1] [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([role=img]):not([class*=text-])]:opacity-60",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AutocompleteContentProps extends React.ComponentProps<
|
||||||
|
typeof AutocompletePrimitive.Popup
|
||||||
|
> {
|
||||||
|
align?: AutocompletePrimitive.Positioner.Props["align"]
|
||||||
|
sideOffset?: AutocompletePrimitive.Positioner.Props["sideOffset"]
|
||||||
|
alignOffset?: AutocompletePrimitive.Positioner.Props["alignOffset"]
|
||||||
|
side?: AutocompletePrimitive.Positioner.Props["side"]
|
||||||
|
anchor?: AutocompletePrimitive.Positioner.Props["anchor"]
|
||||||
|
showBackdrop?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
function AutocompleteContent({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
showBackdrop = false,
|
||||||
|
align = "start",
|
||||||
|
sideOffset = 4,
|
||||||
|
alignOffset = 0,
|
||||||
|
side = "bottom",
|
||||||
|
anchor,
|
||||||
|
...props
|
||||||
|
}: AutocompleteContentProps) {
|
||||||
|
return (
|
||||||
|
<AutocompletePortal>
|
||||||
|
{showBackdrop && <AutocompleteBackdrop />}
|
||||||
|
<AutocompletePositioner
|
||||||
|
align={align}
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
alignOffset={alignOffset}
|
||||||
|
side={side}
|
||||||
|
anchor={anchor}
|
||||||
|
>
|
||||||
|
<div className="relative flex max-h-full">
|
||||||
|
<AutocompletePrimitive.Popup
|
||||||
|
data-slot="autocomplete-popup"
|
||||||
|
className={cn(
|
||||||
|
"bg-popover text-popover-foreground rounded-lg shadow-md ring-foreground/10 flex max-h-[min(var(--available-height),24rem)] w-(--anchor-width) max-w-(--available-width) origin-(--transform-origin) scroll-pt-2 scroll-pb-2 flex-col overscroll-contain py-0.5 ring-1 transition-[scale,opacity] has-data-starting-style:scale-98 has-data-starting-style:opacity-0 has-data-[side=none]:scale-100 has-data-[side=none]:transition-none",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</AutocompletePrimitive.Popup>
|
||||||
|
</div>
|
||||||
|
</AutocompletePositioner>
|
||||||
|
</AutocompletePortal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AutocompleteGroup({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AutocompletePrimitive.Group>) {
|
||||||
|
return (
|
||||||
|
<AutocompletePrimitive.Group data-slot="autocomplete-group" {...props} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AutocompleteGroupLabel({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AutocompletePrimitive.GroupLabel>) {
|
||||||
|
return (
|
||||||
|
<AutocompletePrimitive.GroupLabel
|
||||||
|
data-slot="autocomplete-group-label"
|
||||||
|
className={cn(
|
||||||
|
"text-muted-foreground px-1.5 py-1 text-xs font-medium",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AutocompleteEmpty({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AutocompletePrimitive.Empty>) {
|
||||||
|
return (
|
||||||
|
<AutocompletePrimitive.Empty
|
||||||
|
data-slot="autocomplete-empty"
|
||||||
|
className={cn(
|
||||||
|
"text-muted-foreground px-2 py-1.5 text-sm text-center empty:m-0 empty:p-0",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AutocompleteClear({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AutocompletePrimitive.Clear>) {
|
||||||
|
return (
|
||||||
|
<AutocompletePrimitive.Clear
|
||||||
|
data-slot="autocomplete-clear"
|
||||||
|
className={cn(
|
||||||
|
"ring-offset-background focus:ring-ring absolute top-1/2 -translate-y-1/2 cursor-pointer opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-none disabled:pointer-events-none data-disabled:pointer-events-none",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<XIcon className="size-4" />
|
||||||
|
</AutocompletePrimitive.Clear>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AutocompleteTrigger({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AutocompletePrimitive.Trigger>) {
|
||||||
|
return (
|
||||||
|
<AutocompletePrimitive.Trigger
|
||||||
|
data-slot="autocomplete-trigger"
|
||||||
|
className={cn(
|
||||||
|
"focus:ring-ring ring-offset-background absolute top-1/2 -translate-y-1/2 cursor-pointer focus:ring-2 focus:ring-offset-2 focus:outline-none disabled:pointer-events-none has-[+[data-slot=autocomplete-clear]]:hidden data-disabled:pointer-events-none",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ChevronsUpDownIcon className="size-4 opacity-70" />
|
||||||
|
</AutocompletePrimitive.Trigger>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AutocompleteArrow({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AutocompletePrimitive.Arrow>) {
|
||||||
|
return (
|
||||||
|
<AutocompletePrimitive.Arrow data-slot="autocomplete-arrow" {...props} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AutocompleteSeparator({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AutocompletePrimitive.Separator>) {
|
||||||
|
return (
|
||||||
|
<AutocompletePrimitive.Separator
|
||||||
|
data-slot="autocomplete-separator"
|
||||||
|
className={cn(
|
||||||
|
"bg-border my-1.5 h-px",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Autocomplete,
|
||||||
|
AutocompleteValue,
|
||||||
|
AutocompleteTrigger,
|
||||||
|
AutocompleteInput,
|
||||||
|
AutocompleteStatus,
|
||||||
|
AutocompletePortal,
|
||||||
|
AutocompleteBackdrop,
|
||||||
|
AutocompletePositioner,
|
||||||
|
AutocompleteContent,
|
||||||
|
AutocompleteList,
|
||||||
|
AutocompleteCollection,
|
||||||
|
AutocompleteRow,
|
||||||
|
AutocompleteItem,
|
||||||
|
AutocompleteGroup,
|
||||||
|
AutocompleteGroupLabel,
|
||||||
|
AutocompleteEmpty,
|
||||||
|
AutocompleteClear,
|
||||||
|
AutocompleteArrow,
|
||||||
|
AutocompleteSeparator,
|
||||||
|
}
|
||||||
@@ -1,69 +0,0 @@
|
|||||||
import { cn } from '@evofw/ui/lib/utils'
|
|
||||||
import {
|
|
||||||
ToggleGroup,
|
|
||||||
ToggleGroupItem,
|
|
||||||
} from '@evofw/ui/components/toggle-group'
|
|
||||||
import {
|
|
||||||
Frame,
|
|
||||||
FrameDescription,
|
|
||||||
FrameHeader,
|
|
||||||
FramePanel,
|
|
||||||
FrameTitle,
|
|
||||||
} from '@/components/reui/frame'
|
|
||||||
|
|
||||||
type PolicyMode = 'blacklist' | 'whitelist'
|
|
||||||
|
|
||||||
type PolicyModeToggleProps = {
|
|
||||||
value: PolicyMode
|
|
||||||
onChange: (mode: PolicyMode) => void
|
|
||||||
disabled?: boolean
|
|
||||||
className?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Filter mode — settings-3 ToggleGroup pattern.
|
|
||||||
* Preview: https://reui.io/preview/base/settings-3
|
|
||||||
* Docs: https://ui.shadcn.com/docs/components/base/toggle-group
|
|
||||||
*/
|
|
||||||
export function PolicyModeToggle({
|
|
||||||
value,
|
|
||||||
onChange,
|
|
||||||
disabled,
|
|
||||||
className,
|
|
||||||
}: PolicyModeToggleProps) {
|
|
||||||
return (
|
|
||||||
<Frame dense spacing="sm" className={cn(className)}>
|
|
||||||
<FrameHeader>
|
|
||||||
<FrameTitle>Режим фильтра</FrameTitle>
|
|
||||||
<FrameDescription>
|
|
||||||
Чёрный список: блокировать deny. Белый список: пропускать только
|
|
||||||
allow, остальное (forward) — DROP.
|
|
||||||
</FrameDescription>
|
|
||||||
</FrameHeader>
|
|
||||||
<FramePanel>
|
|
||||||
<ToggleGroup
|
|
||||||
multiple={false}
|
|
||||||
value={[value]}
|
|
||||||
onValueChange={(next) => {
|
|
||||||
const mode = next[0]
|
|
||||||
if (mode === 'blacklist' || mode === 'whitelist') {
|
|
||||||
onChange(mode)
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
disabled={disabled}
|
|
||||||
aria-label="Режим фильтра"
|
|
||||||
className="flex flex-wrap justify-start gap-1"
|
|
||||||
>
|
|
||||||
<ToggleGroupItem value="blacklist" aria-label="Чёрный список">
|
|
||||||
Чёрный список
|
|
||||||
</ToggleGroupItem>
|
|
||||||
<ToggleGroupItem value="whitelist" aria-label="Белый список">
|
|
||||||
Белый список
|
|
||||||
</ToggleGroupItem>
|
|
||||||
</ToggleGroup>
|
|
||||||
</FramePanel>
|
|
||||||
</Frame>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -58,7 +58,6 @@ function ruleSubtitle(r: PolicyRule): string | null {
|
|||||||
type PolicyRulesSortableProps = {
|
type PolicyRulesSortableProps = {
|
||||||
setId: string
|
setId: string
|
||||||
rules: PolicyRule[]
|
rules: PolicyRule[]
|
||||||
policyMode: 'blacklist' | 'whitelist'
|
|
||||||
onDelete: (id: string) => void
|
onDelete: (id: string) => void
|
||||||
onAdd?: () => void
|
onAdd?: () => void
|
||||||
}
|
}
|
||||||
@@ -66,7 +65,6 @@ type PolicyRulesSortableProps = {
|
|||||||
export function PolicyRulesSortable({
|
export function PolicyRulesSortable({
|
||||||
setId,
|
setId,
|
||||||
rules: rulesProp,
|
rules: rulesProp,
|
||||||
policyMode,
|
|
||||||
onDelete,
|
onDelete,
|
||||||
onAdd,
|
onAdd,
|
||||||
}: PolicyRulesSortableProps) {
|
}: PolicyRulesSortableProps) {
|
||||||
@@ -105,22 +103,16 @@ export function PolicyRulesSortable({
|
|||||||
onError: (e: Error) => toast.error(e.message),
|
onError: (e: Error) => toast.error(e.message),
|
||||||
})
|
})
|
||||||
|
|
||||||
const isWl = policyMode === 'whitelist'
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
<Frame dense spacing="sm">
|
<Frame dense spacing="sm">
|
||||||
<FramePanel className="flex items-center gap-3 py-3">
|
<FramePanel className="flex items-center gap-3 py-3">
|
||||||
<Badge
|
<Badge variant="secondary" size="sm">
|
||||||
variant={isWl ? 'destructive-light' : 'success-light'}
|
deny → allow
|
||||||
size="sm"
|
|
||||||
>
|
|
||||||
{isWl ? 'DROP' : 'ACCEPT'}
|
|
||||||
</Badge>
|
</Badge>
|
||||||
<p className="text-muted-foreground text-sm">
|
<p className="text-muted-foreground text-sm">
|
||||||
{isWl
|
Правила с action deny блокируют, allow — пропускают; default задаётся
|
||||||
? 'По умолчанию DROP — ниже только allow-правила пропускают трафик'
|
на агенте
|
||||||
: 'По умолчанию ACCEPT — ниже deny-правила блокируют адреса'}
|
|
||||||
</p>
|
</p>
|
||||||
</FramePanel>
|
</FramePanel>
|
||||||
</Frame>
|
</Frame>
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import { cn } from '@evofw/ui/lib/utils'
|
|||||||
import { Item, ItemMedia } from '@evofw/ui/components/item'
|
import { Item, ItemMedia } from '@evofw/ui/components/item'
|
||||||
|
|
||||||
type PolicySetIconProps = {
|
type PolicySetIconProps = {
|
||||||
mode?: 'blacklist' | 'whitelist' | string | null
|
|
||||||
className?: string
|
className?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -11,16 +10,14 @@ type PolicySetIconProps = {
|
|||||||
* KPI-style tile for policy set rows.
|
* KPI-style tile for policy set rows.
|
||||||
* Preview DNA: https://reui.io/preview/base/stats-12
|
* Preview DNA: https://reui.io/preview/base/stats-12
|
||||||
*/
|
*/
|
||||||
export function PolicySetIcon({ mode, className }: PolicySetIconProps) {
|
export function PolicySetIcon({ className }: PolicySetIconProps) {
|
||||||
const isWhitelist = mode === 'whitelist'
|
|
||||||
return (
|
return (
|
||||||
<Item
|
<Item
|
||||||
className={cn(
|
className={cn(
|
||||||
'border-background bg-muted flex size-10.5 shrink-0 items-center justify-center border-2 p-0 shadow-[0_1px_3px_0_rgba(0,0,0,0.14)] dark:border [&_svg]:size-4',
|
'border-background bg-muted text-muted-foreground flex size-10.5 shrink-0 items-center justify-center border-2 p-0 shadow-[0_1px_3px_0_rgba(0,0,0,0.14)] dark:border [&_svg]:size-4',
|
||||||
isWhitelist ? 'text-warning' : 'text-muted-foreground',
|
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
aria-label={isWhitelist ? 'Whitelist' : 'Blacklist'}
|
aria-label="Набор правил"
|
||||||
>
|
>
|
||||||
<ItemMedia variant="icon" className="size-auto">
|
<ItemMedia variant="icon" className="size-auto">
|
||||||
<Shield aria-hidden />
|
<Shield aria-hidden />
|
||||||
|
|||||||
@@ -93,11 +93,30 @@ export const agentPolicySetsQueryOptions = (agentId: string) =>
|
|||||||
name: string
|
name: string
|
||||||
description?: string | null
|
description?: string | null
|
||||||
enabled: boolean
|
enabled: boolean
|
||||||
policy_mode: 'blacklist' | 'whitelist'
|
|
||||||
}[]
|
}[]
|
||||||
}>(`/api/v1/agents/${agentId}/policy-sets`),
|
}>(`/api/v1/agents/${agentId}/policy-sets`),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
export const agentPreviewQueryOptions = (agentId: string) =>
|
||||||
|
queryOptions({
|
||||||
|
queryKey: ['agents', agentId, 'preview'],
|
||||||
|
queryFn: () =>
|
||||||
|
apiFetch<import('@evofw/shared').AgentPolicyPreview>(
|
||||||
|
`/api/v1/agents/${agentId}/preview?limit_cidrs=100`,
|
||||||
|
),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const evobgpCommunitiesQueryOptions = () =>
|
||||||
|
queryOptions({
|
||||||
|
queryKey: ['integrations', 'evobgp', 'communities'],
|
||||||
|
queryFn: () =>
|
||||||
|
apiFetch<{
|
||||||
|
items: import('@evofw/shared').EvobgpCommunity[]
|
||||||
|
}>('/api/v1/integrations/evobgp/communities'),
|
||||||
|
staleTime: 30_000,
|
||||||
|
retry: false,
|
||||||
|
})
|
||||||
|
|
||||||
export const agentOverridesQueryOptions = (agentId: string) =>
|
export const agentOverridesQueryOptions = (agentId: string) =>
|
||||||
queryOptions({
|
queryOptions({
|
||||||
queryKey: ['agents', agentId, 'overrides'],
|
queryKey: ['agents', agentId, 'overrides'],
|
||||||
|
|||||||
@@ -1,31 +1,20 @@
|
|||||||
import { createFileRoute, Link } from '@tanstack/react-router'
|
import { createFileRoute, Link } from '@tanstack/react-router'
|
||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import { useMemo, useRef, useState } from 'react'
|
import { useRef, useState } from 'react'
|
||||||
import {
|
import {
|
||||||
BanIcon,
|
BanIcon,
|
||||||
CheckCircle2Icon,
|
CheckCircle2Icon,
|
||||||
CircleAlertIcon,
|
CircleAlertIcon,
|
||||||
ClockIcon,
|
ClockIcon,
|
||||||
Copy,
|
Copy,
|
||||||
CpuIcon,
|
|
||||||
CopyPlusIcon,
|
CopyPlusIcon,
|
||||||
ShieldOffIcon,
|
CpuIcon,
|
||||||
|
MoreHorizontalIcon,
|
||||||
ShieldPlusIcon,
|
ShieldPlusIcon,
|
||||||
TerminalIcon,
|
TerminalIcon,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import {
|
import { DetailPanel, PageShell } from '@/components/reui-kit'
|
||||||
DetailPanel,
|
|
||||||
PageShell,
|
|
||||||
QuickActionGrid,
|
|
||||||
} from '@/components/reui-kit'
|
|
||||||
import {
|
|
||||||
Frame,
|
|
||||||
FrameDescription,
|
|
||||||
FrameHeader,
|
|
||||||
FramePanel,
|
|
||||||
FrameTitle,
|
|
||||||
} from '@/components/reui/frame'
|
|
||||||
import {
|
import {
|
||||||
Alert,
|
Alert,
|
||||||
AlertDescription,
|
AlertDescription,
|
||||||
@@ -36,21 +25,34 @@ import {
|
|||||||
AgentPlatformIcon,
|
AgentPlatformIcon,
|
||||||
platformLabel,
|
platformLabel,
|
||||||
} from '@/components/agents/agent-platform-icon'
|
} from '@/components/agents/agent-platform-icon'
|
||||||
import { AgentLifecycleTimeline } from '@/components/agents/agent-lifecycle-timeline'
|
|
||||||
import { AgentPolicySetsSortable } from '@/components/agents/agent-policy-sets-sortable'
|
import { AgentPolicySetsSortable } from '@/components/agents/agent-policy-sets-sortable'
|
||||||
|
import { AgentPolicyTrace } from '@/components/agents/agent-policy-trace'
|
||||||
|
import { AgentFactsPanel } from '@/components/agents/agent-facts-panel'
|
||||||
|
import { AgentEffectiveCidrs } from '@/components/agents/agent-effective-cidrs'
|
||||||
import {
|
import {
|
||||||
AgentCloneSetsSheet,
|
AgentCloneSetsSheet,
|
||||||
AgentOverrideSheet,
|
AgentOverrideSheet,
|
||||||
} from '@/components/agents/agent-settings-sheets'
|
} from '@/components/agents/agent-settings-sheets'
|
||||||
import { agentQueryOptions } from '@/queries'
|
import {
|
||||||
|
agentPreviewQueryOptions,
|
||||||
|
agentQueryOptions,
|
||||||
|
} from '@/queries'
|
||||||
import { apiFetch } from '@/lib/api'
|
import { apiFetch } from '@/lib/api'
|
||||||
import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard'
|
import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard'
|
||||||
import { Button } from '@evofw/ui/components/button'
|
import { Button } from '@evofw/ui/components/button'
|
||||||
import { Skeleton } from '@evofw/ui/components/skeleton'
|
import { Skeleton } from '@evofw/ui/components/skeleton'
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from '@evofw/ui/components/dropdown-menu'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Agent detail — Solutions Agents DNA.
|
* Agent detail — SA3 layout DNA (Header + Trace 2/3 + Facts 1/3).
|
||||||
* Preview: https://reui.io/preview/base/solution-agents-3 · stats-12 · sheet-8 · c-sortable-5
|
* Preview: https://reui.io/preview/base/solution-agents-3
|
||||||
|
* · https://reui.io/preview/base/stats-12
|
||||||
|
* Docs: https://reui.io/blocks/solutions/agents
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export const Route = createFileRoute('/_auth/agents/$id')({
|
export const Route = createFileRoute('/_auth/agents/$id')({
|
||||||
@@ -58,6 +60,7 @@ export const Route = createFileRoute('/_auth/agents/$id')({
|
|||||||
const agent = await queryClient.ensureQueryData(
|
const agent = await queryClient.ensureQueryData(
|
||||||
agentQueryOptions(params.id),
|
agentQueryOptions(params.id),
|
||||||
)
|
)
|
||||||
|
void queryClient.ensureQueryData(agentPreviewQueryOptions(params.id))
|
||||||
return { breadcrumb: agent.name }
|
return { breadcrumb: agent.name }
|
||||||
},
|
},
|
||||||
component: AgentDetailPage,
|
component: AgentDetailPage,
|
||||||
@@ -68,6 +71,7 @@ function AgentDetailPage() {
|
|||||||
const qc = useQueryClient()
|
const qc = useQueryClient()
|
||||||
const { copyToClipboard } = useCopyToClipboard()
|
const { copyToClipboard } = useCopyToClipboard()
|
||||||
const agentQ = useQuery(agentQueryOptions(id))
|
const agentQ = useQuery(agentQueryOptions(id))
|
||||||
|
const previewQ = useQuery(agentPreviewQueryOptions(id))
|
||||||
const installRef = useRef<HTMLDivElement>(null)
|
const installRef = useRef<HTMLDivElement>(null)
|
||||||
const [overrideOpen, setOverrideOpen] = useState(false)
|
const [overrideOpen, setOverrideOpen] = useState(false)
|
||||||
const [cloneOpen, setCloneOpen] = useState(false)
|
const [cloneOpen, setCloneOpen] = useState(false)
|
||||||
@@ -94,68 +98,6 @@ function AgentDetailPage() {
|
|||||||
|
|
||||||
const a = agentQ.data
|
const a = agentQ.data
|
||||||
|
|
||||||
const quickActions = useMemo(() => {
|
|
||||||
if (!a) return []
|
|
||||||
const actions = [
|
|
||||||
{
|
|
||||||
id: 'override',
|
|
||||||
title: 'IP override',
|
|
||||||
description: 'Allow/deny поверх политики',
|
|
||||||
icon: <ShieldPlusIcon aria-hidden />,
|
|
||||||
iconClassName: 'text-warning [&_svg]:text-current',
|
|
||||||
badgeLabel: 'Открыть',
|
|
||||||
onSelect: () => setOverrideOpen(true),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'clone',
|
|
||||||
title: 'Копировать наборы',
|
|
||||||
description: 'С другого агента + overrides',
|
|
||||||
icon: <CopyPlusIcon aria-hidden />,
|
|
||||||
iconClassName: 'text-info [&_svg]:text-current',
|
|
||||||
badgeLabel: 'Открыть',
|
|
||||||
onSelect: () => setCloneOpen(true),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'install',
|
|
||||||
title: 'Install curl',
|
|
||||||
description: a.install_curl ? 'Скопировать one-liner' : 'Недоступен',
|
|
||||||
icon: <TerminalIcon aria-hidden />,
|
|
||||||
iconClassName: 'text-primary [&_svg]:text-current',
|
|
||||||
badgeLabel: 'Копировать',
|
|
||||||
onSelect: () => {
|
|
||||||
if (a.install_curl) {
|
|
||||||
copyToClipboard(a.install_curl)
|
|
||||||
toast.success('Скопировано')
|
|
||||||
}
|
|
||||||
installRef.current?.scrollIntoView({ behavior: 'smooth' })
|
|
||||||
},
|
|
||||||
},
|
|
||||||
]
|
|
||||||
if (a.status === 'pending') {
|
|
||||||
actions.push({
|
|
||||||
id: 'approve',
|
|
||||||
title: 'Approve',
|
|
||||||
description: 'Выдать политику агенту',
|
|
||||||
icon: <CheckCircle2Icon aria-hidden />,
|
|
||||||
iconClassName: 'text-success [&_svg]:text-current',
|
|
||||||
badgeLabel: 'Выполнить',
|
|
||||||
onSelect: () => approve.mutate(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
if (a.status === 'approved') {
|
|
||||||
actions.push({
|
|
||||||
id: 'revoke',
|
|
||||||
title: 'Revoke',
|
|
||||||
description: 'Отозвать доступ агента',
|
|
||||||
icon: <ShieldOffIcon aria-hidden />,
|
|
||||||
iconClassName: 'text-destructive [&_svg]:text-current',
|
|
||||||
badgeLabel: 'Выполнить',
|
|
||||||
onSelect: () => revoke.mutate(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return actions
|
|
||||||
}, [a, approve, copyToClipboard, revoke])
|
|
||||||
|
|
||||||
if (agentQ.isLoading || !a) {
|
if (agentQ.isLoading || !a) {
|
||||||
return (
|
return (
|
||||||
<PageShell>
|
<PageShell>
|
||||||
@@ -171,6 +113,7 @@ function AgentDetailPage() {
|
|||||||
a.hostname,
|
a.hostname,
|
||||||
platformLabel(a.platform),
|
platformLabel(a.platform),
|
||||||
`gen ${a.policy_generation}`,
|
`gen ${a.policy_generation}`,
|
||||||
|
a.default_action === 'drop' ? 'default Drop' : 'default Accept',
|
||||||
]
|
]
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join(' · ')
|
.join(' · ')
|
||||||
@@ -217,13 +160,44 @@ function AgentDetailPage() {
|
|||||||
Install
|
Install
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
<Button
|
<DropdownMenu>
|
||||||
variant="outline"
|
<DropdownMenuTrigger
|
||||||
size="sm"
|
render={
|
||||||
render={<Link to="/agents" />}
|
<Button variant="outline" size="icon-sm" aria-label="Ещё" />
|
||||||
>
|
}
|
||||||
К списку
|
>
|
||||||
</Button>
|
<MoreHorizontalIcon className="size-4" />
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuItem onClick={() => setOverrideOpen(true)}>
|
||||||
|
<ShieldPlusIcon className="size-4" />
|
||||||
|
IP override
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={() => setCloneOpen(true)}>
|
||||||
|
<CopyPlusIcon className="size-4" />
|
||||||
|
Копировать наборы
|
||||||
|
</DropdownMenuItem>
|
||||||
|
{a.install_curl ? (
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => {
|
||||||
|
copyToClipboard(a.install_curl!)
|
||||||
|
toast.success('Скопировано')
|
||||||
|
installRef.current?.scrollIntoView({
|
||||||
|
behavior: 'smooth',
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<TerminalIcon className="size-4" />
|
||||||
|
Install curl
|
||||||
|
</DropdownMenuItem>
|
||||||
|
) : null}
|
||||||
|
<DropdownMenuItem
|
||||||
|
render={<Link to="/agents" />}
|
||||||
|
>
|
||||||
|
К списку
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@@ -272,78 +246,26 @@ function AgentDetailPage() {
|
|||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<QuickActionGrid actions={quickActions} />
|
|
||||||
|
|
||||||
<DetailPanel.Section>
|
<DetailPanel.Section>
|
||||||
<div className="grid gap-4 lg:grid-cols-2">
|
<div className="@container flex flex-col gap-4">
|
||||||
<AgentLifecycleTimeline agent={a} />
|
<div className="grid gap-4 @4xl:grid-cols-3">
|
||||||
|
<div className="@4xl:col-span-2">
|
||||||
|
<AgentPolicyTrace
|
||||||
|
preview={previewQ.data}
|
||||||
|
isLoading={previewQ.isLoading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<AgentFactsPanel agent={a} />
|
||||||
|
</div>
|
||||||
|
|
||||||
<div ref={installRef}>
|
<div ref={installRef}>
|
||||||
<Frame dense spacing="sm">
|
|
||||||
<FrameHeader>
|
|
||||||
<FrameTitle>Install / identity</FrameTitle>
|
|
||||||
<FrameDescription>
|
|
||||||
Copy one-liner · hostname · token
|
|
||||||
</FrameDescription>
|
|
||||||
</FrameHeader>
|
|
||||||
<FramePanel className="flex flex-col gap-3">
|
|
||||||
{a.install_curl ? (
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
<pre className="bg-muted overflow-x-auto rounded-lg p-3 text-xs break-all whitespace-pre-wrap">
|
|
||||||
{a.install_curl}
|
|
||||||
</pre>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
className="self-start"
|
|
||||||
onClick={() => {
|
|
||||||
copyToClipboard(a.install_curl!)
|
|
||||||
toast.success('Скопировано')
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Copy data-icon="inline-start" />
|
|
||||||
Копировать
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<p className="text-muted-foreground text-sm">
|
|
||||||
Install curl недоступен
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
<div className="text-muted-foreground grid gap-1 text-sm">
|
|
||||||
<div>
|
|
||||||
Hostname:{' '}
|
|
||||||
<span className="text-foreground">
|
|
||||||
{a.hostname ?? '—'}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
Last seen IP:{' '}
|
|
||||||
<span className="text-foreground">
|
|
||||||
{a.last_seen_ip ?? '—'}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
Client:{' '}
|
|
||||||
<span className="text-foreground">
|
|
||||||
{a.client_version ?? '—'}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
Token prefix:{' '}
|
|
||||||
<span className="text-foreground font-mono">
|
|
||||||
{a.token_prefix}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</FramePanel>
|
|
||||||
</Frame>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="lg:col-span-2">
|
|
||||||
<AgentPolicySetsSortable agentId={id} />
|
<AgentPolicySetsSortable agentId={id} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<AgentEffectiveCidrs
|
||||||
|
preview={previewQ.data}
|
||||||
|
isLoading={previewQ.isLoading}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</DetailPanel.Section>
|
</DetailPanel.Section>
|
||||||
</DetailPanel>
|
</DetailPanel>
|
||||||
|
|||||||
@@ -13,8 +13,16 @@ import {
|
|||||||
listTabFilter,
|
listTabFilter,
|
||||||
} from '@/components/lists/lists-columns'
|
} from '@/components/lists/lists-columns'
|
||||||
import { ConfirmDialog } from '@/components/confirm-dialog'
|
import { ConfirmDialog } from '@/components/confirm-dialog'
|
||||||
import { listsQueryOptions } from '@/queries'
|
import { listsQueryOptions, evobgpCommunitiesQueryOptions } from '@/queries'
|
||||||
import { apiFetch } from '@/lib/api'
|
import { apiFetch } from '@/lib/api'
|
||||||
|
import {
|
||||||
|
Autocomplete,
|
||||||
|
AutocompleteContent,
|
||||||
|
AutocompleteEmpty,
|
||||||
|
AutocompleteInput,
|
||||||
|
AutocompleteItem,
|
||||||
|
AutocompleteList,
|
||||||
|
} from '@/components/reui/autocomplete'
|
||||||
import { Button } from '@evofw/ui/components/button'
|
import { Button } from '@evofw/ui/components/button'
|
||||||
import { Field, FieldLabel } from '@evofw/ui/components/field'
|
import { Field, FieldLabel } from '@evofw/ui/components/field'
|
||||||
import { Input } from '@evofw/ui/components/input'
|
import { Input } from '@evofw/ui/components/input'
|
||||||
@@ -58,8 +66,6 @@ const CREATE_SOURCE_ITEMS = [
|
|||||||
function ListsPage() {
|
function ListsPage() {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const qc = useQueryClient()
|
const qc = useQueryClient()
|
||||||
const listsQ = useQuery(listsQueryOptions())
|
|
||||||
|
|
||||||
const [createOpen, setCreateOpen] = useState(false)
|
const [createOpen, setCreateOpen] = useState(false)
|
||||||
const [name, setName] = useState('')
|
const [name, setName] = useState('')
|
||||||
const [source, setSource] = useState<CreateSource>('static')
|
const [source, setSource] = useState<CreateSource>('static')
|
||||||
@@ -69,6 +75,21 @@ function ListsPage() {
|
|||||||
const [activeTab, setActiveTab] = useState('all')
|
const [activeTab, setActiveTab] = useState('all')
|
||||||
const [deleteListId, setDeleteListId] = useState<string | null>(null)
|
const [deleteListId, setDeleteListId] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const listsQ = useQuery(listsQueryOptions())
|
||||||
|
const communitiesQ = useQuery({
|
||||||
|
...evobgpCommunitiesQueryOptions(),
|
||||||
|
enabled: createOpen && source === 'evobgp_community',
|
||||||
|
})
|
||||||
|
|
||||||
|
const communityItems = useMemo(
|
||||||
|
() =>
|
||||||
|
(communitiesQ.data?.items ?? []).map((c) => ({
|
||||||
|
value: c.id,
|
||||||
|
label: c.title ? `${c.community} · ${c.title}` : c.community,
|
||||||
|
})),
|
||||||
|
[communitiesQ.data?.items],
|
||||||
|
)
|
||||||
|
|
||||||
const create = useMutation({
|
const create = useMutation({
|
||||||
mutationFn: async () => {
|
mutationFn: async () => {
|
||||||
const config: Record<string, unknown> = {}
|
const config: Record<string, unknown> = {}
|
||||||
@@ -273,12 +294,35 @@ function ListsPage() {
|
|||||||
) : null}
|
) : null}
|
||||||
{source === 'evobgp_community' ? (
|
{source === 'evobgp_community' ? (
|
||||||
<Field>
|
<Field>
|
||||||
<FieldLabel htmlFor="list-comm">Community ID</FieldLabel>
|
<FieldLabel>BGP community</FieldLabel>
|
||||||
<Input
|
<Autocomplete
|
||||||
id="list-comm"
|
items={communityItems}
|
||||||
value={extra}
|
value={extra}
|
||||||
onChange={(e) => setExtra(e.target.value)}
|
onValueChange={setExtra}
|
||||||
/>
|
>
|
||||||
|
<AutocompleteInput
|
||||||
|
placeholder={
|
||||||
|
communitiesQ.isError
|
||||||
|
? 'ID вручную (EvoBGP недоступен)'
|
||||||
|
: 'Поиск community…'
|
||||||
|
}
|
||||||
|
showClear
|
||||||
|
/>
|
||||||
|
<AutocompleteContent>
|
||||||
|
<AutocompleteEmpty>
|
||||||
|
{communitiesQ.isLoading
|
||||||
|
? 'Загрузка…'
|
||||||
|
: 'Нет совпадений'}
|
||||||
|
</AutocompleteEmpty>
|
||||||
|
<AutocompleteList>
|
||||||
|
{(item) => (
|
||||||
|
<AutocompleteItem key={item.value} value={item}>
|
||||||
|
{item.label}
|
||||||
|
</AutocompleteItem>
|
||||||
|
)}
|
||||||
|
</AutocompleteList>
|
||||||
|
</AutocompleteContent>
|
||||||
|
</Autocomplete>
|
||||||
</Field>
|
</Field>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ import { DataGridPrimaryCell } from '@/components/data-grid-cell'
|
|||||||
import { StatusBadge } from '@/components/status-badge'
|
import { StatusBadge } from '@/components/status-badge'
|
||||||
import { ConfirmDialog } from '@/components/confirm-dialog'
|
import { ConfirmDialog } from '@/components/confirm-dialog'
|
||||||
import { PolicyRulesSortable } from '@/components/rules/policy-rules-sortable'
|
import { PolicyRulesSortable } from '@/components/rules/policy-rules-sortable'
|
||||||
import { PolicyModeToggle } from '@/components/rules/policy-mode-toggle'
|
|
||||||
import {
|
import {
|
||||||
agentsQueryOptions,
|
agentsQueryOptions,
|
||||||
listsQueryOptions,
|
listsQueryOptions,
|
||||||
@@ -103,11 +102,7 @@ function PolicySetDetailPage() {
|
|||||||
}, [assignedIds])
|
}, [assignedIds])
|
||||||
|
|
||||||
const patchSet = useMutation({
|
const patchSet = useMutation({
|
||||||
mutationFn: (body: {
|
mutationFn: (body: { enabled?: boolean; name?: string }) =>
|
||||||
enabled?: boolean
|
|
||||||
name?: string
|
|
||||||
policy_mode?: 'blacklist' | 'whitelist'
|
|
||||||
}) =>
|
|
||||||
apiFetch(`/api/v1/policy-sets/${setId}`, {
|
apiFetch(`/api/v1/policy-sets/${setId}`, {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
body: JSON.stringify(body),
|
body: JSON.stringify(body),
|
||||||
@@ -277,8 +272,6 @@ function PolicySetDetailPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const set = setQ.data
|
const set = setQ.data
|
||||||
const policyMode =
|
|
||||||
set.policy_mode === 'whitelist' ? 'whitelist' : 'blacklist'
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageShell>
|
<PageShell>
|
||||||
@@ -339,19 +332,10 @@ function PolicySetDetailPage() {
|
|||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<DetailPanel.Section>
|
|
||||||
<PolicyModeToggle
|
|
||||||
value={policyMode}
|
|
||||||
disabled={patchSet.isPending}
|
|
||||||
onChange={(mode) => patchSet.mutate({ policy_mode: mode })}
|
|
||||||
/>
|
|
||||||
</DetailPanel.Section>
|
|
||||||
|
|
||||||
<DetailPanel.Section>
|
<DetailPanel.Section>
|
||||||
<PolicyRulesSortable
|
<PolicyRulesSortable
|
||||||
setId={setId}
|
setId={setId}
|
||||||
rules={rules}
|
rules={rules}
|
||||||
policyMode={policyMode}
|
|
||||||
onDelete={(id) => setDeleteRuleId(id)}
|
onDelete={(id) => setDeleteRuleId(id)}
|
||||||
onAdd={() => setRuleOpen(true)}
|
onAdd={() => setRuleOpen(true)}
|
||||||
/>
|
/>
|
||||||
@@ -359,7 +343,7 @@ function PolicySetDetailPage() {
|
|||||||
|
|
||||||
<DetailPanel.Section
|
<DetailPanel.Section
|
||||||
title="Назначено агентам"
|
title="Назначено агентам"
|
||||||
description="Все наборы агента должны иметь один режим фильтра."
|
description="Агенты, которым применён этот набор."
|
||||||
>
|
>
|
||||||
<Frame dense spacing="sm">
|
<Frame dense spacing="sm">
|
||||||
<FrameHeader>
|
<FrameHeader>
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import { PageHeader, PageShell, ResourcePage } from '@/components/reui-kit'
|
|||||||
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
||||||
import { DataGridPrimaryCell } from '@/components/data-grid-cell'
|
import { DataGridPrimaryCell } from '@/components/data-grid-cell'
|
||||||
import { StatusBadge } from '@/components/status-badge'
|
import { StatusBadge } from '@/components/status-badge'
|
||||||
import { Badge } from '@/components/reui/badge'
|
|
||||||
import { ConfirmDialog } from '@/components/confirm-dialog'
|
import { ConfirmDialog } from '@/components/confirm-dialog'
|
||||||
import { PolicySetIcon } from '@/components/rules/policy-set-icon'
|
import { PolicySetIcon } from '@/components/rules/policy-set-icon'
|
||||||
import { policySetsQueryOptions } from '@/queries'
|
import { policySetsQueryOptions } from '@/queries'
|
||||||
@@ -120,7 +119,7 @@ function PolicySetsPage() {
|
|||||||
),
|
),
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<div className="flex min-w-0 items-center gap-3">
|
<div className="flex min-w-0 items-center gap-3">
|
||||||
<PolicySetIcon mode={row.original.policy_mode} />
|
<PolicySetIcon />
|
||||||
<DataGridPrimaryCell
|
<DataGridPrimaryCell
|
||||||
accent="primary"
|
accent="primary"
|
||||||
title={row.original.name}
|
title={row.original.name}
|
||||||
@@ -140,26 +139,6 @@ function PolicySetsPage() {
|
|||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
|
||||||
accessorKey: 'policy_mode',
|
|
||||||
header: ({ column }) => (
|
|
||||||
<DataGridColumnHeader column={column} title="Режим" />
|
|
||||||
),
|
|
||||||
cell: ({ row }) => (
|
|
||||||
<Badge
|
|
||||||
variant={
|
|
||||||
row.original.policy_mode === 'whitelist'
|
|
||||||
? 'warning-light'
|
|
||||||
: 'secondary'
|
|
||||||
}
|
|
||||||
size="sm"
|
|
||||||
>
|
|
||||||
{row.original.policy_mode === 'whitelist'
|
|
||||||
? 'whitelist'
|
|
||||||
: 'blacklist'}
|
|
||||||
</Badge>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
accessorKey: 'rules_count',
|
accessorKey: 'rules_count',
|
||||||
header: ({ column }) => (
|
header: ({ column }) => (
|
||||||
|
|||||||
+5
-5
@@ -63,14 +63,14 @@ Install RSC:
|
|||||||
|
|
||||||
1. Enroll (с `install_link_id` → агент Invited → Pending).
|
1. Enroll (с `install_link_id` → агент Invited → Pending).
|
||||||
2. Создаёт filter-правила `evofw-*` и address-list `EVOFW_DENY` / `EVOFW_ALLOW`.
|
2. Создаёт filter-правила `evofw-*` и address-list `EVOFW_DENY` / `EVOFW_ALLOW`.
|
||||||
3. Scheduler `evofw-sync` каждую минуту: `GET /v1/agent/policy.rsc` → `/import` (списки + режим).
|
3. Scheduler `evofw-sync` каждую минуту: `GET /v1/agent/policy.rsc` → `/import` (списки + default).
|
||||||
|
|
||||||
**Режим фильтра** задаётся на **наборе правил** (`/rules`), не на агенте:
|
**Default action** задаётся на **агенте** (`default_action: accept | drop`):
|
||||||
|
|
||||||
- **blacklist** — по умолчанию ACCEPT; deny-CIDR блокируются
|
- **accept** — пакет вне deny/allow пропускается
|
||||||
- **whitelist** — по умолчанию DROP (forward); только allow-CIDR
|
- **drop** — пакет вне deny/allow отбрасывается (forward)
|
||||||
|
|
||||||
Все наборы, назначенные агенту, должны иметь один режим.
|
Цепочка всегда: deny-drop → allow-accept → default. Наборы несут только правила deny/allow, без exclusive mode.
|
||||||
|
|
||||||
## Force sync
|
## Force sync
|
||||||
|
|
||||||
|
|||||||
@@ -17,18 +17,18 @@
|
|||||||
|
|
||||||
1. **Enroll** — `POST /v1/agent/enroll` + `X-EvoFW-Seed` → pending agent
|
1. **Enroll** — `POST /v1/agent/enroll` + `X-EvoFW-Seed` → pending agent
|
||||||
2. **Approve** — UI/API → status approved
|
2. **Approve** — UI/API → status approved
|
||||||
3. **Policy** — `GET /v1/agent/policy` → deny/allow CIDRs + mode + hash
|
3. **Policy** — `GET /v1/agent/policy` → deny/allow CIDRs + `default_action` + hash (`apply_version: 2`)
|
||||||
4. **Apply** — agent пишет kernel rules, `POST /v1/agent/apply-report` + stats sample
|
4. **Apply** — agent пишет kernel rules, `POST /v1/agent/apply-report` + stats sample
|
||||||
5. **Lists refresh** — cron каждые 5 мин (json_url / domains / evobgp_community)
|
5. **Lists refresh** — cron каждые 5 мин (json_url / domains / evobgp_community)
|
||||||
|
|
||||||
## Политика
|
## Политика
|
||||||
|
|
||||||
- Именованные **наборы правил** (`policy_sets`); агенту назначается **M:N** через `agent_policy_sets`
|
- Именованные **наборы правил** (`policy_sets`); агенту назначается **M:N** через `agent_policy_sets`
|
||||||
- Правило в наборе: ровно один источник — IP-список (`list_id`), CIDR или DNS-имя (`hostname` → A/AAAA, кэш в `policy_rule_resolved`)
|
- Правило в наборе: `action: deny | allow` + ровно один источник — IP-список (`list_id`), CIDR или DNS-имя (`hostname` → A/AAAA, кэш в `policy_rule_resolved`)
|
||||||
- Evaluate: правила всех назначенных enabled-наборов (sort + priority) + `ip_overrides`
|
- Evaluate: правила всех назначенных enabled-наборов (sort + priority) + `ip_overrides`
|
||||||
- `blacklist` — default accept, apply deny set
|
- Цепочка ядра **всегда**: deny → allow → `default_action` (`accept` | `drop` на агенте)
|
||||||
- `whitelist` — default drop, apply allow set (+ lo/established на Linux)
|
- Exact overlap: `allow \ deny` (`conflicts_dropped`); deny wins
|
||||||
- Overrides, смена наборов и refresh DNS/lists бампят `policy_generation`
|
- Overrides, смена наборов, `default_action` и refresh DNS/lists бампят `policy_generation`
|
||||||
|
|
||||||
## Auth
|
## Auth
|
||||||
|
|
||||||
|
|||||||
@@ -7,15 +7,22 @@ EvoFirewall использует EvoBGP как **источник префикс
|
|||||||
В UI Settings или `settings` table:
|
В UI Settings или `settings` table:
|
||||||
|
|
||||||
- `evobgp_api_url` — base URL EvoBGP API
|
- `evobgp_api_url` — base URL EvoBGP API
|
||||||
- `evobgp_api_token` — API key (viewer+)
|
- `evobgp_api_token` — API key (viewer+ / `bgp:directories:read`)
|
||||||
|
|
||||||
При refresh списка:
|
## Refresh списка
|
||||||
|
|
||||||
1. `GET {api}/v1/directories/communities/{id}/prefixes` (если доступен)
|
При refresh списка `evobgp_community`:
|
||||||
2. fallback `GET {api}/v1/lookup?q={community_id}`
|
|
||||||
|
1. `GET {api}/v1/communities/{id}/prefixes?limit=5000`
|
||||||
|
2. Ответ: `{ items: [{ prefix }], prefixes: string[], has_more, next_cursor }`
|
||||||
|
3. Entries заменяются; generation агентов с правилами на этот list бампится
|
||||||
|
|
||||||
|
## Autocomplete в UI
|
||||||
|
|
||||||
|
`GET /api/v1/integrations/evobgp/communities` — proxy к EvoBGP `GET /v1/communities?limit=200` (нужны settings выше).
|
||||||
|
|
||||||
## Список
|
## Список
|
||||||
|
|
||||||
Создайте IP list type `evobgp_community` с `config.community_id`. Cron / кнопка Refresh обновляет entries и бампит generation агентов.
|
Создайте IP list type `evobgp_community` с `config.community_id`. Cron / кнопка Refresh обновляет entries.
|
||||||
|
|
||||||
Firewall-подсистема в EvoBGP **удалена** (hard cutover) — клиенты переустанавливаются на EvoFirewall agents.
|
Firewall-подсистема в EvoBGP **удалена** (hard cutover) — клиенты переустанавливаются на EvoFirewall agents.
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
-- Replace exclusive blacklist/whitelist with default_action (accept|drop).
|
||||||
|
-- Unified kernel chain: deny → allow → default_action.
|
||||||
|
|
||||||
|
PRAGMA foreign_keys = OFF;
|
||||||
|
|
||||||
|
CREATE TABLE agents_v3 (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
hostname TEXT,
|
||||||
|
platform TEXT NOT NULL DEFAULT 'linux',
|
||||||
|
token_prefix TEXT NOT NULL,
|
||||||
|
token_hash TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL DEFAULT 'pending',
|
||||||
|
default_action TEXT NOT NULL DEFAULT 'accept',
|
||||||
|
policy_generation INTEGER NOT NULL DEFAULT 1,
|
||||||
|
last_seen_at TEXT,
|
||||||
|
last_seen_ip TEXT,
|
||||||
|
last_apply_at TEXT,
|
||||||
|
last_apply_status TEXT,
|
||||||
|
last_apply_error TEXT,
|
||||||
|
last_apply_prefix_count INTEGER DEFAULT 0,
|
||||||
|
last_apply_packets_dropped INTEGER NOT NULL DEFAULT 0,
|
||||||
|
last_apply_packets_accepted INTEGER NOT NULL DEFAULT 0,
|
||||||
|
last_apply_kernel_method TEXT,
|
||||||
|
client_version TEXT,
|
||||||
|
settings_json TEXT NOT NULL DEFAULT '{}',
|
||||||
|
created_by_user_id TEXT,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
||||||
|
approved_at TEXT,
|
||||||
|
revoked_at TEXT,
|
||||||
|
CHECK (status IN ('invited', 'pending', 'approved', 'revoked')),
|
||||||
|
CHECK (platform IN ('linux', 'mikrotik')),
|
||||||
|
CHECK (default_action IN ('accept', 'drop')),
|
||||||
|
CHECK (length(trim(name)) > 0)
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO agents_v3 (
|
||||||
|
id, name, hostname, platform, token_prefix, token_hash, status, default_action,
|
||||||
|
policy_generation, last_seen_at, last_seen_ip, last_apply_at, last_apply_status,
|
||||||
|
last_apply_error, last_apply_prefix_count, last_apply_packets_dropped,
|
||||||
|
last_apply_packets_accepted, last_apply_kernel_method, client_version,
|
||||||
|
settings_json, created_by_user_id, created_at, approved_at, revoked_at
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
id, name, hostname, platform, token_prefix, token_hash, status,
|
||||||
|
CASE
|
||||||
|
WHEN policy_mode = 'whitelist' THEN 'drop'
|
||||||
|
WHEN policy_mode = 'drop' THEN 'drop'
|
||||||
|
WHEN policy_mode = 'accept' THEN 'accept'
|
||||||
|
ELSE 'accept'
|
||||||
|
END,
|
||||||
|
policy_generation, last_seen_at, last_seen_ip, last_apply_at, last_apply_status,
|
||||||
|
last_apply_error, last_apply_prefix_count, last_apply_packets_dropped,
|
||||||
|
last_apply_packets_accepted, last_apply_kernel_method, client_version,
|
||||||
|
settings_json, created_by_user_id, created_at, approved_at, revoked_at
|
||||||
|
FROM agents;
|
||||||
|
|
||||||
|
DROP TABLE agents;
|
||||||
|
ALTER TABLE agents_v3 RENAME TO agents;
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_agents_token_hash ON agents (token_hash);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_agents_status ON agents (status);
|
||||||
|
|
||||||
|
PRAGMA foreign_keys = ON;
|
||||||
@@ -215,22 +215,12 @@ export function listSetsForAgent(db: Db, agentId: string) {
|
|||||||
.all()
|
.all()
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Replace agent↔set assignments; set_ids order = sort. All sets must share policy_mode. */
|
/** Replace agent↔set assignments; set_ids order = sort. */
|
||||||
export function setAgentPolicySets(db: Db, agentId: string, setIds: string[]) {
|
export function setAgentPolicySets(db: Db, agentId: string, setIds: string[]) {
|
||||||
if (setIds.length > 0) {
|
for (const setId of setIds) {
|
||||||
const modes = new Set<string>()
|
if (!getPolicySet(db, setId)) {
|
||||||
for (const setId of setIds) {
|
throw new Error(`policy set not found: ${setId}`)
|
||||||
const s = getPolicySet(db, setId)
|
|
||||||
if (!s) throw new Error(`policy set not found: ${setId}`)
|
|
||||||
modes.add(s.policyMode === 'whitelist' ? 'whitelist' : 'blacklist')
|
|
||||||
}
|
}
|
||||||
if (modes.size > 1) {
|
|
||||||
throw new Error(
|
|
||||||
'все наборы агента должны иметь один режим (blacklist или whitelist)',
|
|
||||||
)
|
|
||||||
}
|
|
||||||
const mode = [...modes][0] ?? 'blacklist'
|
|
||||||
updateAgent(db, agentId, { policyMode: mode })
|
|
||||||
}
|
}
|
||||||
|
|
||||||
db.delete(agentPolicySets).where(eq(agentPolicySets.agentId, agentId)).run()
|
db.delete(agentPolicySets).where(eq(agentPolicySets.agentId, agentId)).run()
|
||||||
@@ -498,7 +488,7 @@ export function cloneRulesFrom(
|
|||||||
}
|
}
|
||||||
|
|
||||||
updateAgent(db, targetAgentId, {
|
updateAgent(db, targetAgentId, {
|
||||||
policyMode: source.policyMode,
|
defaultAction: source.defaultAction,
|
||||||
policyGeneration: (target.policyGeneration ?? 1) + 1,
|
policyGeneration: (target.policyGeneration ?? 1) + 1,
|
||||||
})
|
})
|
||||||
return getAgent(db, targetAgentId)
|
return getAgent(db, targetAgentId)
|
||||||
|
|||||||
@@ -19,7 +19,8 @@ export const agents = sqliteTable(
|
|||||||
tokenPrefix: text('token_prefix').notNull(),
|
tokenPrefix: text('token_prefix').notNull(),
|
||||||
tokenHash: text('token_hash').notNull(),
|
tokenHash: text('token_hash').notNull(),
|
||||||
status: text('status').notNull().default('pending'), // invited | pending | approved | revoked
|
status: text('status').notNull().default('pending'), // invited | pending | approved | revoked
|
||||||
policyMode: text('policy_mode').notNull().default('blacklist'), // blacklist | whitelist
|
/** Packet default when not in deny/allow sets: accept | drop */
|
||||||
|
defaultAction: text('default_action').notNull().default('accept'),
|
||||||
policyGeneration: integer('policy_generation').notNull().default(1),
|
policyGeneration: integer('policy_generation').notNull().default(1),
|
||||||
lastSeenAt: text('last_seen_at'),
|
lastSeenAt: text('last_seen_at'),
|
||||||
lastSeenIp: text('last_seen_ip'),
|
lastSeenIp: text('last_seen_ip'),
|
||||||
@@ -84,7 +85,8 @@ export const policySets = sqliteTable('policy_sets', {
|
|||||||
name: text('name').notNull(),
|
name: text('name').notNull(),
|
||||||
description: text('description'),
|
description: text('description'),
|
||||||
enabled: integer('enabled').notNull().default(1),
|
enabled: integer('enabled').notNull().default(1),
|
||||||
policyMode: text('policy_mode').notNull().default('blacklist'), // blacklist | whitelist
|
/** Legacy unused; sets no longer carry exclusive mode. */
|
||||||
|
policyMode: text('policy_mode').notNull().default('blacklist'),
|
||||||
createdAt: text('created_at')
|
createdAt: text('created_at')
|
||||||
.notNull()
|
.notNull()
|
||||||
.default(sql`(strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))`),
|
.default(sql`(strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))`),
|
||||||
|
|||||||
@@ -7,7 +7,13 @@ export const agentStatusSchema = z.enum([
|
|||||||
'approved',
|
'approved',
|
||||||
'revoked',
|
'revoked',
|
||||||
])
|
])
|
||||||
|
|
||||||
|
/** Packet default when CIDR is in neither deny nor allow set. */
|
||||||
|
export const defaultActionSchema = z.enum(['accept', 'drop'])
|
||||||
|
|
||||||
|
/** @deprecated Use defaultActionSchema. Kept for API input compat. */
|
||||||
export const policyModeSchema = z.enum(['blacklist', 'whitelist'])
|
export const policyModeSchema = z.enum(['blacklist', 'whitelist'])
|
||||||
|
|
||||||
export const policyActionSchema = z.enum(['allow', 'deny'])
|
export const policyActionSchema = z.enum(['allow', 'deny'])
|
||||||
export const ipListTypeSchema = z.enum([
|
export const ipListTypeSchema = z.enum([
|
||||||
'static',
|
'static',
|
||||||
@@ -16,6 +22,21 @@ export const ipListTypeSchema = z.enum([
|
|||||||
'evobgp_community',
|
'evobgp_community',
|
||||||
])
|
])
|
||||||
|
|
||||||
|
/** Map legacy blacklist/whitelist → default_action. */
|
||||||
|
export function defaultActionFromLegacyMode(
|
||||||
|
mode: string | null | undefined,
|
||||||
|
): 'accept' | 'drop' {
|
||||||
|
if (mode === 'whitelist' || mode === 'drop') return 'drop'
|
||||||
|
return 'accept'
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Optional mirror for old agent binaries. */
|
||||||
|
export function legacyModeFromDefaultAction(
|
||||||
|
action: 'accept' | 'drop',
|
||||||
|
): 'blacklist' | 'whitelist' {
|
||||||
|
return action === 'drop' ? 'whitelist' : 'blacklist'
|
||||||
|
}
|
||||||
|
|
||||||
export const agentSchema = z.object({
|
export const agentSchema = z.object({
|
||||||
id: z.string(),
|
id: z.string(),
|
||||||
name: z.string(),
|
name: z.string(),
|
||||||
@@ -23,7 +44,9 @@ export const agentSchema = z.object({
|
|||||||
platform: agentPlatformSchema,
|
platform: agentPlatformSchema,
|
||||||
token_prefix: z.string(),
|
token_prefix: z.string(),
|
||||||
status: agentStatusSchema,
|
status: agentStatusSchema,
|
||||||
policy_mode: policyModeSchema,
|
default_action: defaultActionSchema,
|
||||||
|
/** @deprecated mirror of default_action for older clients */
|
||||||
|
policy_mode: policyModeSchema.optional(),
|
||||||
policy_generation: z.number().int(),
|
policy_generation: z.number().int(),
|
||||||
last_seen_at: z.string().nullable().optional(),
|
last_seen_at: z.string().nullable().optional(),
|
||||||
last_seen_ip: z.string().nullable().optional(),
|
last_seen_ip: z.string().nullable().optional(),
|
||||||
@@ -76,7 +99,8 @@ export const policySetSchema = z.object({
|
|||||||
name: z.string(),
|
name: z.string(),
|
||||||
description: z.string().nullable().optional(),
|
description: z.string().nullable().optional(),
|
||||||
enabled: z.boolean(),
|
enabled: z.boolean(),
|
||||||
policy_mode: policyModeSchema,
|
/** @deprecated ignored — sets have no exclusive mode */
|
||||||
|
policy_mode: policyModeSchema.optional(),
|
||||||
rules_count: z.number().int().optional(),
|
rules_count: z.number().int().optional(),
|
||||||
agents_count: z.number().int().optional(),
|
agents_count: z.number().int().optional(),
|
||||||
created_at: z.string(),
|
created_at: z.string(),
|
||||||
@@ -104,13 +128,15 @@ export const createPolicySetBodySchema = z.object({
|
|||||||
name: z.string().min(1),
|
name: z.string().min(1),
|
||||||
description: z.string().nullable().optional(),
|
description: z.string().nullable().optional(),
|
||||||
enabled: z.boolean().optional().default(true),
|
enabled: z.boolean().optional().default(true),
|
||||||
policy_mode: policyModeSchema.optional().default('blacklist'),
|
/** @deprecated ignored */
|
||||||
|
policy_mode: policyModeSchema.optional(),
|
||||||
})
|
})
|
||||||
|
|
||||||
export const patchPolicySetBodySchema = z.object({
|
export const patchPolicySetBodySchema = z.object({
|
||||||
name: z.string().min(1).optional(),
|
name: z.string().min(1).optional(),
|
||||||
description: z.string().nullable().optional(),
|
description: z.string().nullable().optional(),
|
||||||
enabled: z.boolean().optional(),
|
enabled: z.boolean().optional(),
|
||||||
|
/** @deprecated ignored */
|
||||||
policy_mode: policyModeSchema.optional(),
|
policy_mode: policyModeSchema.optional(),
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -158,11 +184,26 @@ export const createOverrideBodySchema = z.object({
|
|||||||
comment: z.string().nullable().optional(),
|
comment: z.string().nullable().optional(),
|
||||||
})
|
})
|
||||||
|
|
||||||
export const patchAgentBodySchema = z.object({
|
export const patchAgentBodySchema = z
|
||||||
name: z.string().min(1).optional(),
|
.object({
|
||||||
policy_mode: policyModeSchema.optional(),
|
name: z.string().min(1).optional(),
|
||||||
settings: z.record(z.string(), z.unknown()).optional(),
|
default_action: defaultActionSchema.optional(),
|
||||||
})
|
/** @deprecated use default_action */
|
||||||
|
policy_mode: policyModeSchema.optional(),
|
||||||
|
settings: z.record(z.string(), z.unknown()).optional(),
|
||||||
|
})
|
||||||
|
.transform((v) => {
|
||||||
|
const default_action =
|
||||||
|
v.default_action ??
|
||||||
|
(v.policy_mode !== undefined
|
||||||
|
? defaultActionFromLegacyMode(v.policy_mode)
|
||||||
|
: undefined)
|
||||||
|
return {
|
||||||
|
name: v.name,
|
||||||
|
default_action,
|
||||||
|
settings: v.settings,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
export const cloneFromBodySchema = z.object({
|
export const cloneFromBodySchema = z.object({
|
||||||
include_overrides: z.boolean().optional().default(false),
|
include_overrides: z.boolean().optional().default(false),
|
||||||
@@ -190,12 +231,47 @@ export const applyReportBodySchema = z.object({
|
|||||||
export const agentPolicySchema = z.object({
|
export const agentPolicySchema = z.object({
|
||||||
generation: z.number().int(),
|
generation: z.number().int(),
|
||||||
hash: z.string(),
|
hash: z.string(),
|
||||||
policy_mode: policyModeSchema,
|
apply_version: z.number().int(),
|
||||||
|
default_action: defaultActionSchema,
|
||||||
|
/** @deprecated mirror for old agents */
|
||||||
|
policy_mode: policyModeSchema.optional(),
|
||||||
deny_cidrs: z.array(z.string()),
|
deny_cidrs: z.array(z.string()),
|
||||||
allow_cidrs: z.array(z.string()),
|
allow_cidrs: z.array(z.string()),
|
||||||
sync_interval_sec: z.number().int(),
|
sync_interval_sec: z.number().int(),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
export const agentPolicyPreviewSchema = z.object({
|
||||||
|
default_action: defaultActionSchema,
|
||||||
|
hash: z.string(),
|
||||||
|
generation: z.number().int(),
|
||||||
|
sync_interval_sec: z.number().int(),
|
||||||
|
apply_version: z.literal(2),
|
||||||
|
summary: z.object({
|
||||||
|
sets: z.number().int(),
|
||||||
|
rules_deny: z.number().int(),
|
||||||
|
rules_allow: z.number().int(),
|
||||||
|
cidrs_deny: z.number().int(),
|
||||||
|
cidrs_allow: z.number().int(),
|
||||||
|
overrides: z.number().int(),
|
||||||
|
conflicts_dropped: z.number().int(),
|
||||||
|
}),
|
||||||
|
chain: z.array(
|
||||||
|
z.object({
|
||||||
|
set_id: z.string().nullable(),
|
||||||
|
set_name: z.string().nullable(),
|
||||||
|
rule_id: z.string().nullable(),
|
||||||
|
action: policyActionSchema,
|
||||||
|
source_kind: z.enum(['list', 'cidr', 'hostname', 'override']),
|
||||||
|
source_label: z.string(),
|
||||||
|
cidr_count: z.number().int(),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
deny_cidrs: z.array(z.string()),
|
||||||
|
allow_cidrs: z.array(z.string()),
|
||||||
|
deny_cidrs_total: z.number().int(),
|
||||||
|
allow_cidrs_total: z.number().int(),
|
||||||
|
})
|
||||||
|
|
||||||
export const dashboardStatsSchema = z.object({
|
export const dashboardStatsSchema = z.object({
|
||||||
agents_total: z.number().int(),
|
agents_total: z.number().int(),
|
||||||
agents_approved: z.number().int(),
|
agents_approved: z.number().int(),
|
||||||
@@ -235,11 +311,20 @@ export const installLinkSchema = z.object({
|
|||||||
.optional(),
|
.optional(),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
export const evobgpCommunitySchema = z.object({
|
||||||
|
id: z.string(),
|
||||||
|
community: z.string(),
|
||||||
|
title: z.string().nullable().optional(),
|
||||||
|
})
|
||||||
|
|
||||||
export type Agent = z.infer<typeof agentSchema>
|
export type Agent = z.infer<typeof agentSchema>
|
||||||
export type IpList = z.infer<typeof ipListSchema>
|
export type IpList = z.infer<typeof ipListSchema>
|
||||||
export type PolicyRule = z.infer<typeof policyRuleSchema>
|
export type PolicyRule = z.infer<typeof policyRuleSchema>
|
||||||
export type PolicySet = z.infer<typeof policySetSchema>
|
export type PolicySet = z.infer<typeof policySetSchema>
|
||||||
export type IpOverride = z.infer<typeof ipOverrideSchema>
|
export type IpOverride = z.infer<typeof ipOverrideSchema>
|
||||||
export type AgentPolicy = z.infer<typeof agentPolicySchema>
|
export type AgentPolicy = z.infer<typeof agentPolicySchema>
|
||||||
|
export type AgentPolicyPreview = z.infer<typeof agentPolicyPreviewSchema>
|
||||||
export type DashboardStats = z.infer<typeof dashboardStatsSchema>
|
export type DashboardStats = z.infer<typeof dashboardStatsSchema>
|
||||||
export type InstallLink = z.infer<typeof installLinkSchema>
|
export type InstallLink = z.infer<typeof installLinkSchema>
|
||||||
|
export type EvobgpCommunity = z.infer<typeof evobgpCommunitySchema>
|
||||||
|
export type DefaultAction = z.infer<typeof defaultActionSchema>
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import * as React from "react"
|
import * as React from "react"
|
||||||
import { ScrollArea as ScrollAreaPrimitive } from "@base-ui/react/scroll-area"
|
import { ScrollArea as ScrollAreaPrimitive } from "@base-ui/react/scroll-area"
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user