From 43f5ac25252e8cc82d34fae3a76d86c2a0d99c82 Mon Sep 17 00:00:00 2001 From: Denozordec Date: Tue, 11 Aug 2026 15:08:00 +0700 Subject: [PATCH] feat(api, web): implement port ACL and host firewall snapshot features - Added support for managing desired L4 port ACL rules for Linux agents, allowing for open/close actions on specified ports. - Introduced a new endpoint for CRUD operations on port rules, enhancing the API's capabilities for agent management. - Implemented functionality to collect and report host firewall snapshots, capturing observed rules and listeners for better monitoring. - Updated the agent detail view to include tabs for managing port ACLs and viewing host firewall data, improving user experience. - Enhanced documentation to reflect the new features and API changes, ensuring clarity for users and developers. These changes significantly improve the management and visibility of firewall rules and port access control for agents. --- apps/api/src/agent-scripts/evofw-firewall.sh | 295 +++++++- apps/api/src/routes/agent.ts | 26 + apps/api/src/routes/agents.ts | 9 + apps/api/src/routes/control.ts | 2 + apps/api/src/routes/port-acl.ts | 364 +++++++++ apps/api/src/services/install-links.test.ts | 2 +- apps/api/src/services/policy/evaluate.ts | 65 +- .../src/services/policy/mikrotik-rsc.test.ts | 4 +- .../src/services/policy/policy-mode.test.ts | 2 +- apps/api/src/services/port-acl.test.ts | 304 ++++++++ .../components/agents/agent-detail-view.tsx | 40 +- .../components/agents/agent-host-firewall.tsx | 329 +++++++++ .../src/components/agents/agent-port-acl.tsx | 697 ++++++++++++++++++ apps/web/src/queries/index.ts | 62 ++ docs/agents.md | 25 + docs/architecture.md | 7 +- docs/openapi.yaml | 258 ++++++- packages/db/migrations/011_agent_port_acl.sql | 31 + packages/db/src/repositories/index.ts | 30 + packages/db/src/repositories/port-acl.ts | 93 +++ packages/db/src/schema.ts | 56 ++ packages/shared/src/contracts.ts | 169 ++++- 22 files changed, 2853 insertions(+), 17 deletions(-) create mode 100644 apps/api/src/routes/port-acl.ts create mode 100644 apps/api/src/services/port-acl.test.ts create mode 100644 apps/web/src/components/agents/agent-host-firewall.tsx create mode 100644 apps/web/src/components/agents/agent-port-acl.tsx create mode 100644 packages/db/migrations/011_agent_port_acl.sql create mode 100644 packages/db/src/repositories/port-acl.ts diff --git a/apps/api/src/agent-scripts/evofw-firewall.sh b/apps/api/src/agent-scripts/evofw-firewall.sh index c207dec..bd58471 100644 --- a/apps/api/src/agent-scripts/evofw-firewall.sh +++ b/apps/api/src/agent-scripts/evofw-firewall.sh @@ -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) diff --git a/apps/api/src/routes/agent.ts b/apps/api/src/routes/agent.ts index 630c886..1ced2f4 100644 --- a/apps/api/src/routes/agent.ts +++ b/apps/api/src/routes/agent.ts @@ -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 } }) diff --git a/apps/api/src/routes/agents.ts b/apps/api/src/routes/agents.ts index 541b4b9..da30343 100644 --- a/apps/api/src/routes/agents.ts +++ b/apps/api/src/routes/agents.ts @@ -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, + })), } }) diff --git a/apps/api/src/routes/control.ts b/apps/api/src/routes/control.ts index 0ecbe79..7660706 100644 --- a/apps/api/src/routes/control.ts +++ b/apps/api/src/routes/control.ts @@ -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 }) } diff --git a/apps/api/src/routes/port-acl.ts b/apps/api/src/routes/port-acl.ts new file mode 100644 index 0000000..a6208f1 --- /dev/null +++ b/apps/api/src/routes/port-acl.ts @@ -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>, + 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[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[] = [] + + 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() + const cidrs = new Set() + 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) +} diff --git a/apps/api/src/services/install-links.test.ts b/apps/api/src/services/install-links.test.ts index 28097c0..bf4a3be 100644 --- a/apps/api/src/services/install-links.test.ts +++ b/apps/api/src/services/install-links.test.ts @@ -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' }) diff --git a/apps/api/src/services/policy/evaluate.ts b/apps/api/src/services/policy/evaluate.ts index 4e17e3f..de7d8c0 100644 --- a/apps/api/src/services/policy/evaluate.ts +++ b/apps/api/src/services/policy/evaluate.ts @@ -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, }, } } diff --git a/apps/api/src/services/policy/mikrotik-rsc.test.ts b/apps/api/src/services/policy/mikrotik-rsc.test.ts index fbc2d4d..865de98 100644 --- a/apps/api/src/services/policy/mikrotik-rsc.test.ts +++ b/apps/api/src/services/policy/mikrotik-rsc.test.ts @@ -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') diff --git a/apps/api/src/services/policy/policy-mode.test.ts b/apps/api/src/services/policy/policy-mode.test.ts index 60e91ee..0b30897 100644 --- a/apps/api/src/services/policy/policy-mode.test.ts +++ b/apps/api/src/services/policy/policy-mode.test.ts @@ -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') diff --git a/apps/api/src/services/port-acl.test.ts b/apps/api/src/services/port-acl.test.ts new file mode 100644 index 0000000..b4566e1 --- /dev/null +++ b/apps/api/src/services/port-acl.test.ts @@ -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>, + 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) + }) +}) diff --git a/apps/web/src/components/agents/agent-detail-view.tsx b/apps/web/src/components/agents/agent-detail-view.tsx index 622b3b8..dcd8162 100644 --- a/apps/web/src/components/agents/agent-detail-view.tsx +++ b/apps/web/src/components/agents/agent-detail-view.tsx @@ -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(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' ? ( - - ) : null} - - +
+ + + + + + + + + + + + +
+ ) : ( + + )} diff --git a/apps/web/src/components/agents/agent-host-firewall.tsx b/apps/web/src/components/agents/agent-host-firewall.tsx new file mode 100644 index 0000000..d183ef0 --- /dev/null +++ b/apps/web/src/components/agents/agent-host-firewall.tsx @@ -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('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[]>( + () => [ + { + id: 'ownership', + accessorKey: 'ownership', + header: ({ column }) => ( + + ), + cell: ({ row }) => + row.original.ownership === 'evofw' ? ( + + EvoFW + + ) : ( + + foreign + + ), + meta: { headerTitle: 'Owner' }, + }, + { + accessorKey: 'backend', + header: ({ column }) => ( + + ), + cell: ({ row }) => ( + {row.original.backend} + ), + }, + { + id: 'chain', + accessorFn: (r) => r.chain || r.table || '—', + header: ({ column }) => ( + + ), + cell: ({ row }) => ( + + {row.original.chain || row.original.table || '—'} + + ), + }, + { + accessorKey: 'action', + header: ({ column }) => ( + + ), + cell: ({ row }) => ( + {row.original.action || '—'} + ), + }, + { + id: 'ports', + accessorFn: (r) => + [r.protocol, r.dport].filter(Boolean).join('/') || '—', + header: ({ column }) => ( + + ), + cell: ({ row }) => ( + + {[row.original.protocol, row.original.dport] + .filter(Boolean) + .join('/') || '—'} + + ), + }, + { + accessorKey: 'saddr', + header: ({ column }) => ( + + ), + cell: ({ row }) => ( + + {row.original.saddr || '—'} + + ), + }, + { + accessorKey: 'raw', + header: ({ column }) => ( + + ), + cell: ({ row }) => ( + + {row.original.raw} + + ), + }, + ], + [], + ) + + const listenerCols = useMemo[]>( + () => [ + { + accessorKey: 'protocol', + header: ({ column }) => ( + + ), + cell: ({ row }) => ( + + {row.original.protocol} + + ), + }, + { + accessorKey: 'port', + header: ({ column }) => ( + + ), + cell: ({ row }) => ( + + {row.original.port} + + ), + }, + { + accessorKey: 'address', + header: ({ column }) => ( + + ), + cell: ({ row }) => ( + {row.original.address} + ), + }, + { + accessorKey: 'process', + header: ({ column }) => ( + + ), + cell: ({ row }) => ( + + {row.original.process || '—'} + + ), + }, + ], + [], + ) + + 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 ( + + + Host firewall + + Снимок nft/iptables/ufw/firewalld + listeners. EvoFW vs foreign. + {q.data?.collected_at + ? ` Обновлено: ${new Date(q.data.collected_at).toLocaleString('ru-RU')}` + : ' Пока нет снимка — дождитесь sync агента.'} + + + + + +
+ + +
+ {q.isLoading ? ( +
+ + +
+ ) : rules.length === 0 ? ( + + ) : ( + + + + )} +
+ + {q.isLoading ? ( + + ) : listeners.length === 0 ? ( + + ) : ( + + + + )} + +
+
+ + ) +} diff --git a/apps/web/src/components/agents/agent-port-acl.tsx b/apps/web/src/components/agents/agent-port-acl.tsx new file mode 100644 index 0000000..18baca5 --- /dev/null +++ b/apps/web/src/components/agents/agent-port-acl.tsx @@ -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(null) + const [form, setForm] = useState(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[]>( + () => [ + { + accessorKey: 'action', + header: ({ column }) => ( + + ), + cell: ({ row }) => + row.original.action === 'open' ? ( + + open + + ) : ( + + close + + ), + }, + { + accessorKey: 'protocol', + header: ({ column }) => ( + + ), + cell: ({ row }) => ( + + {row.original.protocol} + + ), + }, + { + id: 'ports', + accessorFn: formatPorts, + header: ({ column }) => ( + + ), + cell: ({ row }) => ( + + {formatPorts(row.original)} + + ), + }, + { + id: 'src', + accessorFn: formatSrc, + header: ({ column }) => ( + + ), + cell: ({ row }) => ( + {formatSrc(row.original)} + ), + }, + { + id: 'enabled', + header: ({ column }) => ( + + ), + cell: ({ row }) => ( + toggle.mutate(row.original)} + aria-label="toggle enabled" + /> + ), + }, + { + id: 'actions', + header: () => Actions, + cell: ({ row }) => ( +
+ + +
+ ), + }, + ], + [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 ( + <> + + +
+
+ Port ACL + + Open/close портов для all / CIDR / IP-list. Apply через nft + (upgrade install-ссылкой). + +
+
+ + +
+
+
+ + {q.isLoading ? ( +
+ + +
+ ) : data.length === 0 ? ( + + Добавить + + } + /> + ) : ( + + + + )} +
+ + + + + + + {editing ? 'Редактировать Port ACL' : 'Новое Port ACL'} + + + Preview: https://reui.io/preview/base/sheet-8 + + + +
+ + Action + + + + Protocol + + +
+ + Port start + + setForm((f) => ({ ...f, port_start: e.target.value })) + } + inputMode="numeric" + placeholder="443" + /> + + + Port end + + setForm((f) => ({ ...f, port_end: e.target.value })) + } + inputMode="numeric" + placeholder="optional" + /> + +
+ + Source + + + {form.src_kind === 'cidr' ? ( + + CIDR + + setForm((f) => ({ ...f, src_cidr: e.target.value })) + } + placeholder="10.0.0.0/8" + /> + + ) : null} + {form.src_kind === 'list' ? ( + + List + + + ) : null} + + Comment + + setForm((f) => ({ ...f, comment: e.target.value })) + } + /> + +
+
+ + + + +
+
+ + + + + Импорт Port ACL + + Источник CIDR/list из набора или IP-list + порты. + + + +
+ + From + + + {impFrom === 'list' ? ( + + List + + + ) : ( + + Set + + + )} + + Action + + + + Protocol + + + + Ports + setImpPorts(e.target.value)} + placeholder="22,80,443 или 8000-8010" + /> + +
+
+ + + + +
+
+ + ) +} diff --git a/apps/web/src/queries/index.ts b/apps/web/src/queries/index.ts index 3072f2f..4b1ab20 100644 --- a/apps/web/src/queries/index.ts +++ b/apps/web/src/queries/index.ts @@ -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'], diff --git a/docs/agents.md b/docs/agents.md index f88e0c7..d6cdb0a 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -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-"`) +- 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: diff --git a/docs/architecture.md b/docs/architecture.md index 0c79752..618b43f 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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 diff --git a/docs/openapi.yaml b/docs/openapi.yaml index efcbbf5..443e98d 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -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 diff --git a/packages/db/migrations/011_agent_port_acl.sql b/packages/db/migrations/011_agent_port_acl.sql new file mode 100644 index 0000000..6eca174 --- /dev/null +++ b/packages/db/migrations/011_agent_port_acl.sql @@ -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 +); diff --git a/packages/db/src/repositories/index.ts b/packages/db/src/repositories/index.ts index 17244f9..3be3668 100644 --- a/packages/db/src/repositories/index.ts +++ b/packages/db/src/repositories/index.ts @@ -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, diff --git a/packages/db/src/repositories/port-acl.ts b/packages/db/src/repositories/port-acl.ts new file mode 100644 index 0000000..ba9a6f5 --- /dev/null +++ b/packages/db/src/repositories/port-acl.ts @@ -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 + >, +) { + 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() +} diff --git a/packages/db/src/schema.ts b/packages/db/src/schema.ts index c85c28f..4f867dc 100644 --- a/packages/db/src/schema.ts +++ b/packages/db/src/schema.ts @@ -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, } diff --git a/packages/shared/src/contracts.ts b/packages/shared/src/contracts.ts index 87d7112..df5968f 100644 --- a/packages/shared/src/contracts.ts +++ b/packages/shared/src/contracts.ts @@ -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 export type IpOverride = z.infer export type AgentPolicy = z.infer export type AgentPolicyPreview = z.infer +export type AgentPortRule = z.infer +export type CreateAgentPortRuleBody = z.infer +export type UpdateAgentPortRuleBody = z.infer +export type ImportAgentPortRulesBody = z.infer +export type HostFirewallPayload = z.infer export type DashboardStats = z.infer export type InstallLink = z.infer export type EvobgpCommunity = z.infer