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