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

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

These changes significantly improve the management and visibility of firewall rules and port access control for agents.
This commit is contained in:
Denozordec
2026-08-11 15:08:00 +07:00
parent c5069fbdaf
commit 43f5ac2525
22 changed files with 2853 additions and 17 deletions
+291 -4
View File
@@ -57,6 +57,7 @@ fi
parse_policy() {
local f="$1"
PORT_RULES_FILE="${STATE_DIR}/last_port_rules.json"
if command -v jq >/dev/null 2>&1; then
HASH=$(jq -r '.hash // empty' "$f")
DEFAULT_ACTION=$(jq -r '.default_action // empty' "$f")
@@ -67,10 +68,11 @@ parse_policy() {
fi
mapfile -t DENY < <(jq -r '.deny_cidrs[]? // empty' "$f")
mapfile -t ALLOW < <(jq -r '.allow_cidrs[]? // empty' "$f")
jq -c '.port_rules // []' "$f" >"$PORT_RULES_FILE" 2>/dev/null || echo '[]' >"$PORT_RULES_FILE"
return 0
fi
if command -v python3 >/dev/null 2>&1; then
eval "$(python3 - "$f" <<'PY'
eval "$(python3 - "$f" "$PORT_RULES_FILE" <<'PY'
import json,sys
d=json.load(open(sys.argv[1],encoding="utf-8"))
print(f'HASH={d.get("hash") or ""}')
@@ -80,6 +82,7 @@ if not da:
print(f'DEFAULT_ACTION={da}')
print("DENY=("+" ".join(json.dumps(x) for x in (d.get("deny_cidrs") or []))+")")
print("ALLOW=("+" ".join(json.dumps(x) for x in (d.get("allow_cidrs") or []))+")")
open(sys.argv[2],"w",encoding="utf-8").write(json.dumps(d.get("port_rules") or []))
PY
)"
return 0
@@ -89,11 +92,17 @@ PY
}
HASH=""; DEFAULT_ACTION=accept; DENY=(); ALLOW=()
PORT_RULES_FILE="${STATE_DIR}/last_port_rules.json"
parse_policy "$POLICY_FILE"
# Empty deny/allow is valid — agent may have no rule sets yet.
DENY=("${DENY[@]+"${DENY[@]}"}")
ALLOW=("${ALLOW[@]+"${ALLOW[@]}"}")
log "default_action=$DEFAULT_ACTION deny=${#DENY[@]} allow=${#ALLOW[@]} hash=$HASH"
[[ -f "$PORT_RULES_FILE" ]] || echo '[]' >"$PORT_RULES_FILE"
PORT_RULES_COUNT=0
if command -v python3 >/dev/null 2>&1; then
PORT_RULES_COUNT=$(python3 -c 'import json,sys; print(len(json.load(open(sys.argv[1]))))' "$PORT_RULES_FILE" 2>/dev/null || echo 0)
fi
log "default_action=$DEFAULT_ACTION deny=${#DENY[@]} allow=${#ALLOW[@]} port_rules=$PORT_RULES_COUNT hash=$HASH"
PACKETS_DROPPED=0
PACKETS_ACCEPTED=0
@@ -101,6 +110,7 @@ KERNEL_METHOD=""
APPLIED=0
IP_HITS_JSON="[]"
PORT_HITS_JSON="[]"
HOST_FIREWALL_JSON='{"rules":[],"listeners":[]}'
# 1 when deny_port_hits dynamic set is available for this apply.
PORT_HITS_ENABLED=0
@@ -371,6 +381,8 @@ apply_nft() {
nft add rule "$table" "$name" input ip saddr @deny_v4 counter drop
fi
nft add rule "$table" "$name" input ip saddr @allow_v4 counter accept
# Port ACL: close (drop) then open (accept), before default.
apply_nft_port_acl "$table" "$name"
if [[ "$DEFAULT_ACTION" == "drop" ]]; then
nft add rule "$table" "$name" input counter drop
else
@@ -380,6 +392,253 @@ apply_nft() {
APPLIED=$((${#deny_v4[@]} + ${#allow_v4[@]}))
}
# Apply desired L4 port open/close rules from PORT_RULES_FILE (apply_version 3).
apply_nft_port_acl() {
local table=$1 name=$2
[[ -f "$PORT_RULES_FILE" ]] || return 0
if ! command -v python3 >/dev/null 2>&1; then
log "nft port ACL skipped — need python3"
return 0
fi
# Delete prior per-rule src sets (name prefix port_src_)
local setline setname
while IFS= read -r setline; do
setname=$(echo "$setline" | sed -n 's/.*set \(port_src_[a-zA-Z0-9_-]*\).*/\1/p')
[[ -n "$setname" ]] || continue
nft delete set "$table" "$name" "$setname" 2>/dev/null || true
done < <(nft list table "$table" "$name" 2>/dev/null | grep -E 'set port_src_' || true)
local cmds_file
cmds_file=$(mktemp)
python3 - "$PORT_RULES_FILE" >"$cmds_file" <<'PY'
import json, sys, re
path = sys.argv[1]
try:
rules = json.load(open(path, encoding="utf-8"))
except Exception:
rules = []
safe_id = re.compile(r"[^a-zA-Z0-9_]")
for r in rules:
rid = safe_id.sub("_", str(r.get("id") or "x"))[:40]
action = r.get("action") or "open"
proto = r.get("protocol") or "tcp"
if proto not in ("tcp", "udp"):
continue
ps = int(r.get("port_start") or 0)
pe = int(r.get("port_end") or ps)
if ps < 1 or pe > 65535 or pe < ps:
continue
cidrs = [c for c in (r.get("src_cidrs") or []) if c and ":" not in c]
if not cidrs:
continue
verdict = "drop" if action == "close" else "accept"
dport = f"{ps}" if ps == pe else f"{ps}-{pe}"
comment = f"evofw-port-{rid}"
is_all = any(c in ("0.0.0.0/0", "0.0.0.0") for c in cidrs)
if is_all:
print(f'nft add rule inet evofw input {proto} dport {dport} counter {verdict} comment "{comment}"')
continue
setname = f"port_src_{rid}"
print(f"nft add set inet evofw {setname} '{{ type ipv4_addr; flags interval; }}'")
chunk = []
for c in cidrs:
chunk.append(c)
if len(chunk) >= 32:
joined = ", ".join(chunk)
print(f"nft add element inet evofw {setname} '{{ {joined} }}'")
chunk = []
if chunk:
joined = ", ".join(chunk)
print(f"nft add element inet evofw {setname} '{{ {joined} }}'")
print(
f'nft add rule inet evofw input ip saddr @{setname} {proto} dport {dport} counter {verdict} comment "{comment}"'
)
PY
local cmd
while IFS= read -r cmd; do
[[ -n "$cmd" ]] || continue
# shellcheck disable=SC2086
eval "$cmd" 2>>"$LOG_FILE" || log "nft port ACL cmd failed: $cmd"
done <"$cmds_file"
rm -f "$cmds_file"
}
# Collect observed host firewall rules + listeners (best-effort).
collect_host_firewall() {
HOST_FIREWALL_JSON='{"rules":[],"listeners":[]}'
if ! command -v python3 >/dev/null 2>&1; then
return 0
fi
local nft_txt="" ipt_txt="" ufw_txt="" fwd_txt="" ss_txt=""
nft_txt=$(nft list ruleset 2>/dev/null || true)
ipt_txt=$(iptables-save 2>/dev/null || true)
if command -v ufw >/dev/null 2>&1; then
ufw_txt=$(ufw status verbose 2>/dev/null || true)
fi
if command -v firewall-cmd >/dev/null 2>&1; then
fwd_txt=$(firewall-cmd --list-all 2>/dev/null || true)
fi
ss_txt=$(ss -lntu 2>/dev/null || true)
HOST_FIREWALL_JSON=$(NFT_TXT="$nft_txt" IPT_TXT="$ipt_txt" UFW_TXT="$ufw_txt" FWD_TXT="$fwd_txt" SS_TXT="$ss_txt" python3 - <<'PY'
import json, os, re
def ownership_of(text: str) -> str:
t = text.lower()
if "evofw" in t or "evofw-port-" in t:
return "evofw"
return "foreign"
rules = []
listeners = []
nft = os.environ.get("NFT_TXT") or ""
# Rough nft rule lines
cur_table = ""
cur_chain = ""
for line in nft.splitlines():
ls = line.strip()
if ls.startswith("table "):
cur_table = ls
cur_chain = ""
continue
m = re.match(r"chain\s+(\S+)", ls)
if m:
cur_chain = m.group(1)
continue
if not ls or ls.startswith("type ") or ls.startswith("policy ") or ls.startswith("set ") or ls.startswith("map "):
continue
if "accept" in ls or "drop" in ls or "reject" in ls or "jump " in ls or "goto " in ls:
act = "accept" if " accept" in f" {ls}" or ls.endswith("accept") else (
"drop" if " drop" in f" {ls}" or ls.endswith("drop") else (
"reject" if "reject" in ls else "other"
)
)
proto = ""
if " tcp " in f" {ls}" or ls.startswith("tcp "):
proto = "tcp"
elif " udp " in f" {ls}" or ls.startswith("udp "):
proto = "udp"
dport = ""
m = re.search(r"dport\s+(\S+)", ls)
if m:
dport = m.group(1)
saddr = ""
m = re.search(r"saddr\s+(\S+)", ls)
if m:
saddr = m.group(1).lstrip("@")
raw = ls[:500]
rules.append({
"ownership": ownership_of(cur_table + " " + cur_chain + " " + raw),
"backend": "nft",
"table": cur_table[:120],
"chain": cur_chain[:120],
"action": act,
"protocol": proto or None,
"dport": dport or None,
"saddr": saddr or None,
"raw": raw,
})
ipt = os.environ.get("IPT_TXT") or ""
cur_chain = ""
for line in ipt.splitlines():
if line.startswith(":"):
cur_chain = line[1:].split()[0] if line[1:] else ""
continue
if not line.startswith("-A "):
continue
parts = line.split(None, 2)
chain = parts[1] if len(parts) > 1 else ""
rest = parts[2] if len(parts) > 2 else line
act = "DROP" if " -j DROP" in line else (
"ACCEPT" if " -j ACCEPT" in line else (
"REJECT" if " -j REJECT" in line else "other"
)
)
proto = ""
m = re.search(r"-p\s+(\w+)", line)
if m:
proto = m.group(1)
dport = ""
m = re.search(r"--dport(?:s)?\s+(\S+)", line)
if m:
dport = m.group(1)
saddr = ""
m = re.search(r"-s\s+(\S+)", line)
if m:
saddr = m.group(1)
raw = line[:500]
rules.append({
"ownership": ownership_of(raw),
"backend": "iptables",
"chain": chain[:120],
"action": act.lower() if isinstance(act, str) else act,
"protocol": proto or None,
"dport": dport or None,
"saddr": saddr or None,
"raw": raw,
})
ufw = os.environ.get("UFW_TXT") or ""
for line in ufw.splitlines():
ls = line.strip()
if not ls or ls.startswith("Status") or ls.startswith("Logging") or ls.startswith("Default") or ls.startswith("To") or ls.startswith("--"):
continue
if "ALLOW" in ls or "DENY" in ls or "REJECT" in ls:
rules.append({
"ownership": ownership_of(ls),
"backend": "ufw",
"action": "allow" if "ALLOW" in ls else ("deny" if "DENY" in ls else "reject"),
"raw": ls[:500],
})
fwd = os.environ.get("FWD_TXT") or ""
for line in fwd.splitlines():
ls = line.strip()
if not ls:
continue
if ls.startswith("ports:") or ls.startswith("services:") or ":" in ls:
rules.append({
"ownership": "foreign",
"backend": "firewalld",
"raw": ls[:500],
})
ss = os.environ.get("SS_TXT") or ""
for line in ss.splitlines()[1:]:
parts = line.split()
if len(parts) < 5:
continue
proto = parts[0]
local = parts[4]
# *:22 or 0.0.0.0:22 or [::]:22
m = re.search(r"([^:]+):(\d+)$", local)
if not m:
# IPv6 [::]:port
m = re.search(r"\[([^\]]+)\]:(\d+)$", local)
if not m:
continue
addr, port_s = m.group(1), m.group(2)
else:
addr, port_s = m.group(1), m.group(2)
try:
port = int(port_s)
except ValueError:
continue
listeners.append({
"protocol": "tcp" if proto.startswith("tcp") else ("udp" if proto.startswith("udp") else proto),
"port": port,
"address": addr,
})
# Cap
rules = rules[:500]
listeners = listeners[:200]
print(json.dumps({"rules": rules, "listeners": listeners}, separators=(",", ":")))
PY
) || HOST_FIREWALL_JSON='{"rules":[],"listeners":[]}'
}
ensure_ipset_counters() {
local name=$1
if ! ipset list "$name" >/dev/null 2>&1; then
@@ -436,9 +695,33 @@ send_report() {
if [[ -z "${PORT_HITS_CAPTURED:-}" ]]; then
collect_port_hits
fi
if [[ -z "${HOST_FW_CAPTURED:-}" ]]; then
collect_host_firewall
fi
local report
report=$(printf '{"status":"ok","prefix_count":%s,"packets_dropped":%s,"packets_accepted":%s,"kernel_method":"%s","source":"agent","ip_hits":%s,"port_hits":%s}' \
"${APPLIED:-0}" "${PACKETS_DROPPED:-0}" "${PACKETS_ACCEPTED:-0}" "${KERNEL_METHOD:-$BACKEND}" "${IP_HITS_JSON:-[]}" "${PORT_HITS_JSON:-[]}")
# Compose report with python to safely embed host_firewall JSON
if command -v python3 >/dev/null 2>&1; then
report=$(APPLIED="${APPLIED:-0}" DROPPED="${PACKETS_DROPPED:-0}" ACCEPTED="${PACKETS_ACCEPTED:-0}" \
METHOD="${KERNEL_METHOD:-$BACKEND}" IP_HITS="${IP_HITS_JSON:-[]}" PORT_HITS="${PORT_HITS_JSON:-[]}" \
HOST_FW="${HOST_FIREWALL_JSON}" python3 - <<'PY'
import json, os
print(json.dumps({
"status": "ok",
"prefix_count": int(os.environ.get("APPLIED") or 0),
"packets_dropped": int(os.environ.get("DROPPED") or 0),
"packets_accepted": int(os.environ.get("ACCEPTED") or 0),
"kernel_method": os.environ.get("METHOD") or "auto",
"source": "agent",
"ip_hits": json.loads(os.environ.get("IP_HITS") or "[]"),
"port_hits": json.loads(os.environ.get("PORT_HITS") or "[]"),
"host_firewall": json.loads(os.environ.get("HOST_FW") or '{"rules":[],"listeners":[]}'),
}, separators=(",", ":")))
PY
)
else
report=$(printf '{"status":"ok","prefix_count":%s,"packets_dropped":%s,"packets_accepted":%s,"kernel_method":"%s","source":"agent","ip_hits":%s,"port_hits":%s}' \
"${APPLIED:-0}" "${PACKETS_DROPPED:-0}" "${PACKETS_ACCEPTED:-0}" "${KERNEL_METHOD:-$BACKEND}" "${IP_HITS_JSON:-[]}" "${PORT_HITS_JSON:-[]}")
fi
curl -fsS -X POST "${EVOFW_CP_URL%/}/v1/agent/apply-report" \
-H "Authorization: Bearer ${CLIENT_TOKEN}" \
-H "Content-Type: application/json" \
@@ -468,6 +751,8 @@ if [[ -f "$HASH_FILE" && "$(tr -d '\r\n' <"$HASH_FILE")" == "$HASH" && -n "$HASH
local_allow=$(ipset list evofw_allow_v4 2>/dev/null | awk '/^[0-9]/{c++} END{print c+0}')
APPLIED=$((${APPLIED:-0} + ${local_allow:-0}))
fi
collect_host_firewall
HOST_FW_CAPTURED=1
send_report
exit 0
fi
@@ -486,6 +771,8 @@ elif command -v ipset >/dev/null 2>&1 && ipset list evofw_deny_v4 >/dev/null 2>&
PORT_HITS_JSON="[]"
PORT_HITS_CAPTURED=1
fi
collect_host_firewall
HOST_FW_CAPTURED=1
case "$BACKEND" in
nft|auto)
+26
View File
@@ -1,4 +1,5 @@
import { readFileSync } from 'node:fs'
import { createHash } from 'node:crypto'
import { join } from 'node:path'
import type { FastifyPluginAsync } from 'fastify'
import { repos } from '@evofw/db'
@@ -154,6 +155,14 @@ export const agentRoutes: FastifyPluginAsync<{ config: AppConfig }> = async (
policy_mode: policy.policyMode,
deny_cidrs: policy.denyCidrs,
allow_cidrs: policy.allowCidrs,
port_rules: policy.portRules.map((r) => ({
id: r.id,
action: r.action,
protocol: r.protocol,
port_start: r.portStart,
port_end: r.portEnd,
src_cidrs: r.srcCidrs,
})),
sync_interval_sec: policy.syncIntervalSec,
// compat: prefixes = deny when default accept, else allow (legacy single-bag clients)
prefixes:
@@ -237,6 +246,23 @@ export const agentRoutes: FastifyPluginAsync<{ config: AppConfig }> = async (
if (body.port_hits?.length && body.source !== 'mikrotik') {
repos.upsertPortBlockStats(app.db, agentId, body.port_hits, now)
}
if (body.host_firewall && body.source !== 'mikrotik') {
const payloadJson = JSON.stringify({
rules: body.host_firewall.rules ?? [],
listeners: body.host_firewall.listeners ?? [],
})
const rawDigest = createHash('sha256')
.update(payloadJson)
.digest('hex')
.slice(0, 16)
repos.upsertHostFirewallSnapshot(
app.db,
agentId,
payloadJson,
now,
rawDigest,
)
}
return { ok: true }
})
+9
View File
@@ -73,6 +73,7 @@ export const agentsRoutes: FastifyPluginAsync<{ config: AppConfig }> = async (
cidrs_allow: policy.summary.cidrsAllow,
overrides: policy.summary.overrides,
conflicts_dropped: policy.summary.conflictsDropped,
port_rules: policy.summary.portRules,
},
chain: policy.chain.map((s) => ({
set_id: s.setId,
@@ -87,6 +88,14 @@ export const agentsRoutes: FastifyPluginAsync<{ config: AppConfig }> = async (
allow_cidrs: truncateCidrs(policy.allowCidrs, limit),
deny_cidrs_total: policy.denyCidrs.length,
allow_cidrs_total: policy.allowCidrs.length,
port_rules: policy.portRules.map((r) => ({
id: r.id,
action: r.action,
protocol: r.protocol,
port_start: r.portStart,
port_end: r.portEnd,
src_cidrs: r.srcCidrs,
})),
}
})
+2
View File
@@ -7,6 +7,7 @@ import { listsRoutes } from './lists.js'
import { policySetsRoutes } from './policy-sets.js'
import { rulesRoutes } from './rules.js'
import { statsRoutes } from './stats.js'
import { portAclRoutes } from './port-acl.js'
import { integrationsEvobgpRoutes } from './integrations-evobgp.js'
import { settingsRoutes } from './settings.js'
@@ -23,6 +24,7 @@ export const controlRoutes: FastifyPluginAsync<{ config: AppConfig }> = async (
await app.register(policySetsRoutes, { config })
await app.register(rulesRoutes, { config })
await app.register(statsRoutes, { config })
await app.register(portAclRoutes, { config })
await app.register(integrationsEvobgpRoutes)
await app.register(settingsRoutes, { config })
}
+364
View File
@@ -0,0 +1,364 @@
import type { FastifyPluginAsync } from 'fastify'
import { createHash } from 'node:crypto'
import { repos } from '@evofw/db'
import {
createAgentPortRuleBodySchema,
importAgentPortRulesBodySchema,
updateAgentPortRuleBodySchema,
} from '@evofw/shared'
import { AppError } from '../plugins/error-handler.js'
import type { AppConfig } from '../config.js'
import { auditMutation } from '../services/audit.js'
function mapPortRule(
row: NonNullable<ReturnType<typeof repos.getAgentPortRule>>,
listName?: string | null,
) {
return {
id: row.id,
agent_id: row.agentId,
action: row.action,
protocol: row.protocol,
port_start: row.portStart,
port_end: row.portEnd,
src_kind: row.srcKind,
src_cidr: row.srcCidr,
list_id: row.listId,
list_name: listName ?? null,
enabled: row.enabled === 1,
comment: row.comment,
priority: row.priority,
created_at: row.createdAt,
updated_at: row.updatedAt,
}
}
function validateSrc(
srcKind: string,
srcCidr: string | null | undefined,
listId: string | null | undefined,
db: Parameters<typeof repos.getIpList>[0],
) {
if (srcKind === 'cidr' && !srcCidr?.trim()) {
throw new AppError('VALIDATION_ERROR', 'src_cidr required', 400)
}
if (srcKind === 'list') {
if (!listId?.trim()) {
throw new AppError('VALIDATION_ERROR', 'list_id required', 400)
}
if (!repos.getIpList(db, listId)) {
throw new AppError('NOT_FOUND', 'IP list not found', 404)
}
}
}
export const portAclRoutes: FastifyPluginAsync<{ config: AppConfig }> = async (
app,
opts,
) => {
const { config } = opts
app.get<{ Params: { id: string } }>(
'/agents/:id/port-rules',
async (req) => {
const agent = repos.getAgent(app.db, req.params.id)
if (!agent) throw new AppError('NOT_FOUND', 'Agent not found', 404)
const items = repos.listAgentPortRules(app.db, agent.id).map((row) => {
const listName = row.listId
? repos.getIpList(app.db, row.listId)?.name
: null
return mapPortRule(row, listName)
})
return { items }
},
)
app.post<{ Params: { id: string } }>(
'/agents/:id/port-rules',
async (req) => {
const agent = repos.getAgent(app.db, req.params.id)
if (!agent) throw new AppError('NOT_FOUND', 'Agent not found', 404)
if (agent.platform !== 'linux') {
throw new AppError(
'VALIDATION_ERROR',
'Port ACL is only supported on Linux agents',
400,
)
}
const body = createAgentPortRuleBodySchema.parse(req.body)
const portEnd = body.port_end ?? body.port_start
const srcKind = body.src_kind
const srcCidr = srcKind === 'cidr' ? body.src_cidr!.trim() : null
const listId = srcKind === 'list' ? body.list_id! : null
validateSrc(srcKind, srcCidr, listId, app.db)
const now = new Date().toISOString()
const row = repos.insertAgentPortRule(app.db, {
id: crypto.randomUUID(),
agentId: agent.id,
action: body.action,
protocol: body.protocol,
portStart: body.port_start,
portEnd,
srcKind,
srcCidr,
listId,
enabled: body.enabled === false ? 0 : 1,
comment: body.comment ?? null,
priority: body.priority ?? 100,
createdAt: now,
updatedAt: now,
})
repos.bumpAgentGeneration(app.db, agent.id)
auditMutation(app, config, req, {
action: 'port_rule.create',
targetType: 'app_resource',
targetId: row!.id,
summary: `Port ACL ${body.action} ${body.protocol}/${body.port_start} для ${agent.name}`,
details: {
agent_id: agent.id,
rule_id: row!.id,
action: body.action,
protocol: body.protocol,
port_start: body.port_start,
port_end: portEnd,
},
})
const listName = row!.listId
? repos.getIpList(app.db, row!.listId)?.name
: null
return mapPortRule(row!, listName)
},
)
app.patch<{ Params: { id: string; ruleId: string } }>(
'/agents/:id/port-rules/:ruleId',
async (req) => {
const agent = repos.getAgent(app.db, req.params.id)
if (!agent) throw new AppError('NOT_FOUND', 'Agent not found', 404)
const existing = repos.getAgentPortRule(app.db, req.params.ruleId)
if (!existing || existing.agentId !== agent.id) {
throw new AppError('NOT_FOUND', 'Port rule not found', 404)
}
const body = updateAgentPortRuleBodySchema.parse(req.body)
const nextSrcKind = body.src_kind ?? existing.srcKind
const nextSrcCidr =
body.src_cidr !== undefined
? body.src_cidr
: existing.srcCidr
const nextListId =
body.list_id !== undefined ? body.list_id : existing.listId
validateSrc(nextSrcKind, nextSrcCidr, nextListId, app.db)
const portStart = body.port_start ?? existing.portStart
const portEnd = body.port_end ?? existing.portEnd
if (portEnd < portStart) {
throw new AppError(
'VALIDATION_ERROR',
'port_end must be >= port_start',
400,
)
}
const row = repos.updateAgentPortRule(app.db, existing.id, {
...(body.action !== undefined ? { action: body.action } : {}),
...(body.protocol !== undefined ? { protocol: body.protocol } : {}),
portStart,
portEnd,
srcKind: nextSrcKind,
srcCidr: nextSrcKind === 'cidr' ? nextSrcCidr : null,
listId: nextSrcKind === 'list' ? nextListId : null,
...(body.enabled !== undefined
? { enabled: body.enabled ? 1 : 0 }
: {}),
...(body.comment !== undefined ? { comment: body.comment } : {}),
...(body.priority !== undefined ? { priority: body.priority } : {}),
})
repos.bumpAgentGeneration(app.db, agent.id)
auditMutation(app, config, req, {
action: 'port_rule.update',
targetType: 'app_resource',
targetId: existing.id,
summary: `Port ACL обновлён у ${agent.name}`,
details: { agent_id: agent.id, rule_id: existing.id },
})
const listName = row!.listId
? repos.getIpList(app.db, row!.listId)?.name
: null
return mapPortRule(row!, listName)
},
)
app.delete<{ Params: { id: string; ruleId: string } }>(
'/agents/:id/port-rules/:ruleId',
async (req) => {
const agent = repos.getAgent(app.db, req.params.id)
if (!agent) throw new AppError('NOT_FOUND', 'Agent not found', 404)
const existing = repos.getAgentPortRule(app.db, req.params.ruleId)
if (!existing || existing.agentId !== agent.id) {
throw new AppError('NOT_FOUND', 'Port rule not found', 404)
}
repos.deleteAgentPortRule(app.db, existing.id)
repos.bumpAgentGeneration(app.db, agent.id)
auditMutation(app, config, req, {
action: 'port_rule.delete',
severity: 'warning',
targetType: 'app_resource',
targetId: existing.id,
summary: `Port ACL удалён у ${agent.name}`,
details: { agent_id: agent.id, rule_id: existing.id },
})
return { ok: true }
},
)
app.post<{ Params: { id: string } }>(
'/agents/:id/port-rules/import',
async (req) => {
const agent = repos.getAgent(app.db, req.params.id)
if (!agent) throw new AppError('NOT_FOUND', 'Agent not found', 404)
if (agent.platform !== 'linux') {
throw new AppError(
'VALIDATION_ERROR',
'Port ACL is only supported on Linux agents',
400,
)
}
const body = importAgentPortRulesBodySchema.parse(req.body)
const now = new Date().toISOString()
const created: ReturnType<typeof mapPortRule>[] = []
if (body.from === 'list') {
const list = repos.getIpList(app.db, body.list_id!)
if (!list) throw new AppError('NOT_FOUND', 'IP list not found', 404)
for (const p of body.ports) {
const portEnd = p.port_end ?? p.port_start
const row = repos.insertAgentPortRule(app.db, {
id: crypto.randomUUID(),
agentId: agent.id,
action: body.action,
protocol: body.protocol,
portStart: p.port_start,
portEnd,
srcKind: 'list',
srcCidr: null,
listId: list.id,
enabled: body.enabled === false ? 0 : 1,
comment: body.comment ?? `import list:${list.name}`,
priority: 100,
createdAt: now,
updatedAt: now,
})
created.push(mapPortRule(row!, list.name))
}
} else {
const set = repos.getPolicySet(app.db, body.set_id!)
if (!set) throw new AppError('NOT_FOUND', 'Policy set not found', 404)
const rules = repos.listPolicyRules(app.db, set.id)
const listIds = new Set<string>()
const cidrs = new Set<string>()
for (const r of rules) {
if (r.listId) listIds.add(r.listId)
if (r.cidr?.trim()) cidrs.add(r.cidr.trim())
}
if (!listIds.size && !cidrs.size) {
throw new AppError(
'VALIDATION_ERROR',
'Policy set has no list/cidr sources to import',
400,
)
}
for (const p of body.ports) {
const portEnd = p.port_end ?? p.port_start
for (const listId of listIds) {
const list = repos.getIpList(app.db, listId)
const row = repos.insertAgentPortRule(app.db, {
id: crypto.randomUUID(),
agentId: agent.id,
action: body.action,
protocol: body.protocol,
portStart: p.port_start,
portEnd,
srcKind: 'list',
srcCidr: null,
listId,
enabled: body.enabled === false ? 0 : 1,
comment:
body.comment ?? `import set:${set.name} list:${list?.name ?? listId}`,
priority: 100,
createdAt: now,
updatedAt: now,
})
created.push(mapPortRule(row!, list?.name ?? null))
}
for (const cidr of cidrs) {
const row = repos.insertAgentPortRule(app.db, {
id: crypto.randomUUID(),
agentId: agent.id,
action: body.action,
protocol: body.protocol,
portStart: p.port_start,
portEnd,
srcKind: 'cidr',
srcCidr: cidr,
listId: null,
enabled: body.enabled === false ? 0 : 1,
comment: body.comment ?? `import set:${set.name} cidr:${cidr}`,
priority: 100,
createdAt: now,
updatedAt: now,
})
created.push(mapPortRule(row!, null))
}
}
}
repos.bumpAgentGeneration(app.db, agent.id)
auditMutation(app, config, req, {
action: 'port_rule.import',
targetType: 'app_resource',
targetId: agent.id,
summary: `Импорт ${created.length} Port ACL для ${agent.name}`,
details: {
agent_id: agent.id,
from: body.from,
count: created.length,
},
})
return { items: created }
},
)
app.get<{ Params: { id: string } }>(
'/agents/:id/host-firewall',
async (req) => {
const agent = repos.getAgent(app.db, req.params.id)
if (!agent) throw new AppError('NOT_FOUND', 'Agent not found', 404)
const snap = repos.getHostFirewallSnapshot(app.db, agent.id)
if (!snap) {
return {
collected_at: null,
raw_digest: null,
rules: [],
listeners: [],
}
}
let payload: { rules?: unknown[]; listeners?: unknown[] } = {}
try {
payload = JSON.parse(snap.payloadJson) as typeof payload
} catch {
payload = {}
}
return {
collected_at: snap.collectedAt,
raw_digest: snap.rawDigest,
rules: payload.rules ?? [],
listeners: payload.listeners ?? [],
}
},
)
}
export function digestHostFirewallPayload(json: string): string {
return createHash('sha256').update(json).digest('hex').slice(0, 16)
}
+1 -1
View File
@@ -198,7 +198,7 @@ describe('install-links', () => {
expect(body.allow_cidrs).toEqual([])
expect(body.default_action).toBe('accept')
expect(body.policy_mode).toBe('blacklist')
expect(body.apply_version).toBe(2)
expect(body.apply_version).toBe(3)
expect(body.hash).toMatch(/^sha256:/)
const agents = await app.inject({ method: 'GET', url: '/api/v1/agents' })
+64 -1
View File
@@ -8,7 +8,7 @@ import {
} from '@evofw/shared'
import { uniqCidrs } from '../uniq.js'
export const POLICY_APPLY_VERSION = 2 as const
export const POLICY_APPLY_VERSION = 3 as const
export type PolicyChainStep = {
setId: string | null
@@ -20,6 +20,15 @@ export type PolicyChainStep = {
cidrCount: number
}
export type EvaluatedPortRule = {
id: string
action: 'open' | 'close'
protocol: 'tcp' | 'udp'
portStart: number
portEnd: number
srcCidrs: string[]
}
export type EvaluatedPolicy = {
generation: number
hash: string
@@ -29,6 +38,7 @@ export type EvaluatedPolicy = {
policyMode: 'blacklist' | 'whitelist'
denyCidrs: string[]
allowCidrs: string[]
portRules: EvaluatedPortRule[]
conflictsDropped: number
syncIntervalSec: number
chain: PolicyChainStep[]
@@ -40,6 +50,7 @@ export type EvaluatedPolicy = {
cidrsAllow: number
overrides: number
conflictsDropped: number
portRules: number
}
}
@@ -90,6 +101,54 @@ function sourceMeta(
return { kind: 'list', label: name || listId || 'list' }
}
function expandPortSrcCidrs(
db: Db,
row: {
srcKind: string
srcCidr: string | null
listId: string | null
},
): string[] {
if (row.srcKind === 'all') return ['0.0.0.0/0']
if (row.srcKind === 'cidr' && row.srcCidr?.trim()) {
return [row.srcCidr.trim()]
}
if (row.srcKind === 'list') {
const cidrs = expandList(db, row.listId)
return cidrs.length ? uniqCidrs(cidrs) : []
}
return []
}
function expandPortRules(db: Db, agentId: string): EvaluatedPortRule[] {
const rows = repos.listEnabledAgentPortRules(db, agentId)
const out: EvaluatedPortRule[] = []
for (const row of rows) {
const action = row.action === 'close' ? 'close' : 'open'
const srcCidrs = expandPortSrcCidrs(db, row)
if (!srcCidrs.length) continue
const portStart = Math.max(1, Math.min(65535, row.portStart))
const portEnd = Math.max(portStart, Math.min(65535, row.portEnd))
const protocols: Array<'tcp' | 'udp'> =
row.protocol === 'udp'
? ['udp']
: row.protocol === 'both'
? ['tcp', 'udp']
: ['tcp']
for (const protocol of protocols) {
out.push({
id: row.id,
action,
protocol,
portStart,
portEnd,
srcCidrs,
})
}
}
return out
}
/** Evaluate allow/deny sets for an agent from assigned policy sets. */
export function evaluateAgentPolicy(db: Db, agentId: string): EvaluatedPolicy {
const agent = repos.getAgent(db, agentId)
@@ -155,6 +214,7 @@ export function evaluateAgentPolicy(db: Db, agentId: string): EvaluatedPolicy {
const conflictsDropped = allowRaw.length - allowCidrs.length
const defaultAction = resolveDefaultAction(agent.defaultAction)
const policyMode = legacyModeFromDefaultAction(defaultAction)
const portRules = expandPortRules(db, agentId)
const payload = JSON.stringify({
apply_version: POLICY_APPLY_VERSION,
@@ -162,6 +222,7 @@ export function evaluateAgentPolicy(db: Db, agentId: string): EvaluatedPolicy {
defaultAction,
denyCidrs,
allowCidrs,
portRules,
})
const hash = `sha256:${createHash('sha256').update(payload).digest('hex')}`
@@ -176,6 +237,7 @@ export function evaluateAgentPolicy(db: Db, agentId: string): EvaluatedPolicy {
policyMode,
denyCidrs,
allowCidrs,
portRules,
conflictsDropped,
syncIntervalSec,
chain,
@@ -187,6 +249,7 @@ export function evaluateAgentPolicy(db: Db, agentId: string): EvaluatedPolicy {
cidrsAllow: allowCidrs.length,
overrides: overrides.length,
conflictsDropped,
portRules: portRules.length,
},
}
}
@@ -16,6 +16,7 @@ function basePolicy(
policyMode: 'blacklist',
denyCidrs: ['1.2.3.0/24', '2001:db8::/32', '10.0.0.1/32'],
allowCidrs: ['8.8.8.8/32', 'fe80::1/128'],
portRules: [],
conflictsDropped: 0,
syncIntervalSec: 60,
chain: [],
@@ -27,6 +28,7 @@ function basePolicy(
cidrsAllow: 1,
overrides: 0,
conflictsDropped: 0,
portRules: 0,
},
...patch,
}
@@ -36,7 +38,7 @@ describe('renderMikrotikPolicyRsc', () => {
it('renders accept default: lists + default-drop disabled', () => {
const rsc = renderMikrotikPolicyRsc(basePolicy())
expect(rsc).toContain(
'# evofw hash=sha256:abc default_action=accept apply_version=2 gen=3',
`# evofw hash=sha256:abc default_action=accept apply_version=${POLICY_APPLY_VERSION} gen=3`,
)
expect(rsc).toContain('list=EVOFW_DENY')
expect(rsc).toContain('list=EVOFW_ALLOW')
@@ -100,7 +100,7 @@ describe('classic policy default_action', () => {
chain: unknown[]
}
expect(body.default_action).toBe('drop')
expect(body.apply_version).toBe(2)
expect(body.apply_version).toBe(3)
expect(body.deny_cidrs).toContain('10.0.0.1/32')
expect(body.allow_cidrs).not.toContain('10.0.0.1/32')
expect(body.allow_cidrs).toContain('10.0.0.2/32')
+304
View File
@@ -0,0 +1,304 @@
import { describe, it, expect, afterAll } from 'vitest'
import { buildApp } from '../app.js'
import type { AppConfig } from '../config.js'
const testConfig: AppConfig = {
databaseUrl: 'sqlite::memory:',
jwtSecret: 'test',
jwtTtlHours: 24,
serverPort: 8080,
staticDir: null,
logLevel: 'error',
authRequired: false,
authIssuer: 'https://auth.test',
authPortalUrl: 'http://localhost:5175',
publicBaseUrl: 'https://fw.example.com',
enrollSeed: 'test-seed',
}
async function enrollApprovedLinux(
app: Awaited<ReturnType<typeof buildApp>>,
name: string,
token: string,
) {
const created = await app.inject({
method: 'POST',
url: '/api/v1/install-links',
payload: { name, platform: 'linux' },
})
expect(created.statusCode).toBe(201)
const link = created.json() as { id: string; agent_id: string }
const enroll = await app.inject({
method: 'POST',
url: '/v1/agent/enroll',
headers: {
'content-type': 'application/json',
'x-evofw-seed': 'test-seed',
},
payload: {
name,
platform: 'linux',
token,
install_link_id: link.id,
},
})
expect(enroll.statusCode).toBe(201)
await app.inject({
method: 'POST',
url: `/api/v1/agents/${link.agent_id}/approve`,
})
return { agentId: link.agent_id, token }
}
describe('port ACL + host firewall snapshot', () => {
const appPromise = buildApp({ memory: true, config: testConfig })
afterAll(async () => {
const app = await appPromise
await app.close()
})
it('CRUD port-rules bumps generation and expands in policy', async () => {
const app = await appPromise
await app.ready()
const { agentId, token } = await enrollApprovedLinux(
app,
'port-acl-01',
'evofw_port_acl_token_abcdefghij',
)
const before = await app.inject({
method: 'GET',
url: `/api/v1/agents/${agentId}`,
})
const genBefore = (before.json() as { policy_generation: number })
.policy_generation
const create = await app.inject({
method: 'POST',
url: `/api/v1/agents/${agentId}/port-rules`,
payload: {
action: 'open',
protocol: 'tcp',
port_start: 443,
src_kind: 'cidr',
src_cidr: '10.0.0.0/8',
},
})
expect(create.statusCode).toBe(200)
const rule = create.json() as { id: string; action: string; port_start: number }
expect(rule.action).toBe('open')
expect(rule.port_start).toBe(443)
const after = await app.inject({
method: 'GET',
url: `/api/v1/agents/${agentId}`,
})
expect(
(after.json() as { policy_generation: number }).policy_generation,
).toBe(genBefore + 1)
const list = await app.inject({
method: 'GET',
url: `/api/v1/agents/${agentId}/port-rules`,
})
expect(list.statusCode).toBe(200)
expect((list.json() as { items: unknown[] }).items).toHaveLength(1)
const policy = await app.inject({
method: 'GET',
url: '/v1/agent/policy',
headers: { authorization: `Bearer ${token}` },
})
expect(policy.statusCode).toBe(200)
const body = policy.json() as {
apply_version: number
port_rules: {
action: string
protocol: string
port_start: number
src_cidrs: string[]
}[]
}
expect(body.apply_version).toBe(3)
expect(body.port_rules).toHaveLength(1)
expect(body.port_rules[0]?.src_cidrs).toEqual(['10.0.0.0/8'])
expect(body.port_rules[0]?.protocol).toBe('tcp')
const patch = await app.inject({
method: 'PATCH',
url: `/api/v1/agents/${agentId}/port-rules/${rule.id}`,
payload: { enabled: false },
})
expect(patch.statusCode).toBe(200)
expect((patch.json() as { enabled: boolean }).enabled).toBe(false)
const policyOff = await app.inject({
method: 'GET',
url: '/v1/agent/policy',
headers: { authorization: `Bearer ${token}` },
})
expect(
(policyOff.json() as { port_rules: unknown[] }).port_rules,
).toHaveLength(0)
const del = await app.inject({
method: 'DELETE',
url: `/api/v1/agents/${agentId}/port-rules/${rule.id}`,
})
expect(del.statusCode).toBe(200)
})
it('imports port-rules from IP list', async () => {
const app = await appPromise
await app.ready()
const { agentId } = await enrollApprovedLinux(
app,
'port-acl-import',
'evofw_port_import_token_abcdefgh',
)
const listRes = await app.inject({
method: 'POST',
url: '/api/v1/lists',
payload: {
name: 'port-src-list',
type: 'static',
entries: ['203.0.113.0/24'],
},
})
expect(listRes.statusCode).toBe(200)
const listId = (listRes.json() as { id: string }).id
const before = await app.inject({
method: 'GET',
url: `/api/v1/agents/${agentId}`,
})
const genBefore = (before.json() as { policy_generation: number })
.policy_generation
const imp = await app.inject({
method: 'POST',
url: `/api/v1/agents/${agentId}/port-rules/import`,
payload: {
from: 'list',
list_id: listId,
action: 'close',
protocol: 'both',
ports: [{ port_start: 22 }, { port_start: 80, port_end: 81 }],
},
})
expect(imp.statusCode).toBe(200)
const items = (imp.json() as { items: { src_kind: string; list_id: string }[] })
.items
expect(items).toHaveLength(2)
expect(items.every((i) => i.src_kind === 'list' && i.list_id === listId)).toBe(
true,
)
const after = await app.inject({
method: 'GET',
url: `/api/v1/agents/${agentId}`,
})
expect(
(after.json() as { policy_generation: number }).policy_generation,
).toBe(genBefore + 1)
})
it('upserts host_firewall snapshot from apply-report', async () => {
const app = await appPromise
await app.ready()
const { agentId, token } = await enrollApprovedLinux(
app,
'host-fw-snap',
'evofw_host_fw_token_abcdefghij',
)
const report = await app.inject({
method: 'POST',
url: '/v1/agent/apply-report',
headers: {
authorization: `Bearer ${token}`,
'content-type': 'application/json',
},
payload: {
status: 'ok',
kernel_method: 'nft',
host_firewall: {
rules: [
{
ownership: 'evofw',
backend: 'nft',
table: 'evofw',
chain: 'input',
action: 'drop',
protocol: 'tcp',
dport: '22',
raw: 'tcp dport 22 drop comment "evofw-port-x"',
},
{
ownership: 'foreign',
backend: 'iptables',
chain: 'INPUT',
action: 'ACCEPT',
raw: '-A INPUT -p tcp --dport 80 -j ACCEPT',
},
],
listeners: [
{ protocol: 'tcp', port: 22, address: '0.0.0.0' },
],
},
},
})
expect(report.statusCode).toBe(200)
const snap = await app.inject({
method: 'GET',
url: `/api/v1/agents/${agentId}/host-firewall`,
})
expect(snap.statusCode).toBe(200)
const body = snap.json() as {
collected_at: string | null
rules: { ownership: string }[]
listeners: { port: number }[]
}
expect(body.collected_at).toBeTruthy()
expect(body.rules).toHaveLength(2)
expect(body.rules.some((r) => r.ownership === 'evofw')).toBe(true)
expect(body.listeners[0]?.port).toBe(22)
})
it('rejects port ACL on non-linux agents', async () => {
const app = await appPromise
await app.ready()
const created = await app.inject({
method: 'POST',
url: '/api/v1/install-links',
payload: { name: 'mt-no-acl', platform: 'mikrotik' },
})
const agentId = (created.json() as { agent_id: string }).agent_id
await app.inject({
method: 'POST',
url: `/api/v1/agents/${agentId}/approve`,
})
const create = await app.inject({
method: 'POST',
url: `/api/v1/agents/${agentId}/port-rules`,
payload: {
action: 'open',
protocol: 'tcp',
port_start: 443,
src_kind: 'all',
},
})
expect(create.statusCode).toBe(400)
})
})
@@ -35,6 +35,9 @@ import { AgentFactsPanel } from '@/components/agents/agent-facts-panel'
import { AgentEffectiveCidrs } from '@/components/agents/agent-effective-cidrs'
import { AgentBlockedIps } from '@/components/agents/agent-blocked-ips'
import { AgentBlockedPorts } from '@/components/agents/agent-blocked-ports'
import { AgentHostFirewall } from '@/components/agents/agent-host-firewall'
import { AgentPortAcl } from '@/components/agents/agent-port-acl'
import { CountedLineTabs } from '@/components/counted-line-tabs'
import {
AgentCloneSetsSheet,
AgentOverrideSheet,
@@ -47,6 +50,7 @@ import { apiFetch } from '@/lib/api'
import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard'
import { Button } from '@evofw/ui/components/button'
import { Skeleton } from '@evofw/ui/components/skeleton'
import { TabsContent } from '@evofw/ui/components/tabs'
import {
DropdownMenu,
DropdownMenuContent,
@@ -75,6 +79,7 @@ export function AgentDetailView({ agentId, onDelete }: AgentDetailViewProps) {
const installRef = useRef<HTMLDivElement>(null)
const [overrideOpen, setOverrideOpen] = useState(false)
const [cloneOpen, setCloneOpen] = useState(false)
const [fwTab, setFwTab] = useState('host')
const revoke = useMutation({
mutationFn: () =>
@@ -323,10 +328,37 @@ export function AgentDetailView({ agentId, onDelete }: AgentDetailViewProps) {
/>
{a.platform === 'linux' ? (
<AgentBlockedPorts agentId={agentId} />
) : null}
<AgentBlockedIps agentId={agentId} platform={a.platform} />
<div className="flex flex-col gap-3">
<CountedLineTabs
value={fwTab}
onValueChange={setFwTab}
tabs={[
{ id: 'host', label: 'Host firewall' },
{ id: 'acl', label: 'Port ACL' },
{ id: 'hits', label: 'Blocked' },
]}
>
<TabsContent value="host" className="mt-3">
<AgentHostFirewall agentId={agentId} />
</TabsContent>
<TabsContent value="acl" className="mt-3">
<AgentPortAcl agentId={agentId} />
</TabsContent>
<TabsContent
value="hits"
className="mt-3 flex flex-col gap-4"
>
<AgentBlockedPorts agentId={agentId} />
<AgentBlockedIps
agentId={agentId}
platform={a.platform}
/>
</TabsContent>
</CountedLineTabs>
</div>
) : (
<AgentBlockedIps agentId={agentId} platform={a.platform} />
)}
</div>
</DetailPanel.Section>
</DetailPanel>
@@ -0,0 +1,329 @@
import { useMemo, useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import {
getCoreRowModel,
useReactTable,
type ColumnDef,
} from '@tanstack/react-table'
import { ShieldIcon } from 'lucide-react'
import {
Frame,
FrameDescription,
FrameHeader,
FramePanel,
FrameTitle,
} from '@/components/reui/frame'
import { DataGrid } from '@/components/reui/data-grid/data-grid'
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
import { DataGridTable } from '@/components/reui/data-grid/data-grid-table'
import { Badge } from '@/components/reui/badge'
import { EmptyState } from '@/components/empty-state'
import { CountedLineTabs } from '@/components/counted-line-tabs'
import {
agentHostFirewallQueryOptions,
type HostFwRuleDto,
type HostListenerDto,
} from '@/queries'
import { Skeleton } from '@evofw/ui/components/skeleton'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@evofw/ui/components/select'
import { TabsContent } from '@evofw/ui/components/tabs'
/**
* Observed host firewall + listeners (Linux).
* Preview: https://reui.io/preview/base/data-grid-filtering-2
* · https://reui.io/preview/base/empty-state-12
*/
type AgentHostFirewallProps = {
agentId: string
}
export function AgentHostFirewall({ agentId }: AgentHostFirewallProps) {
const q = useQuery(agentHostFirewallQueryOptions(agentId))
const [tab, setTab] = useState('rules')
const [ownership, setOwnership] = useState<'all' | 'evofw' | 'foreign'>('all')
const [backend, setBackend] = useState<string>('all')
const rules = useMemo(() => {
let items = q.data?.rules ?? []
if (ownership !== 'all') {
items = items.filter((r) => r.ownership === ownership)
}
if (backend !== 'all') {
items = items.filter((r) => r.backend === backend)
}
return items
}, [q.data?.rules, ownership, backend])
const listeners = q.data?.listeners ?? []
const backends = useMemo(() => {
const s = new Set((q.data?.rules ?? []).map((r) => r.backend))
return Array.from(s).sort()
}, [q.data?.rules])
const ruleCols = useMemo<ColumnDef<HostFwRuleDto>[]>(
() => [
{
id: 'ownership',
accessorKey: 'ownership',
header: ({ column }) => (
<DataGridColumnHeader column={column} title="Owner" />
),
cell: ({ row }) =>
row.original.ownership === 'evofw' ? (
<Badge variant="success" size="sm">
EvoFW
</Badge>
) : (
<Badge variant="secondary" size="sm">
foreign
</Badge>
),
meta: { headerTitle: 'Owner' },
},
{
accessorKey: 'backend',
header: ({ column }) => (
<DataGridColumnHeader column={column} title="Backend" />
),
cell: ({ row }) => (
<span className="font-mono text-xs">{row.original.backend}</span>
),
},
{
id: 'chain',
accessorFn: (r) => r.chain || r.table || '—',
header: ({ column }) => (
<DataGridColumnHeader column={column} title="Chain" />
),
cell: ({ row }) => (
<span className="font-mono text-muted-foreground text-xs">
{row.original.chain || row.original.table || '—'}
</span>
),
},
{
accessorKey: 'action',
header: ({ column }) => (
<DataGridColumnHeader column={column} title="Action" />
),
cell: ({ row }) => (
<span className="text-xs">{row.original.action || '—'}</span>
),
},
{
id: 'ports',
accessorFn: (r) =>
[r.protocol, r.dport].filter(Boolean).join('/') || '—',
header: ({ column }) => (
<DataGridColumnHeader column={column} title="Proto/Port" />
),
cell: ({ row }) => (
<span className="font-mono text-xs tabular-nums">
{[row.original.protocol, row.original.dport]
.filter(Boolean)
.join('/') || '—'}
</span>
),
},
{
accessorKey: 'saddr',
header: ({ column }) => (
<DataGridColumnHeader column={column} title="Src" />
),
cell: ({ row }) => (
<span className="font-mono text-xs">
{row.original.saddr || '—'}
</span>
),
},
{
accessorKey: 'raw',
header: ({ column }) => (
<DataGridColumnHeader column={column} title="Raw" />
),
cell: ({ row }) => (
<span
className="text-muted-foreground block max-w-[280px] truncate font-mono text-[11px]"
title={row.original.raw}
>
{row.original.raw}
</span>
),
},
],
[],
)
const listenerCols = useMemo<ColumnDef<HostListenerDto>[]>(
() => [
{
accessorKey: 'protocol',
header: ({ column }) => (
<DataGridColumnHeader column={column} title="Proto" />
),
cell: ({ row }) => (
<span className="font-mono text-xs uppercase">
{row.original.protocol}
</span>
),
},
{
accessorKey: 'port',
header: ({ column }) => (
<DataGridColumnHeader column={column} title="Port" />
),
cell: ({ row }) => (
<span className="font-mono text-xs tabular-nums">
{row.original.port}
</span>
),
},
{
accessorKey: 'address',
header: ({ column }) => (
<DataGridColumnHeader column={column} title="Address" />
),
cell: ({ row }) => (
<span className="font-mono text-xs">{row.original.address}</span>
),
},
{
accessorKey: 'process',
header: ({ column }) => (
<DataGridColumnHeader column={column} title="Process" />
),
cell: ({ row }) => (
<span className="text-muted-foreground text-xs">
{row.original.process || '—'}
</span>
),
},
],
[],
)
const rulesTable = useReactTable({
data: rules,
columns: ruleCols,
getCoreRowModel: getCoreRowModel(),
getRowId: (r, i) => `${r.backend}-${r.chain}-${i}-${r.raw.slice(0, 40)}`,
})
const listenersTable = useReactTable({
data: listeners,
columns: listenerCols,
getCoreRowModel: getCoreRowModel(),
getRowId: (r, i) => `${r.protocol}-${r.address}-${r.port}-${i}`,
})
return (
<Frame dense spacing="sm">
<FrameHeader>
<FrameTitle>Host firewall</FrameTitle>
<FrameDescription>
Снимок nft/iptables/ufw/firewalld + listeners. EvoFW vs foreign.
{q.data?.collected_at
? ` Обновлено: ${new Date(q.data.collected_at).toLocaleString('ru-RU')}`
: ' Пока нет снимка — дождитесь sync агента.'}
</FrameDescription>
</FrameHeader>
<FramePanel className="flex flex-col gap-3 p-4">
<CountedLineTabs
value={tab}
onValueChange={setTab}
tabs={[
{ id: 'rules', label: 'Rules', count: rules.length },
{ id: 'listeners', label: 'Listeners', count: listeners.length },
]}
>
<TabsContent value="rules" className="mt-3 flex flex-col gap-3">
<div className="flex flex-wrap gap-2">
<Select
value={ownership}
onValueChange={(v) => {
if (v) setOwnership(v as typeof ownership)
}}
>
<SelectTrigger className="w-[140px]">
<SelectValue placeholder="Owner" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All owners</SelectItem>
<SelectItem value="evofw">EvoFW</SelectItem>
<SelectItem value="foreign">Foreign</SelectItem>
</SelectContent>
</Select>
<Select
value={backend}
onValueChange={(v) => {
if (v) setBackend(v)
}}
>
<SelectTrigger className="w-[140px]">
<SelectValue placeholder="Backend" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All backends</SelectItem>
{backends.map((b) => (
<SelectItem key={b} value={b}>
{b}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{q.isLoading ? (
<div className="flex flex-col gap-2">
<Skeleton className="h-8 w-full" />
<Skeleton className="h-8 w-full" />
</div>
) : rules.length === 0 ? (
<EmptyState
icon={ShieldIcon}
title="Нет правил в снимке"
description="После sync Linux-агент пришлёт host_firewall."
centered={false}
className="py-6"
/>
) : (
<DataGrid
table={rulesTable}
recordCount={rules.length}
tableLayout={{ dense: true }}
>
<DataGridTable />
</DataGrid>
)}
</TabsContent>
<TabsContent value="listeners" className="mt-3">
{q.isLoading ? (
<Skeleton className="h-8 w-full" />
) : listeners.length === 0 ? (
<EmptyState
icon={ShieldIcon}
title="Нет listeners"
description="ss -lntu не вернул сокеты или снимок пуст."
centered={false}
className="py-6"
/>
) : (
<DataGrid
table={listenersTable}
recordCount={listeners.length}
tableLayout={{ dense: true }}
>
<DataGridTable />
</DataGrid>
)}
</TabsContent>
</CountedLineTabs>
</FramePanel>
</Frame>
)
}
@@ -0,0 +1,697 @@
import { useMemo, useState } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import {
getCoreRowModel,
useReactTable,
type ColumnDef,
} from '@tanstack/react-table'
import { toast } from 'sonner'
import { NetworkIcon, PencilIcon, PlusIcon, Trash2Icon } from 'lucide-react'
import {
Frame,
FrameDescription,
FrameHeader,
FramePanel,
FrameTitle,
} from '@/components/reui/frame'
import { DataGrid } from '@/components/reui/data-grid/data-grid'
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
import { DataGridTable } from '@/components/reui/data-grid/data-grid-table'
import { Badge } from '@/components/reui/badge'
import { EmptyState } from '@/components/empty-state'
import {
agentPortRulesQueryOptions,
listsQueryOptions,
policySetsQueryOptions,
type AgentPortRuleDto,
} from '@/queries'
import { apiFetch } from '@/lib/api'
import { Button } from '@evofw/ui/components/button'
import { Switch } from '@evofw/ui/components/switch'
import { Skeleton } from '@evofw/ui/components/skeleton'
import { Field, FieldLabel } from '@evofw/ui/components/field'
import { Input } from '@evofw/ui/components/input'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@evofw/ui/components/select'
import {
Sheet,
SheetContent,
SheetDescription,
SheetFooter,
SheetHeader,
SheetTitle,
} from '@evofw/ui/components/sheet'
import { ScrollArea } from '@evofw/ui/components/scroll-area'
/**
* Desired Port ACL for Linux agent.
* Preview: https://reui.io/preview/base/data-grid-filtering-2
* · https://reui.io/preview/base/sheet-8
*/
type AgentPortAclProps = {
agentId: string
}
type FormState = {
action: 'open' | 'close'
protocol: 'tcp' | 'udp' | 'both'
port_start: string
port_end: string
src_kind: 'all' | 'cidr' | 'list'
src_cidr: string
list_id: string
comment: string
enabled: boolean
}
const emptyForm = (): FormState => ({
action: 'open',
protocol: 'tcp',
port_start: '',
port_end: '',
src_kind: 'all',
src_cidr: '',
list_id: '',
comment: '',
enabled: true,
})
function formatPorts(r: AgentPortRuleDto): string {
return r.port_start === r.port_end
? String(r.port_start)
: `${r.port_start}-${r.port_end}`
}
function formatSrc(r: AgentPortRuleDto): string {
if (r.src_kind === 'all') return 'all'
if (r.src_kind === 'cidr') return r.src_cidr || '—'
return r.list_name || r.list_id || 'list'
}
export function AgentPortAcl({ agentId }: AgentPortAclProps) {
const qc = useQueryClient()
const q = useQuery(agentPortRulesQueryOptions(agentId))
const listsQ = useQuery(listsQueryOptions())
const setsQ = useQuery(policySetsQueryOptions())
const [formOpen, setFormOpen] = useState(false)
const [editing, setEditing] = useState<AgentPortRuleDto | null>(null)
const [form, setForm] = useState<FormState>(emptyForm)
const [importOpen, setImportOpen] = useState(false)
const [impFrom, setImpFrom] = useState<'list' | 'set'>('list')
const [impListId, setImpListId] = useState('')
const [impSetId, setImpSetId] = useState('')
const [impAction, setImpAction] = useState<'open' | 'close'>('open')
const [impProtocol, setImpProtocol] = useState<'tcp' | 'udp' | 'both'>('tcp')
const [impPorts, setImpPorts] = useState('22,80,443')
const invalidate = () => {
void qc.invalidateQueries({ queryKey: ['agents', agentId, 'port-rules'] })
void qc.invalidateQueries({ queryKey: ['agents', agentId] })
void qc.invalidateQueries({ queryKey: ['agents', agentId, 'preview'] })
}
const save = useMutation({
mutationFn: async () => {
const portStart = Number(form.port_start)
const portEnd = form.port_end ? Number(form.port_end) : portStart
const body = {
action: form.action,
protocol: form.protocol,
port_start: portStart,
port_end: portEnd,
src_kind: form.src_kind,
src_cidr: form.src_kind === 'cidr' ? form.src_cidr : undefined,
list_id: form.src_kind === 'list' ? form.list_id : undefined,
enabled: form.enabled,
comment: form.comment || undefined,
}
if (editing) {
return apiFetch(`/api/v1/agents/${agentId}/port-rules/${editing.id}`, {
method: 'PATCH',
body: JSON.stringify(body),
})
}
return apiFetch(`/api/v1/agents/${agentId}/port-rules`, {
method: 'POST',
body: JSON.stringify(body),
})
},
onSuccess: () => {
toast.success(editing ? 'Правило обновлено' : 'Правило создано')
setFormOpen(false)
setEditing(null)
setForm(emptyForm())
invalidate()
},
onError: (e: Error) => toast.error(e.message),
})
const toggle = useMutation({
mutationFn: (row: AgentPortRuleDto) =>
apiFetch(`/api/v1/agents/${agentId}/port-rules/${row.id}`, {
method: 'PATCH',
body: JSON.stringify({ enabled: !row.enabled }),
}),
onSuccess: () => {
toast.success('Состояние обновлено')
invalidate()
},
onError: (e: Error) => toast.error(e.message),
})
const remove = useMutation({
mutationFn: (id: string) =>
apiFetch(`/api/v1/agents/${agentId}/port-rules/${id}`, {
method: 'DELETE',
}),
onSuccess: () => {
toast.success('Правило удалено')
invalidate()
},
onError: (e: Error) => toast.error(e.message),
})
const doImport = useMutation({
mutationFn: async () => {
const ports = impPorts
.split(/[,\s]+/)
.map((s) => s.trim())
.filter(Boolean)
.map((s) => {
if (s.includes('-')) {
const [a, b] = s.split('-')
return {
port_start: Number(a),
port_end: Number(b),
}
}
return { port_start: Number(s) }
})
return apiFetch(`/api/v1/agents/${agentId}/port-rules/import`, {
method: 'POST',
body: JSON.stringify({
from: impFrom,
list_id: impFrom === 'list' ? impListId : undefined,
set_id: impFrom === 'set' ? impSetId : undefined,
action: impAction,
protocol: impProtocol,
ports,
}),
})
},
onSuccess: () => {
toast.success('Импорт выполнен')
setImportOpen(false)
invalidate()
},
onError: (e: Error) => toast.error(e.message),
})
const openCreate = () => {
setEditing(null)
setForm(emptyForm())
setFormOpen(true)
}
const openEdit = (row: AgentPortRuleDto) => {
setEditing(row)
setForm({
action: row.action,
protocol: row.protocol,
port_start: String(row.port_start),
port_end:
row.port_end !== row.port_start ? String(row.port_end) : '',
src_kind: row.src_kind,
src_cidr: row.src_cidr || '',
list_id: row.list_id || '',
comment: row.comment || '',
enabled: row.enabled,
})
setFormOpen(true)
}
const columns = useMemo<ColumnDef<AgentPortRuleDto>[]>(
() => [
{
accessorKey: 'action',
header: ({ column }) => (
<DataGridColumnHeader column={column} title="Action" />
),
cell: ({ row }) =>
row.original.action === 'open' ? (
<Badge variant="success" size="sm">
open
</Badge>
) : (
<Badge variant="destructive" size="sm">
close
</Badge>
),
},
{
accessorKey: 'protocol',
header: ({ column }) => (
<DataGridColumnHeader column={column} title="Proto" />
),
cell: ({ row }) => (
<span className="font-mono text-xs uppercase">
{row.original.protocol}
</span>
),
},
{
id: 'ports',
accessorFn: formatPorts,
header: ({ column }) => (
<DataGridColumnHeader column={column} title="Ports" />
),
cell: ({ row }) => (
<span className="font-mono text-xs tabular-nums">
{formatPorts(row.original)}
</span>
),
},
{
id: 'src',
accessorFn: formatSrc,
header: ({ column }) => (
<DataGridColumnHeader column={column} title="Src" />
),
cell: ({ row }) => (
<span className="font-mono text-xs">{formatSrc(row.original)}</span>
),
},
{
id: 'enabled',
header: ({ column }) => (
<DataGridColumnHeader column={column} title="On" />
),
cell: ({ row }) => (
<Switch
checked={row.original.enabled}
onCheckedChange={() => toggle.mutate(row.original)}
aria-label="toggle enabled"
/>
),
},
{
id: 'actions',
header: () => <span className="sr-only">Actions</span>,
cell: ({ row }) => (
<div className="flex items-center gap-1">
<Button
type="button"
size="icon"
variant="ghost"
className="size-7"
onClick={() => openEdit(row.original)}
aria-label="Edit"
>
<PencilIcon className="size-3.5" />
</Button>
<Button
type="button"
size="icon"
variant="ghost"
className="size-7"
onClick={() => remove.mutate(row.original.id)}
aria-label="Delete"
>
<Trash2Icon className="size-3.5" />
</Button>
</div>
),
},
],
[toggle, remove],
)
const data = q.data?.items ?? []
const table = useReactTable({
data,
columns,
getCoreRowModel: getCoreRowModel(),
getRowId: (r) => r.id,
})
const lists = listsQ.data?.items ?? []
const sets = setsQ.data?.items ?? []
return (
<>
<Frame dense spacing="sm">
<FrameHeader>
<div className="flex flex-wrap items-start justify-between gap-2">
<div className="flex flex-col gap-px">
<FrameTitle>Port ACL</FrameTitle>
<FrameDescription>
Open/close портов для all / CIDR / IP-list. Apply через nft
(upgrade install-ссылкой).
</FrameDescription>
</div>
<div className="flex shrink-0 flex-wrap gap-2">
<Button
type="button"
size="sm"
variant="outline"
onClick={() => setImportOpen(true)}
>
Импорт
</Button>
<Button type="button" size="sm" onClick={openCreate}>
<PlusIcon className="size-4" />
Добавить
</Button>
</div>
</div>
</FrameHeader>
<FramePanel className="p-0">
{q.isLoading ? (
<div className="flex flex-col gap-2 p-4">
<Skeleton className="h-8 w-full" />
<Skeleton className="h-8 w-full" />
</div>
) : data.length === 0 ? (
<EmptyState
icon={NetworkIcon}
title="Нет Port ACL"
description="Добавьте open/close или импортируйте источники из списка/набора."
centered={false}
className="py-8"
action={
<Button type="button" size="sm" onClick={openCreate}>
Добавить
</Button>
}
/>
) : (
<DataGrid
table={table}
recordCount={data.length}
tableLayout={{ dense: true }}
>
<DataGridTable />
</DataGrid>
)}
</FramePanel>
</Frame>
<Sheet open={formOpen} onOpenChange={setFormOpen}>
<SheetContent className="flex flex-col gap-0 overflow-hidden sm:max-w-md">
<SheetHeader className="shrink-0">
<SheetTitle>
{editing ? 'Редактировать Port ACL' : 'Новое Port ACL'}
</SheetTitle>
<SheetDescription>
Preview: https://reui.io/preview/base/sheet-8
</SheetDescription>
</SheetHeader>
<ScrollArea className="flex-1 px-4">
<div className="flex flex-col gap-3 py-2 pb-4">
<Field>
<FieldLabel>Action</FieldLabel>
<Select
value={form.action}
onValueChange={(v) =>
v && setForm((f) => ({ ...f, action: v as 'open' | 'close' }))
}
>
<SelectTrigger className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="open">open</SelectItem>
<SelectItem value="close">close</SelectItem>
</SelectContent>
</Select>
</Field>
<Field>
<FieldLabel>Protocol</FieldLabel>
<Select
value={form.protocol}
onValueChange={(v) =>
v &&
setForm((f) => ({
...f,
protocol: v as FormState['protocol'],
}))
}
>
<SelectTrigger className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="tcp">tcp</SelectItem>
<SelectItem value="udp">udp</SelectItem>
<SelectItem value="both">both</SelectItem>
</SelectContent>
</Select>
</Field>
<div className="grid grid-cols-2 gap-2">
<Field>
<FieldLabel>Port start</FieldLabel>
<Input
value={form.port_start}
onChange={(e) =>
setForm((f) => ({ ...f, port_start: e.target.value }))
}
inputMode="numeric"
placeholder="443"
/>
</Field>
<Field>
<FieldLabel>Port end</FieldLabel>
<Input
value={form.port_end}
onChange={(e) =>
setForm((f) => ({ ...f, port_end: e.target.value }))
}
inputMode="numeric"
placeholder="optional"
/>
</Field>
</div>
<Field>
<FieldLabel>Source</FieldLabel>
<Select
value={form.src_kind}
onValueChange={(v) =>
v &&
setForm((f) => ({
...f,
src_kind: v as FormState['src_kind'],
}))
}
>
<SelectTrigger className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">all (0.0.0.0/0)</SelectItem>
<SelectItem value="cidr">CIDR / IP</SelectItem>
<SelectItem value="list">IP list</SelectItem>
</SelectContent>
</Select>
</Field>
{form.src_kind === 'cidr' ? (
<Field>
<FieldLabel>CIDR</FieldLabel>
<Input
value={form.src_cidr}
onChange={(e) =>
setForm((f) => ({ ...f, src_cidr: e.target.value }))
}
placeholder="10.0.0.0/8"
/>
</Field>
) : null}
{form.src_kind === 'list' ? (
<Field>
<FieldLabel>List</FieldLabel>
<Select
value={form.list_id}
onValueChange={(v) =>
v && setForm((f) => ({ ...f, list_id: v }))
}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Выберите список" />
</SelectTrigger>
<SelectContent>
{lists.map((l) => (
<SelectItem key={l.id} value={l.id}>
{l.name}
</SelectItem>
))}
</SelectContent>
</Select>
</Field>
) : null}
<Field>
<FieldLabel>Comment</FieldLabel>
<Input
value={form.comment}
onChange={(e) =>
setForm((f) => ({ ...f, comment: e.target.value }))
}
/>
</Field>
</div>
</ScrollArea>
<SheetFooter className="shrink-0 flex-row gap-2 border-t">
<Button
type="button"
variant="outline"
onClick={() => setFormOpen(false)}
>
Отмена
</Button>
<Button
type="button"
disabled={save.isPending || !form.port_start}
onClick={() => save.mutate()}
>
Сохранить
</Button>
</SheetFooter>
</SheetContent>
</Sheet>
<Sheet open={importOpen} onOpenChange={setImportOpen}>
<SheetContent className="flex flex-col gap-0 overflow-hidden sm:max-w-md">
<SheetHeader className="shrink-0">
<SheetTitle>Импорт Port ACL</SheetTitle>
<SheetDescription>
Источник CIDR/list из набора или IP-list + порты.
</SheetDescription>
</SheetHeader>
<ScrollArea className="flex-1 px-4">
<div className="flex flex-col gap-3 py-2 pb-4">
<Field>
<FieldLabel>From</FieldLabel>
<Select
value={impFrom}
onValueChange={(v) =>
v && setImpFrom(v as 'list' | 'set')
}
>
<SelectTrigger className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="list">IP list</SelectItem>
<SelectItem value="set">Policy set</SelectItem>
</SelectContent>
</Select>
</Field>
{impFrom === 'list' ? (
<Field>
<FieldLabel>List</FieldLabel>
<Select
value={impListId}
onValueChange={(v) => v && setImpListId(v)}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Список" />
</SelectTrigger>
<SelectContent>
{lists.map((l) => (
<SelectItem key={l.id} value={l.id}>
{l.name}
</SelectItem>
))}
</SelectContent>
</Select>
</Field>
) : (
<Field>
<FieldLabel>Set</FieldLabel>
<Select
value={impSetId}
onValueChange={(v) => v && setImpSetId(v)}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Набор" />
</SelectTrigger>
<SelectContent>
{sets.map((s) => (
<SelectItem key={s.id} value={s.id}>
{s.name}
</SelectItem>
))}
</SelectContent>
</Select>
</Field>
)}
<Field>
<FieldLabel>Action</FieldLabel>
<Select
value={impAction}
onValueChange={(v) =>
v && setImpAction(v as 'open' | 'close')
}
>
<SelectTrigger className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="open">open</SelectItem>
<SelectItem value="close">close</SelectItem>
</SelectContent>
</Select>
</Field>
<Field>
<FieldLabel>Protocol</FieldLabel>
<Select
value={impProtocol}
onValueChange={(v) =>
v && setImpProtocol(v as typeof impProtocol)
}
>
<SelectTrigger className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="tcp">tcp</SelectItem>
<SelectItem value="udp">udp</SelectItem>
<SelectItem value="both">both</SelectItem>
</SelectContent>
</Select>
</Field>
<Field>
<FieldLabel>Ports</FieldLabel>
<Input
value={impPorts}
onChange={(e) => setImpPorts(e.target.value)}
placeholder="22,80,443 или 8000-8010"
/>
</Field>
</div>
</ScrollArea>
<SheetFooter className="shrink-0 flex-row gap-2 border-t">
<Button
type="button"
variant="outline"
onClick={() => setImportOpen(false)}
>
Отмена
</Button>
<Button
type="button"
disabled={doImport.isPending}
onClick={() => doImport.mutate()}
>
Импортировать
</Button>
</SheetFooter>
</SheetContent>
</Sheet>
</>
)
}
+62
View File
@@ -198,6 +198,68 @@ export const agentBlockedPortsQueryOptions = (id: string) =>
}>(`/api/v1/agents/${id}/blocked-ports`),
})
export type AgentPortRuleDto = {
id: string
agent_id: string
action: 'open' | 'close'
protocol: 'tcp' | 'udp' | 'both'
port_start: number
port_end: number
src_kind: 'all' | 'cidr' | 'list'
src_cidr?: string | null
list_id?: string | null
list_name?: string | null
enabled: boolean
comment?: string | null
priority: number
created_at: string
updated_at: string
}
export const agentPortRulesQueryOptions = (id: string) =>
queryOptions({
queryKey: ['agents', id, 'port-rules'],
queryFn: () =>
apiFetch<{ items: AgentPortRuleDto[] }>(
`/api/v1/agents/${id}/port-rules`,
),
})
export type HostFwRuleDto = {
ownership: 'evofw' | 'foreign'
backend: string
table?: string
chain?: string
action?: string
protocol?: string
dport?: string
sport?: string
saddr?: string
daddr?: string
comment?: string
raw: string
}
export type HostListenerDto = {
protocol: string
port: number
address: string
process?: string
}
export const agentHostFirewallQueryOptions = (id: string) =>
queryOptions({
queryKey: ['agents', id, 'host-firewall'],
queryFn: () =>
apiFetch<{
collected_at: string | null
raw_digest: string | null
rules: HostFwRuleDto[]
listeners: HostListenerDto[]
}>(`/api/v1/agents/${id}/host-firewall`),
refetchInterval: 60_000,
})
export const recentStatsQueryOptions = () =>
queryOptions({
queryKey: ['stats-recent'],
+25
View File
@@ -81,6 +81,31 @@ IPv6 skipped.
IPv6 skipped.
## Host firewall snapshot + Port ACL (Linux)
### Observed (Host firewall)
Каждый sync агент собирает best-effort снимок и шлёт в `apply-report.host_firewall`:
- `nft list ruleset`, `iptables-save`, optional `ufw` / `firewall-cmd`, `ss -lntu`
- Каждое правило: `ownership: evofw | foreign` (метка по имени table/chain/comment `evofw`)
- CP: `agent_host_firewall_snapshots`; `GET /api/v1/agents/:id/host-firewall`
- UI agent detail → tab **Host firewall** (Rules / Listeners)
Foreign правила **только отображаются** — с CP не редактируются.
### Desired (Port ACL)
Per-agent таблица `agent_port_rules`: `open|close`, `tcp|udp|both`, port range, `src_kind: all|cidr|list`.
- API: CRUD `/api/v1/agents/:id/port-rules`, import `/port-rules/import` (from list или policy set sources)
- Policy `apply_version: 3``port_rules[]` с expanded `src_cidrs`
- nft apply: после L3 allow — close drop, затем open accept (`comment "evofw-port-<id>"`)
- UI: tab **Port ACL** (DataGrid + Sheet create/edit + Import)
- ipset / MikroTik: без L4 apply; секции скрыты для non-linux
Мутация Port ACL бампит `policy_generation` → agent re-apply.
## MikroTik (RouterOS 7.21+)
В UI `/agents`**Добавить агента** → platform **MikroTik**. Скопируйте one-liner:
+4 -3
View File
@@ -17,8 +17,8 @@
1. **Enroll**`POST /v1/agent/enroll` + `X-EvoFW-Seed` → pending agent
2. **Approve** — UI/API → status approved
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
3. **Policy**`GET /v1/agent/policy` → deny/allow CIDRs + `default_action` + optional `port_rules` + hash (`apply_version: 3`)
4. **Apply** — agent пишет kernel rules (L3 + L4 port ACL на nft), `POST /v1/agent/apply-report` + stats + optional `host_firewall` snapshot
5. **Lists refresh** — cron каждые 5 мин (json_url / domains / evobgp_community)
## Политика
@@ -27,8 +27,9 @@
- Правило в наборе: `action: deny | allow` + ровно один источник — IP-список (`list_id`), CIDR или DNS-имя (`hostname` → A/AAAA, кэш в `policy_rule_resolved`)
- Evaluate: правила всех назначенных enabled-наборов (sort + priority) + `ip_overrides`
- Цепочка ядра **всегда**: deny → allow → `default_action` (`accept` | `drop` на агенте)
- На Linux nft: после allow — **Port ACL** (`close` drop, затем `open` accept) из `agent_port_rules`
- Exact overlap: `allow \ deny` (`conflicts_dropped`); deny wins
- Overrides, смена наборов, `default_action` и refresh DNS/lists бампят `policy_generation`
- Overrides, смена наборов, `default_action`, Port ACL и refresh DNS/lists бампят `policy_generation`
## Auth
+257 -1
View File
@@ -457,6 +457,161 @@ paths:
packets: { type: integer }
last_seen_at: { type: string, format: date-time }
/api/v1/agents/{id}/port-rules:
get:
summary: Desired Port ACL rules (Linux)
tags: [ops]
security: [{ bearerAuth: [] }]
parameters:
- $ref: '#/components/parameters/Id'
responses:
'200':
description: Port ACL rules
content:
application/json:
schema:
type: object
properties:
items:
type: array
items:
$ref: '#/components/schemas/AgentPortRule'
post:
summary: Create Port ACL rule
tags: [ops]
security: [{ bearerAuth: [] }]
parameters:
- $ref: '#/components/parameters/Id'
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/CreateAgentPortRule'
responses:
'200':
description: Created rule
content:
application/json:
schema:
$ref: '#/components/schemas/AgentPortRule'
'400':
description: Validation / non-linux
/api/v1/agents/{id}/port-rules/import:
post:
summary: Import Port ACL from IP list or policy set sources
tags: [ops]
security: [{ bearerAuth: [] }]
parameters:
- $ref: '#/components/parameters/Id'
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [from, action, ports]
properties:
from: { type: string, enum: [list, set] }
list_id: { type: string }
set_id: { type: string }
action: { type: string, enum: [open, close] }
protocol: { type: string, enum: [tcp, udp, both], default: tcp }
ports:
type: array
minItems: 1
maxItems: 50
items:
type: object
required: [port_start]
properties:
port_start: { type: integer, minimum: 1, maximum: 65535 }
port_end: { type: integer, minimum: 1, maximum: 65535 }
enabled: { type: boolean, default: true }
comment: { type: string, maxLength: 500 }
responses:
'200':
description: Created rules
content:
application/json:
schema:
type: object
properties:
items:
type: array
items:
$ref: '#/components/schemas/AgentPortRule'
/api/v1/agents/{id}/port-rules/{ruleId}:
patch:
summary: Update Port ACL rule
tags: [ops]
security: [{ bearerAuth: [] }]
parameters:
- $ref: '#/components/parameters/Id'
- name: ruleId
in: path
required: true
schema: { type: string }
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/UpdateAgentPortRule'
responses:
'200':
description: Updated rule
content:
application/json:
schema:
$ref: '#/components/schemas/AgentPortRule'
delete:
summary: Delete Port ACL rule
tags: [ops]
security: [{ bearerAuth: [] }]
parameters:
- $ref: '#/components/parameters/Id'
- name: ruleId
in: path
required: true
schema: { type: string }
responses:
'200':
description: Deleted
/api/v1/agents/{id}/host-firewall:
get:
summary: Last observed host firewall snapshot (Linux)
tags: [ops]
security: [{ bearerAuth: [] }]
parameters:
- $ref: '#/components/parameters/Id'
responses:
'200':
description: Snapshot (empty if never reported)
content:
application/json:
schema:
type: object
properties:
collected_at:
type: string
format: date-time
nullable: true
raw_digest:
type: string
nullable: true
rules:
type: array
items:
$ref: '#/components/schemas/HostFwRule'
listeners:
type: array
items:
$ref: '#/components/schemas/HostListener'
/api/v1/integrations/evobgp/communities:
get:
summary: Proxy EvoBGP communities
@@ -557,7 +712,7 @@ paths:
/v1/agent/apply-report:
post:
summary: Apply report + packet stats (+ optional ip_hits / port_hits)
summary: Apply report + packet stats (+ optional ip_hits / port_hits / host_firewall)
tags: [agent]
security: [{ agentToken: [] }]
requestBody:
@@ -597,6 +752,8 @@ paths:
port: { type: integer, minimum: 1, maximum: 65535 }
protocol: { type: string, enum: [tcp, udp] }
packets: { type: integer, minimum: 0 }
host_firewall:
$ref: '#/components/schemas/HostFirewallPayload'
responses:
'200':
description: OK
@@ -608,6 +765,105 @@ components:
in: path
required: true
schema: { type: string }
schemas:
AgentPortRule:
type: object
required:
[
id,
agent_id,
action,
protocol,
port_start,
port_end,
src_kind,
enabled,
priority,
created_at,
updated_at,
]
properties:
id: { type: string }
agent_id: { type: string }
action: { type: string, enum: [open, close] }
protocol: { type: string, enum: [tcp, udp, both] }
port_start: { type: integer, minimum: 1, maximum: 65535 }
port_end: { type: integer, minimum: 1, maximum: 65535 }
src_kind: { type: string, enum: [all, cidr, list] }
src_cidr: { type: string, nullable: true }
list_id: { type: string, nullable: true }
list_name: { type: string, nullable: true }
enabled: { type: boolean }
comment: { type: string, nullable: true }
priority: { type: integer }
created_at: { type: string, format: date-time }
updated_at: { type: string, format: date-time }
CreateAgentPortRule:
type: object
required: [action, port_start]
properties:
action: { type: string, enum: [open, close] }
protocol: { type: string, enum: [tcp, udp, both], default: tcp }
port_start: { type: integer, minimum: 1, maximum: 65535 }
port_end: { type: integer, minimum: 1, maximum: 65535 }
src_kind: { type: string, enum: [all, cidr, list], default: all }
src_cidr: { type: string }
list_id: { type: string }
enabled: { type: boolean, default: true }
comment: { type: string, maxLength: 500 }
priority: { type: integer, default: 100 }
UpdateAgentPortRule:
type: object
properties:
action: { type: string, enum: [open, close] }
protocol: { type: string, enum: [tcp, udp, both] }
port_start: { type: integer, minimum: 1, maximum: 65535 }
port_end: { type: integer, minimum: 1, maximum: 65535 }
src_kind: { type: string, enum: [all, cidr, list] }
src_cidr: { type: string, nullable: true }
list_id: { type: string, nullable: true }
enabled: { type: boolean }
comment: { type: string, maxLength: 500, nullable: true }
priority: { type: integer }
HostFwRule:
type: object
required: [ownership, backend, raw]
properties:
ownership: { type: string, enum: [evofw, foreign] }
backend:
type: string
enum: [nft, iptables, ufw, firewalld, listener]
table: { type: string }
chain: { type: string }
action: { type: string }
protocol: { type: string }
dport: { type: string }
sport: { type: string }
saddr: { type: string }
daddr: { type: string }
comment: { type: string }
raw: { type: string, maxLength: 512 }
HostListener:
type: object
required: [protocol, port, address]
properties:
protocol: { type: string }
port: { type: integer, minimum: 0, maximum: 65535 }
address: { type: string }
process: { type: string }
HostFirewallPayload:
type: object
properties:
rules:
type: array
maxItems: 500
items:
$ref: '#/components/schemas/HostFwRule'
listeners:
type: array
maxItems: 200
items:
$ref: '#/components/schemas/HostListener'
securitySchemes:
bearerAuth:
type: http
@@ -0,0 +1,31 @@
-- Per-agent L4 port ACL (desired state) + host firewall snapshot (observed).
CREATE TABLE IF NOT EXISTS agent_port_rules (
id TEXT PRIMARY KEY NOT NULL,
agent_id TEXT NOT NULL REFERENCES agents(id) ON DELETE CASCADE,
action TEXT NOT NULL,
protocol TEXT NOT NULL,
port_start INTEGER NOT NULL,
port_end INTEGER NOT NULL,
src_kind TEXT NOT NULL,
src_cidr TEXT,
list_id TEXT REFERENCES ip_lists(id) ON DELETE SET NULL,
enabled INTEGER NOT NULL DEFAULT 1,
comment TEXT,
priority INTEGER NOT NULL DEFAULT 100,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
);
CREATE INDEX IF NOT EXISTS idx_agent_port_rules_agent_priority
ON agent_port_rules(agent_id, priority);
CREATE INDEX IF NOT EXISTS idx_agent_port_rules_agent_enabled
ON agent_port_rules(agent_id, enabled);
CREATE TABLE IF NOT EXISTS agent_host_firewall_snapshots (
agent_id TEXT PRIMARY KEY NOT NULL REFERENCES agents(id) ON DELETE CASCADE,
collected_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
payload_json TEXT NOT NULL,
raw_digest TEXT
);
+30
View File
@@ -77,6 +77,18 @@ export type {
PortBlockPerIpRow,
} from './stats.js'
export {
listAgentPortRules,
listEnabledAgentPortRules,
getAgentPortRule,
insertAgentPortRule,
updateAgentPortRule,
deleteAgentPortRule,
upsertHostFirewallSnapshot,
getHostFirewallSnapshot,
} from './port-acl.js'
export type { AgentPortRuleRow, AgentPortRuleInsert } from './port-acl.js'
export {
getSetting,
setSetting,
@@ -161,6 +173,16 @@ import {
deletePortBlockStatsForAgent,
resetPortBlockStatsBaselines,
} from './stats.js'
import {
listAgentPortRules,
listEnabledAgentPortRules,
getAgentPortRule,
insertAgentPortRule,
updateAgentPortRule,
deleteAgentPortRule,
upsertHostFirewallSnapshot,
getHostFirewallSnapshot,
} from './port-acl.js'
import {
getSetting,
setSetting,
@@ -236,6 +258,14 @@ export const repos = {
mapTopPortsByIp,
deletePortBlockStatsForAgent,
resetPortBlockStatsBaselines,
listAgentPortRules,
listEnabledAgentPortRules,
getAgentPortRule,
insertAgentPortRule,
updateAgentPortRule,
deleteAgentPortRule,
upsertHostFirewallSnapshot,
getHostFirewallSnapshot,
getSetting,
setSetting,
listSettings,
+93
View File
@@ -0,0 +1,93 @@
import { and, asc, eq } from 'drizzle-orm'
import type { Db } from '../client.js'
import { agentHostFirewallSnapshots, agentPortRules } from '../schema.js'
export type AgentPortRuleRow = typeof agentPortRules.$inferSelect
export type AgentPortRuleInsert = typeof agentPortRules.$inferInsert
export function listAgentPortRules(db: Db, agentId: string) {
return db
.select()
.from(agentPortRules)
.where(eq(agentPortRules.agentId, agentId))
.orderBy(asc(agentPortRules.priority), asc(agentPortRules.createdAt))
.all()
}
export function listEnabledAgentPortRules(db: Db, agentId: string) {
return db
.select()
.from(agentPortRules)
.where(
and(eq(agentPortRules.agentId, agentId), eq(agentPortRules.enabled, 1)),
)
.orderBy(asc(agentPortRules.priority), asc(agentPortRules.createdAt))
.all()
}
export function getAgentPortRule(db: Db, id: string) {
return db.select().from(agentPortRules).where(eq(agentPortRules.id, id)).get()
}
export function insertAgentPortRule(db: Db, row: AgentPortRuleInsert) {
db.insert(agentPortRules).values(row).run()
return getAgentPortRule(db, row.id)
}
export function updateAgentPortRule(
db: Db,
id: string,
patch: Partial<
Omit<AgentPortRuleInsert, 'id' | 'agentId' | 'createdAt'>
>,
) {
db.update(agentPortRules)
.set({
...patch,
updatedAt: new Date().toISOString(),
})
.where(eq(agentPortRules.id, id))
.run()
return getAgentPortRule(db, id)
}
export function deleteAgentPortRule(db: Db, id: string) {
db.delete(agentPortRules).where(eq(agentPortRules.id, id)).run()
}
export function upsertHostFirewallSnapshot(
db: Db,
agentId: string,
payloadJson: string,
collectedAt = new Date().toISOString(),
rawDigest: string | null = null,
) {
const existing = db
.select()
.from(agentHostFirewallSnapshots)
.where(eq(agentHostFirewallSnapshots.agentId, agentId))
.get()
if (existing) {
db.update(agentHostFirewallSnapshots)
.set({ payloadJson, collectedAt, rawDigest })
.where(eq(agentHostFirewallSnapshots.agentId, agentId))
.run()
} else {
db.insert(agentHostFirewallSnapshots)
.values({ agentId, payloadJson, collectedAt, rawDigest })
.run()
}
return db
.select()
.from(agentHostFirewallSnapshots)
.where(eq(agentHostFirewallSnapshots.agentId, agentId))
.get()
}
export function getHostFirewallSnapshot(db: Db, agentId: string) {
return db
.select()
.from(agentHostFirewallSnapshots)
.where(eq(agentHostFirewallSnapshots.agentId, agentId))
.get()
}
+56
View File
@@ -264,6 +264,60 @@ export const agentPortBlockStats = sqliteTable(
}),
)
/** Desired L4 port ACL per Linux agent (open/close). */
export const agentPortRules = sqliteTable(
'agent_port_rules',
{
id: text('id').primaryKey(),
agentId: text('agent_id')
.notNull()
.references(() => agents.id, { onDelete: 'cascade' }),
action: text('action').notNull(), // open | close
protocol: text('protocol').notNull(), // tcp | udp | both
portStart: integer('port_start').notNull(),
portEnd: integer('port_end').notNull(),
srcKind: text('src_kind').notNull(), // all | cidr | list
srcCidr: text('src_cidr'),
listId: text('list_id').references(() => ipLists.id, {
onDelete: 'set null',
}),
enabled: integer('enabled').notNull().default(1),
comment: text('comment'),
priority: integer('priority').notNull().default(100),
createdAt: text('created_at')
.notNull()
.default(sql`(strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))`),
updatedAt: text('updated_at')
.notNull()
.default(sql`(strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))`),
},
(t) => ({
agentPriority: index('idx_agent_port_rules_agent_priority').on(
t.agentId,
t.priority,
),
agentEnabled: index('idx_agent_port_rules_agent_enabled').on(
t.agentId,
t.enabled,
),
}),
)
/** Latest observed host firewall + listeners snapshot from Linux agent. */
export const agentHostFirewallSnapshots = sqliteTable(
'agent_host_firewall_snapshots',
{
agentId: text('agent_id')
.primaryKey()
.references(() => agents.id, { onDelete: 'cascade' }),
collectedAt: text('collected_at')
.notNull()
.default(sql`(strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))`),
payloadJson: text('payload_json').notNull(),
rawDigest: text('raw_digest'),
},
)
/** Short install invite links (`/agent-install/:id` and `/:slug`). */
export const agentInstallLinks = sqliteTable(
'agent_install_links',
@@ -328,6 +382,8 @@ export const schema = {
agentStatsSamples,
agentIpBlockStats,
agentPortBlockStats,
agentPortRules,
agentHostFirewallSnapshots,
agentInstallLinks,
auditLog,
}
+168 -1
View File
@@ -233,6 +233,42 @@ export const applyReportPortHitSchema = z.object({
packets: z.number().int().nonnegative(),
})
export const hostFwOwnershipSchema = z.enum(['evofw', 'foreign'])
export const hostFwBackendSchema = z.enum([
'nft',
'iptables',
'ufw',
'firewalld',
'listener',
])
export const hostFwRuleSchema = z.object({
ownership: hostFwOwnershipSchema,
backend: hostFwBackendSchema,
table: z.string().max(128).optional(),
chain: z.string().max(128).optional(),
action: z.string().max(64).optional(),
protocol: z.string().max(16).optional(),
dport: z.string().max(64).optional(),
sport: z.string().max(64).optional(),
saddr: z.string().max(128).optional(),
daddr: z.string().max(128).optional(),
comment: z.string().max(256).optional(),
raw: z.string().max(512),
})
export const hostListenerSchema = z.object({
protocol: z.string().max(16),
port: z.number().int().min(0).max(65535),
address: z.string().max(128),
process: z.string().max(128).optional(),
})
export const hostFirewallPayloadSchema = z.object({
rules: z.array(hostFwRuleSchema).max(500).default([]),
listeners: z.array(hostListenerSchema).max(200).default([]),
})
export const applyReportBodySchema = z.object({
status: z.string(),
prefix_count: z.number().int().optional(),
@@ -245,6 +281,129 @@ export const applyReportBodySchema = z.object({
ip_hits: z.array(applyReportIpHitSchema).max(200).optional(),
/** Linux nft dynamic set per-(ip, proto, dport) deny hits (top-N). */
port_hits: z.array(applyReportPortHitSchema).max(500).optional(),
/** Observed host firewall + listeners (Linux). */
host_firewall: hostFirewallPayloadSchema.optional(),
})
export const agentPortRuleActionSchema = z.enum(['open', 'close'])
export const agentPortRuleProtocolSchema = z.enum(['tcp', 'udp', 'both'])
export const agentPortRuleSrcKindSchema = z.enum(['all', 'cidr', 'list'])
export const agentPortRuleSchema = z.object({
id: z.string(),
agent_id: z.string(),
action: agentPortRuleActionSchema,
protocol: agentPortRuleProtocolSchema,
port_start: z.number().int().min(1).max(65535),
port_end: z.number().int().min(1).max(65535),
src_kind: agentPortRuleSrcKindSchema,
src_cidr: z.string().nullable().optional(),
list_id: z.string().nullable().optional(),
list_name: z.string().nullable().optional(),
enabled: z.boolean(),
comment: z.string().nullable().optional(),
priority: z.number().int(),
created_at: z.string(),
updated_at: z.string(),
})
export const createAgentPortRuleBodySchema = z
.object({
action: agentPortRuleActionSchema,
protocol: agentPortRuleProtocolSchema.default('tcp'),
port_start: z.number().int().min(1).max(65535),
port_end: z.number().int().min(1).max(65535).optional(),
src_kind: agentPortRuleSrcKindSchema.default('all'),
src_cidr: z.string().min(1).max(64).optional(),
list_id: z.string().min(1).optional(),
enabled: z.boolean().optional().default(true),
comment: z.string().max(500).optional(),
priority: z.number().int().optional().default(100),
})
.superRefine((v, ctx) => {
const end = v.port_end ?? v.port_start
if (end < v.port_start) {
ctx.addIssue({
code: 'custom',
message: 'port_end must be >= port_start',
path: ['port_end'],
})
}
if (v.src_kind === 'cidr' && !v.src_cidr?.trim()) {
ctx.addIssue({
code: 'custom',
message: 'src_cidr required when src_kind=cidr',
path: ['src_cidr'],
})
}
if (v.src_kind === 'list' && !v.list_id?.trim()) {
ctx.addIssue({
code: 'custom',
message: 'list_id required when src_kind=list',
path: ['list_id'],
})
}
})
export const updateAgentPortRuleBodySchema = z
.object({
action: agentPortRuleActionSchema.optional(),
protocol: agentPortRuleProtocolSchema.optional(),
port_start: z.number().int().min(1).max(65535).optional(),
port_end: z.number().int().min(1).max(65535).optional(),
src_kind: agentPortRuleSrcKindSchema.optional(),
src_cidr: z.string().min(1).max(64).nullable().optional(),
list_id: z.string().min(1).nullable().optional(),
enabled: z.boolean().optional(),
comment: z.string().max(500).nullable().optional(),
priority: z.number().int().optional(),
})
.refine((o) => Object.keys(o).length > 0, { message: 'empty update' })
export const importAgentPortRulesBodySchema = z
.object({
from: z.enum(['list', 'set']),
list_id: z.string().min(1).optional(),
set_id: z.string().min(1).optional(),
action: agentPortRuleActionSchema,
protocol: agentPortRuleProtocolSchema.default('tcp'),
ports: z
.array(
z.object({
port_start: z.number().int().min(1).max(65535),
port_end: z.number().int().min(1).max(65535).optional(),
}),
)
.min(1)
.max(50),
enabled: z.boolean().optional().default(true),
comment: z.string().max(500).optional(),
})
.superRefine((v, ctx) => {
if (v.from === 'list' && !v.list_id?.trim()) {
ctx.addIssue({
code: 'custom',
message: 'list_id required when from=list',
path: ['list_id'],
})
}
if (v.from === 'set' && !v.set_id?.trim()) {
ctx.addIssue({
code: 'custom',
message: 'set_id required when from=set',
path: ['set_id'],
})
}
})
/** Expanded port rule for agent policy apply_version >= 3. */
export const agentPolicyPortRuleSchema = z.object({
id: z.string(),
action: agentPortRuleActionSchema,
protocol: z.enum(['tcp', 'udp']),
port_start: z.number().int(),
port_end: z.number().int(),
src_cidrs: z.array(z.string()),
})
export const agentIpPortStatSchema = z.object({
@@ -277,6 +436,7 @@ export const agentPolicySchema = z.object({
policy_mode: policyModeSchema.optional(),
deny_cidrs: z.array(z.string()),
allow_cidrs: z.array(z.string()),
port_rules: z.array(agentPolicyPortRuleSchema).optional(),
sync_interval_sec: z.number().int(),
})
@@ -285,7 +445,7 @@ export const agentPolicyPreviewSchema = z.object({
hash: z.string(),
generation: z.number().int(),
sync_interval_sec: z.number().int(),
apply_version: z.literal(2),
apply_version: z.literal(3),
summary: z.object({
sets: z.number().int(),
rules_deny: z.number().int(),
@@ -294,6 +454,7 @@ export const agentPolicyPreviewSchema = z.object({
cidrs_allow: z.number().int(),
overrides: z.number().int(),
conflicts_dropped: z.number().int(),
port_rules: z.number().int().optional(),
}),
chain: z.array(
z.object({
@@ -310,6 +471,7 @@ export const agentPolicyPreviewSchema = z.object({
allow_cidrs: z.array(z.string()),
deny_cidrs_total: z.number().int(),
allow_cidrs_total: z.number().int(),
port_rules: z.array(agentPolicyPortRuleSchema).optional(),
})
export const dashboardStatsSchema = z.object({
@@ -387,6 +549,11 @@ export type PolicySet = z.infer<typeof policySetSchema>
export type IpOverride = z.infer<typeof ipOverrideSchema>
export type AgentPolicy = z.infer<typeof agentPolicySchema>
export type AgentPolicyPreview = z.infer<typeof agentPolicyPreviewSchema>
export type AgentPortRule = z.infer<typeof agentPortRuleSchema>
export type CreateAgentPortRuleBody = z.infer<typeof createAgentPortRuleBodySchema>
export type UpdateAgentPortRuleBody = z.infer<typeof updateAgentPortRuleBodySchema>
export type ImportAgentPortRulesBody = z.infer<typeof importAgentPortRulesBodySchema>
export type HostFirewallPayload = z.infer<typeof hostFirewallPayloadSchema>
export type DashboardStats = z.infer<typeof dashboardStatsSchema>
export type InstallLink = z.infer<typeof installLinkSchema>
export type EvobgpCommunity = z.infer<typeof evobgpCommunitySchema>