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.
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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 }
|
||||
})
|
||||
|
||||
|
||||
@@ -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,
|
||||
})),
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
@@ -0,0 +1,364 @@
|
||||
import type { FastifyPluginAsync } from 'fastify'
|
||||
import { createHash } from 'node:crypto'
|
||||
import { repos } from '@evofw/db'
|
||||
import {
|
||||
createAgentPortRuleBodySchema,
|
||||
importAgentPortRulesBodySchema,
|
||||
updateAgentPortRuleBodySchema,
|
||||
} from '@evofw/shared'
|
||||
import { AppError } from '../plugins/error-handler.js'
|
||||
import type { AppConfig } from '../config.js'
|
||||
import { auditMutation } from '../services/audit.js'
|
||||
|
||||
function mapPortRule(
|
||||
row: NonNullable<ReturnType<typeof repos.getAgentPortRule>>,
|
||||
listName?: string | null,
|
||||
) {
|
||||
return {
|
||||
id: row.id,
|
||||
agent_id: row.agentId,
|
||||
action: row.action,
|
||||
protocol: row.protocol,
|
||||
port_start: row.portStart,
|
||||
port_end: row.portEnd,
|
||||
src_kind: row.srcKind,
|
||||
src_cidr: row.srcCidr,
|
||||
list_id: row.listId,
|
||||
list_name: listName ?? null,
|
||||
enabled: row.enabled === 1,
|
||||
comment: row.comment,
|
||||
priority: row.priority,
|
||||
created_at: row.createdAt,
|
||||
updated_at: row.updatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
function validateSrc(
|
||||
srcKind: string,
|
||||
srcCidr: string | null | undefined,
|
||||
listId: string | null | undefined,
|
||||
db: Parameters<typeof repos.getIpList>[0],
|
||||
) {
|
||||
if (srcKind === 'cidr' && !srcCidr?.trim()) {
|
||||
throw new AppError('VALIDATION_ERROR', 'src_cidr required', 400)
|
||||
}
|
||||
if (srcKind === 'list') {
|
||||
if (!listId?.trim()) {
|
||||
throw new AppError('VALIDATION_ERROR', 'list_id required', 400)
|
||||
}
|
||||
if (!repos.getIpList(db, listId)) {
|
||||
throw new AppError('NOT_FOUND', 'IP list not found', 404)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const portAclRoutes: FastifyPluginAsync<{ config: AppConfig }> = async (
|
||||
app,
|
||||
opts,
|
||||
) => {
|
||||
const { config } = opts
|
||||
|
||||
app.get<{ Params: { id: string } }>(
|
||||
'/agents/:id/port-rules',
|
||||
async (req) => {
|
||||
const agent = repos.getAgent(app.db, req.params.id)
|
||||
if (!agent) throw new AppError('NOT_FOUND', 'Agent not found', 404)
|
||||
const items = repos.listAgentPortRules(app.db, agent.id).map((row) => {
|
||||
const listName = row.listId
|
||||
? repos.getIpList(app.db, row.listId)?.name
|
||||
: null
|
||||
return mapPortRule(row, listName)
|
||||
})
|
||||
return { items }
|
||||
},
|
||||
)
|
||||
|
||||
app.post<{ Params: { id: string } }>(
|
||||
'/agents/:id/port-rules',
|
||||
async (req) => {
|
||||
const agent = repos.getAgent(app.db, req.params.id)
|
||||
if (!agent) throw new AppError('NOT_FOUND', 'Agent not found', 404)
|
||||
if (agent.platform !== 'linux') {
|
||||
throw new AppError(
|
||||
'VALIDATION_ERROR',
|
||||
'Port ACL is only supported on Linux agents',
|
||||
400,
|
||||
)
|
||||
}
|
||||
const body = createAgentPortRuleBodySchema.parse(req.body)
|
||||
const portEnd = body.port_end ?? body.port_start
|
||||
const srcKind = body.src_kind
|
||||
const srcCidr = srcKind === 'cidr' ? body.src_cidr!.trim() : null
|
||||
const listId = srcKind === 'list' ? body.list_id! : null
|
||||
validateSrc(srcKind, srcCidr, listId, app.db)
|
||||
|
||||
const now = new Date().toISOString()
|
||||
const row = repos.insertAgentPortRule(app.db, {
|
||||
id: crypto.randomUUID(),
|
||||
agentId: agent.id,
|
||||
action: body.action,
|
||||
protocol: body.protocol,
|
||||
portStart: body.port_start,
|
||||
portEnd,
|
||||
srcKind,
|
||||
srcCidr,
|
||||
listId,
|
||||
enabled: body.enabled === false ? 0 : 1,
|
||||
comment: body.comment ?? null,
|
||||
priority: body.priority ?? 100,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
repos.bumpAgentGeneration(app.db, agent.id)
|
||||
auditMutation(app, config, req, {
|
||||
action: 'port_rule.create',
|
||||
targetType: 'app_resource',
|
||||
targetId: row!.id,
|
||||
summary: `Port ACL ${body.action} ${body.protocol}/${body.port_start} для ${agent.name}`,
|
||||
details: {
|
||||
agent_id: agent.id,
|
||||
rule_id: row!.id,
|
||||
action: body.action,
|
||||
protocol: body.protocol,
|
||||
port_start: body.port_start,
|
||||
port_end: portEnd,
|
||||
},
|
||||
})
|
||||
const listName = row!.listId
|
||||
? repos.getIpList(app.db, row!.listId)?.name
|
||||
: null
|
||||
return mapPortRule(row!, listName)
|
||||
},
|
||||
)
|
||||
|
||||
app.patch<{ Params: { id: string; ruleId: string } }>(
|
||||
'/agents/:id/port-rules/:ruleId',
|
||||
async (req) => {
|
||||
const agent = repos.getAgent(app.db, req.params.id)
|
||||
if (!agent) throw new AppError('NOT_FOUND', 'Agent not found', 404)
|
||||
const existing = repos.getAgentPortRule(app.db, req.params.ruleId)
|
||||
if (!existing || existing.agentId !== agent.id) {
|
||||
throw new AppError('NOT_FOUND', 'Port rule not found', 404)
|
||||
}
|
||||
const body = updateAgentPortRuleBodySchema.parse(req.body)
|
||||
const nextSrcKind = body.src_kind ?? existing.srcKind
|
||||
const nextSrcCidr =
|
||||
body.src_cidr !== undefined
|
||||
? body.src_cidr
|
||||
: existing.srcCidr
|
||||
const nextListId =
|
||||
body.list_id !== undefined ? body.list_id : existing.listId
|
||||
validateSrc(nextSrcKind, nextSrcCidr, nextListId, app.db)
|
||||
|
||||
const portStart = body.port_start ?? existing.portStart
|
||||
const portEnd = body.port_end ?? existing.portEnd
|
||||
if (portEnd < portStart) {
|
||||
throw new AppError(
|
||||
'VALIDATION_ERROR',
|
||||
'port_end must be >= port_start',
|
||||
400,
|
||||
)
|
||||
}
|
||||
|
||||
const row = repos.updateAgentPortRule(app.db, existing.id, {
|
||||
...(body.action !== undefined ? { action: body.action } : {}),
|
||||
...(body.protocol !== undefined ? { protocol: body.protocol } : {}),
|
||||
portStart,
|
||||
portEnd,
|
||||
srcKind: nextSrcKind,
|
||||
srcCidr: nextSrcKind === 'cidr' ? nextSrcCidr : null,
|
||||
listId: nextSrcKind === 'list' ? nextListId : null,
|
||||
...(body.enabled !== undefined
|
||||
? { enabled: body.enabled ? 1 : 0 }
|
||||
: {}),
|
||||
...(body.comment !== undefined ? { comment: body.comment } : {}),
|
||||
...(body.priority !== undefined ? { priority: body.priority } : {}),
|
||||
})
|
||||
repos.bumpAgentGeneration(app.db, agent.id)
|
||||
auditMutation(app, config, req, {
|
||||
action: 'port_rule.update',
|
||||
targetType: 'app_resource',
|
||||
targetId: existing.id,
|
||||
summary: `Port ACL обновлён у ${agent.name}`,
|
||||
details: { agent_id: agent.id, rule_id: existing.id },
|
||||
})
|
||||
const listName = row!.listId
|
||||
? repos.getIpList(app.db, row!.listId)?.name
|
||||
: null
|
||||
return mapPortRule(row!, listName)
|
||||
},
|
||||
)
|
||||
|
||||
app.delete<{ Params: { id: string; ruleId: string } }>(
|
||||
'/agents/:id/port-rules/:ruleId',
|
||||
async (req) => {
|
||||
const agent = repos.getAgent(app.db, req.params.id)
|
||||
if (!agent) throw new AppError('NOT_FOUND', 'Agent not found', 404)
|
||||
const existing = repos.getAgentPortRule(app.db, req.params.ruleId)
|
||||
if (!existing || existing.agentId !== agent.id) {
|
||||
throw new AppError('NOT_FOUND', 'Port rule not found', 404)
|
||||
}
|
||||
repos.deleteAgentPortRule(app.db, existing.id)
|
||||
repos.bumpAgentGeneration(app.db, agent.id)
|
||||
auditMutation(app, config, req, {
|
||||
action: 'port_rule.delete',
|
||||
severity: 'warning',
|
||||
targetType: 'app_resource',
|
||||
targetId: existing.id,
|
||||
summary: `Port ACL удалён у ${agent.name}`,
|
||||
details: { agent_id: agent.id, rule_id: existing.id },
|
||||
})
|
||||
return { ok: true }
|
||||
},
|
||||
)
|
||||
|
||||
app.post<{ Params: { id: string } }>(
|
||||
'/agents/:id/port-rules/import',
|
||||
async (req) => {
|
||||
const agent = repos.getAgent(app.db, req.params.id)
|
||||
if (!agent) throw new AppError('NOT_FOUND', 'Agent not found', 404)
|
||||
if (agent.platform !== 'linux') {
|
||||
throw new AppError(
|
||||
'VALIDATION_ERROR',
|
||||
'Port ACL is only supported on Linux agents',
|
||||
400,
|
||||
)
|
||||
}
|
||||
const body = importAgentPortRulesBodySchema.parse(req.body)
|
||||
const now = new Date().toISOString()
|
||||
const created: ReturnType<typeof mapPortRule>[] = []
|
||||
|
||||
if (body.from === 'list') {
|
||||
const list = repos.getIpList(app.db, body.list_id!)
|
||||
if (!list) throw new AppError('NOT_FOUND', 'IP list not found', 404)
|
||||
for (const p of body.ports) {
|
||||
const portEnd = p.port_end ?? p.port_start
|
||||
const row = repos.insertAgentPortRule(app.db, {
|
||||
id: crypto.randomUUID(),
|
||||
agentId: agent.id,
|
||||
action: body.action,
|
||||
protocol: body.protocol,
|
||||
portStart: p.port_start,
|
||||
portEnd,
|
||||
srcKind: 'list',
|
||||
srcCidr: null,
|
||||
listId: list.id,
|
||||
enabled: body.enabled === false ? 0 : 1,
|
||||
comment: body.comment ?? `import list:${list.name}`,
|
||||
priority: 100,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
created.push(mapPortRule(row!, list.name))
|
||||
}
|
||||
} else {
|
||||
const set = repos.getPolicySet(app.db, body.set_id!)
|
||||
if (!set) throw new AppError('NOT_FOUND', 'Policy set not found', 404)
|
||||
const rules = repos.listPolicyRules(app.db, set.id)
|
||||
const listIds = new Set<string>()
|
||||
const cidrs = new Set<string>()
|
||||
for (const r of rules) {
|
||||
if (r.listId) listIds.add(r.listId)
|
||||
if (r.cidr?.trim()) cidrs.add(r.cidr.trim())
|
||||
}
|
||||
if (!listIds.size && !cidrs.size) {
|
||||
throw new AppError(
|
||||
'VALIDATION_ERROR',
|
||||
'Policy set has no list/cidr sources to import',
|
||||
400,
|
||||
)
|
||||
}
|
||||
for (const p of body.ports) {
|
||||
const portEnd = p.port_end ?? p.port_start
|
||||
for (const listId of listIds) {
|
||||
const list = repos.getIpList(app.db, listId)
|
||||
const row = repos.insertAgentPortRule(app.db, {
|
||||
id: crypto.randomUUID(),
|
||||
agentId: agent.id,
|
||||
action: body.action,
|
||||
protocol: body.protocol,
|
||||
portStart: p.port_start,
|
||||
portEnd,
|
||||
srcKind: 'list',
|
||||
srcCidr: null,
|
||||
listId,
|
||||
enabled: body.enabled === false ? 0 : 1,
|
||||
comment:
|
||||
body.comment ?? `import set:${set.name} list:${list?.name ?? listId}`,
|
||||
priority: 100,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
created.push(mapPortRule(row!, list?.name ?? null))
|
||||
}
|
||||
for (const cidr of cidrs) {
|
||||
const row = repos.insertAgentPortRule(app.db, {
|
||||
id: crypto.randomUUID(),
|
||||
agentId: agent.id,
|
||||
action: body.action,
|
||||
protocol: body.protocol,
|
||||
portStart: p.port_start,
|
||||
portEnd,
|
||||
srcKind: 'cidr',
|
||||
srcCidr: cidr,
|
||||
listId: null,
|
||||
enabled: body.enabled === false ? 0 : 1,
|
||||
comment: body.comment ?? `import set:${set.name} cidr:${cidr}`,
|
||||
priority: 100,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
created.push(mapPortRule(row!, null))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
repos.bumpAgentGeneration(app.db, agent.id)
|
||||
auditMutation(app, config, req, {
|
||||
action: 'port_rule.import',
|
||||
targetType: 'app_resource',
|
||||
targetId: agent.id,
|
||||
summary: `Импорт ${created.length} Port ACL для ${agent.name}`,
|
||||
details: {
|
||||
agent_id: agent.id,
|
||||
from: body.from,
|
||||
count: created.length,
|
||||
},
|
||||
})
|
||||
return { items: created }
|
||||
},
|
||||
)
|
||||
|
||||
app.get<{ Params: { id: string } }>(
|
||||
'/agents/:id/host-firewall',
|
||||
async (req) => {
|
||||
const agent = repos.getAgent(app.db, req.params.id)
|
||||
if (!agent) throw new AppError('NOT_FOUND', 'Agent not found', 404)
|
||||
const snap = repos.getHostFirewallSnapshot(app.db, agent.id)
|
||||
if (!snap) {
|
||||
return {
|
||||
collected_at: null,
|
||||
raw_digest: null,
|
||||
rules: [],
|
||||
listeners: [],
|
||||
}
|
||||
}
|
||||
let payload: { rules?: unknown[]; listeners?: unknown[] } = {}
|
||||
try {
|
||||
payload = JSON.parse(snap.payloadJson) as typeof payload
|
||||
} catch {
|
||||
payload = {}
|
||||
}
|
||||
return {
|
||||
collected_at: snap.collectedAt,
|
||||
raw_digest: snap.rawDigest,
|
||||
rules: payload.rules ?? [],
|
||||
listeners: payload.listeners ?? [],
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
export function digestHostFirewallPayload(json: string): string {
|
||||
return createHash('sha256').update(json).digest('hex').slice(0, 16)
|
||||
}
|
||||
@@ -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' })
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
} from '@evofw/shared'
|
||||
import { uniqCidrs } from '../uniq.js'
|
||||
|
||||
export const POLICY_APPLY_VERSION = 2 as const
|
||||
export const POLICY_APPLY_VERSION = 3 as const
|
||||
|
||||
export type PolicyChainStep = {
|
||||
setId: string | null
|
||||
@@ -20,6 +20,15 @@ export type PolicyChainStep = {
|
||||
cidrCount: number
|
||||
}
|
||||
|
||||
export type EvaluatedPortRule = {
|
||||
id: string
|
||||
action: 'open' | 'close'
|
||||
protocol: 'tcp' | 'udp'
|
||||
portStart: number
|
||||
portEnd: number
|
||||
srcCidrs: string[]
|
||||
}
|
||||
|
||||
export type EvaluatedPolicy = {
|
||||
generation: number
|
||||
hash: string
|
||||
@@ -29,6 +38,7 @@ export type EvaluatedPolicy = {
|
||||
policyMode: 'blacklist' | 'whitelist'
|
||||
denyCidrs: string[]
|
||||
allowCidrs: string[]
|
||||
portRules: EvaluatedPortRule[]
|
||||
conflictsDropped: number
|
||||
syncIntervalSec: number
|
||||
chain: PolicyChainStep[]
|
||||
@@ -40,6 +50,7 @@ export type EvaluatedPolicy = {
|
||||
cidrsAllow: number
|
||||
overrides: number
|
||||
conflictsDropped: number
|
||||
portRules: number
|
||||
}
|
||||
}
|
||||
|
||||
@@ -90,6 +101,54 @@ function sourceMeta(
|
||||
return { kind: 'list', label: name || listId || 'list' }
|
||||
}
|
||||
|
||||
function expandPortSrcCidrs(
|
||||
db: Db,
|
||||
row: {
|
||||
srcKind: string
|
||||
srcCidr: string | null
|
||||
listId: string | null
|
||||
},
|
||||
): string[] {
|
||||
if (row.srcKind === 'all') return ['0.0.0.0/0']
|
||||
if (row.srcKind === 'cidr' && row.srcCidr?.trim()) {
|
||||
return [row.srcCidr.trim()]
|
||||
}
|
||||
if (row.srcKind === 'list') {
|
||||
const cidrs = expandList(db, row.listId)
|
||||
return cidrs.length ? uniqCidrs(cidrs) : []
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
function expandPortRules(db: Db, agentId: string): EvaluatedPortRule[] {
|
||||
const rows = repos.listEnabledAgentPortRules(db, agentId)
|
||||
const out: EvaluatedPortRule[] = []
|
||||
for (const row of rows) {
|
||||
const action = row.action === 'close' ? 'close' : 'open'
|
||||
const srcCidrs = expandPortSrcCidrs(db, row)
|
||||
if (!srcCidrs.length) continue
|
||||
const portStart = Math.max(1, Math.min(65535, row.portStart))
|
||||
const portEnd = Math.max(portStart, Math.min(65535, row.portEnd))
|
||||
const protocols: Array<'tcp' | 'udp'> =
|
||||
row.protocol === 'udp'
|
||||
? ['udp']
|
||||
: row.protocol === 'both'
|
||||
? ['tcp', 'udp']
|
||||
: ['tcp']
|
||||
for (const protocol of protocols) {
|
||||
out.push({
|
||||
id: row.id,
|
||||
action,
|
||||
protocol,
|
||||
portStart,
|
||||
portEnd,
|
||||
srcCidrs,
|
||||
})
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** Evaluate allow/deny sets for an agent from assigned policy sets. */
|
||||
export function evaluateAgentPolicy(db: Db, agentId: string): EvaluatedPolicy {
|
||||
const agent = repos.getAgent(db, agentId)
|
||||
@@ -155,6 +214,7 @@ export function evaluateAgentPolicy(db: Db, agentId: string): EvaluatedPolicy {
|
||||
const conflictsDropped = allowRaw.length - allowCidrs.length
|
||||
const defaultAction = resolveDefaultAction(agent.defaultAction)
|
||||
const policyMode = legacyModeFromDefaultAction(defaultAction)
|
||||
const portRules = expandPortRules(db, agentId)
|
||||
|
||||
const payload = JSON.stringify({
|
||||
apply_version: POLICY_APPLY_VERSION,
|
||||
@@ -162,6 +222,7 @@ export function evaluateAgentPolicy(db: Db, agentId: string): EvaluatedPolicy {
|
||||
defaultAction,
|
||||
denyCidrs,
|
||||
allowCidrs,
|
||||
portRules,
|
||||
})
|
||||
const hash = `sha256:${createHash('sha256').update(payload).digest('hex')}`
|
||||
|
||||
@@ -176,6 +237,7 @@ export function evaluateAgentPolicy(db: Db, agentId: string): EvaluatedPolicy {
|
||||
policyMode,
|
||||
denyCidrs,
|
||||
allowCidrs,
|
||||
portRules,
|
||||
conflictsDropped,
|
||||
syncIntervalSec,
|
||||
chain,
|
||||
@@ -187,6 +249,7 @@ export function evaluateAgentPolicy(db: Db, agentId: string): EvaluatedPolicy {
|
||||
cidrsAllow: allowCidrs.length,
|
||||
overrides: overrides.length,
|
||||
conflictsDropped,
|
||||
portRules: portRules.length,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ function basePolicy(
|
||||
policyMode: 'blacklist',
|
||||
denyCidrs: ['1.2.3.0/24', '2001:db8::/32', '10.0.0.1/32'],
|
||||
allowCidrs: ['8.8.8.8/32', 'fe80::1/128'],
|
||||
portRules: [],
|
||||
conflictsDropped: 0,
|
||||
syncIntervalSec: 60,
|
||||
chain: [],
|
||||
@@ -27,6 +28,7 @@ function basePolicy(
|
||||
cidrsAllow: 1,
|
||||
overrides: 0,
|
||||
conflictsDropped: 0,
|
||||
portRules: 0,
|
||||
},
|
||||
...patch,
|
||||
}
|
||||
@@ -36,7 +38,7 @@ describe('renderMikrotikPolicyRsc', () => {
|
||||
it('renders accept default: lists + default-drop disabled', () => {
|
||||
const rsc = renderMikrotikPolicyRsc(basePolicy())
|
||||
expect(rsc).toContain(
|
||||
'# evofw hash=sha256:abc default_action=accept apply_version=2 gen=3',
|
||||
`# evofw hash=sha256:abc default_action=accept apply_version=${POLICY_APPLY_VERSION} gen=3`,
|
||||
)
|
||||
expect(rsc).toContain('list=EVOFW_DENY')
|
||||
expect(rsc).toContain('list=EVOFW_ALLOW')
|
||||
|
||||
@@ -100,7 +100,7 @@ describe('classic policy default_action', () => {
|
||||
chain: unknown[]
|
||||
}
|
||||
expect(body.default_action).toBe('drop')
|
||||
expect(body.apply_version).toBe(2)
|
||||
expect(body.apply_version).toBe(3)
|
||||
expect(body.deny_cidrs).toContain('10.0.0.1/32')
|
||||
expect(body.allow_cidrs).not.toContain('10.0.0.1/32')
|
||||
expect(body.allow_cidrs).toContain('10.0.0.2/32')
|
||||
|
||||
@@ -0,0 +1,304 @@
|
||||
import { describe, it, expect, afterAll } from 'vitest'
|
||||
import { buildApp } from '../app.js'
|
||||
import type { AppConfig } from '../config.js'
|
||||
|
||||
const testConfig: AppConfig = {
|
||||
databaseUrl: 'sqlite::memory:',
|
||||
jwtSecret: 'test',
|
||||
jwtTtlHours: 24,
|
||||
serverPort: 8080,
|
||||
staticDir: null,
|
||||
logLevel: 'error',
|
||||
authRequired: false,
|
||||
authIssuer: 'https://auth.test',
|
||||
authPortalUrl: 'http://localhost:5175',
|
||||
publicBaseUrl: 'https://fw.example.com',
|
||||
enrollSeed: 'test-seed',
|
||||
}
|
||||
|
||||
async function enrollApprovedLinux(
|
||||
app: Awaited<ReturnType<typeof buildApp>>,
|
||||
name: string,
|
||||
token: string,
|
||||
) {
|
||||
const created = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/install-links',
|
||||
payload: { name, platform: 'linux' },
|
||||
})
|
||||
expect(created.statusCode).toBe(201)
|
||||
const link = created.json() as { id: string; agent_id: string }
|
||||
|
||||
const enroll = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/v1/agent/enroll',
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
'x-evofw-seed': 'test-seed',
|
||||
},
|
||||
payload: {
|
||||
name,
|
||||
platform: 'linux',
|
||||
token,
|
||||
install_link_id: link.id,
|
||||
},
|
||||
})
|
||||
expect(enroll.statusCode).toBe(201)
|
||||
|
||||
await app.inject({
|
||||
method: 'POST',
|
||||
url: `/api/v1/agents/${link.agent_id}/approve`,
|
||||
})
|
||||
|
||||
return { agentId: link.agent_id, token }
|
||||
}
|
||||
|
||||
describe('port ACL + host firewall snapshot', () => {
|
||||
const appPromise = buildApp({ memory: true, config: testConfig })
|
||||
|
||||
afterAll(async () => {
|
||||
const app = await appPromise
|
||||
await app.close()
|
||||
})
|
||||
|
||||
it('CRUD port-rules bumps generation and expands in policy', async () => {
|
||||
const app = await appPromise
|
||||
await app.ready()
|
||||
|
||||
const { agentId, token } = await enrollApprovedLinux(
|
||||
app,
|
||||
'port-acl-01',
|
||||
'evofw_port_acl_token_abcdefghij',
|
||||
)
|
||||
|
||||
const before = await app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/v1/agents/${agentId}`,
|
||||
})
|
||||
const genBefore = (before.json() as { policy_generation: number })
|
||||
.policy_generation
|
||||
|
||||
const create = await app.inject({
|
||||
method: 'POST',
|
||||
url: `/api/v1/agents/${agentId}/port-rules`,
|
||||
payload: {
|
||||
action: 'open',
|
||||
protocol: 'tcp',
|
||||
port_start: 443,
|
||||
src_kind: 'cidr',
|
||||
src_cidr: '10.0.0.0/8',
|
||||
},
|
||||
})
|
||||
expect(create.statusCode).toBe(200)
|
||||
const rule = create.json() as { id: string; action: string; port_start: number }
|
||||
expect(rule.action).toBe('open')
|
||||
expect(rule.port_start).toBe(443)
|
||||
|
||||
const after = await app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/v1/agents/${agentId}`,
|
||||
})
|
||||
expect(
|
||||
(after.json() as { policy_generation: number }).policy_generation,
|
||||
).toBe(genBefore + 1)
|
||||
|
||||
const list = await app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/v1/agents/${agentId}/port-rules`,
|
||||
})
|
||||
expect(list.statusCode).toBe(200)
|
||||
expect((list.json() as { items: unknown[] }).items).toHaveLength(1)
|
||||
|
||||
const policy = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/v1/agent/policy',
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
})
|
||||
expect(policy.statusCode).toBe(200)
|
||||
const body = policy.json() as {
|
||||
apply_version: number
|
||||
port_rules: {
|
||||
action: string
|
||||
protocol: string
|
||||
port_start: number
|
||||
src_cidrs: string[]
|
||||
}[]
|
||||
}
|
||||
expect(body.apply_version).toBe(3)
|
||||
expect(body.port_rules).toHaveLength(1)
|
||||
expect(body.port_rules[0]?.src_cidrs).toEqual(['10.0.0.0/8'])
|
||||
expect(body.port_rules[0]?.protocol).toBe('tcp')
|
||||
|
||||
const patch = await app.inject({
|
||||
method: 'PATCH',
|
||||
url: `/api/v1/agents/${agentId}/port-rules/${rule.id}`,
|
||||
payload: { enabled: false },
|
||||
})
|
||||
expect(patch.statusCode).toBe(200)
|
||||
expect((patch.json() as { enabled: boolean }).enabled).toBe(false)
|
||||
|
||||
const policyOff = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/v1/agent/policy',
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
})
|
||||
expect(
|
||||
(policyOff.json() as { port_rules: unknown[] }).port_rules,
|
||||
).toHaveLength(0)
|
||||
|
||||
const del = await app.inject({
|
||||
method: 'DELETE',
|
||||
url: `/api/v1/agents/${agentId}/port-rules/${rule.id}`,
|
||||
})
|
||||
expect(del.statusCode).toBe(200)
|
||||
})
|
||||
|
||||
it('imports port-rules from IP list', async () => {
|
||||
const app = await appPromise
|
||||
await app.ready()
|
||||
|
||||
const { agentId } = await enrollApprovedLinux(
|
||||
app,
|
||||
'port-acl-import',
|
||||
'evofw_port_import_token_abcdefgh',
|
||||
)
|
||||
|
||||
const listRes = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/lists',
|
||||
payload: {
|
||||
name: 'port-src-list',
|
||||
type: 'static',
|
||||
entries: ['203.0.113.0/24'],
|
||||
},
|
||||
})
|
||||
expect(listRes.statusCode).toBe(200)
|
||||
const listId = (listRes.json() as { id: string }).id
|
||||
|
||||
const before = await app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/v1/agents/${agentId}`,
|
||||
})
|
||||
const genBefore = (before.json() as { policy_generation: number })
|
||||
.policy_generation
|
||||
|
||||
const imp = await app.inject({
|
||||
method: 'POST',
|
||||
url: `/api/v1/agents/${agentId}/port-rules/import`,
|
||||
payload: {
|
||||
from: 'list',
|
||||
list_id: listId,
|
||||
action: 'close',
|
||||
protocol: 'both',
|
||||
ports: [{ port_start: 22 }, { port_start: 80, port_end: 81 }],
|
||||
},
|
||||
})
|
||||
expect(imp.statusCode).toBe(200)
|
||||
const items = (imp.json() as { items: { src_kind: string; list_id: string }[] })
|
||||
.items
|
||||
expect(items).toHaveLength(2)
|
||||
expect(items.every((i) => i.src_kind === 'list' && i.list_id === listId)).toBe(
|
||||
true,
|
||||
)
|
||||
|
||||
const after = await app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/v1/agents/${agentId}`,
|
||||
})
|
||||
expect(
|
||||
(after.json() as { policy_generation: number }).policy_generation,
|
||||
).toBe(genBefore + 1)
|
||||
})
|
||||
|
||||
it('upserts host_firewall snapshot from apply-report', async () => {
|
||||
const app = await appPromise
|
||||
await app.ready()
|
||||
|
||||
const { agentId, token } = await enrollApprovedLinux(
|
||||
app,
|
||||
'host-fw-snap',
|
||||
'evofw_host_fw_token_abcdefghij',
|
||||
)
|
||||
|
||||
const report = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/v1/agent/apply-report',
|
||||
headers: {
|
||||
authorization: `Bearer ${token}`,
|
||||
'content-type': 'application/json',
|
||||
},
|
||||
payload: {
|
||||
status: 'ok',
|
||||
kernel_method: 'nft',
|
||||
host_firewall: {
|
||||
rules: [
|
||||
{
|
||||
ownership: 'evofw',
|
||||
backend: 'nft',
|
||||
table: 'evofw',
|
||||
chain: 'input',
|
||||
action: 'drop',
|
||||
protocol: 'tcp',
|
||||
dport: '22',
|
||||
raw: 'tcp dport 22 drop comment "evofw-port-x"',
|
||||
},
|
||||
{
|
||||
ownership: 'foreign',
|
||||
backend: 'iptables',
|
||||
chain: 'INPUT',
|
||||
action: 'ACCEPT',
|
||||
raw: '-A INPUT -p tcp --dport 80 -j ACCEPT',
|
||||
},
|
||||
],
|
||||
listeners: [
|
||||
{ protocol: 'tcp', port: 22, address: '0.0.0.0' },
|
||||
],
|
||||
},
|
||||
},
|
||||
})
|
||||
expect(report.statusCode).toBe(200)
|
||||
|
||||
const snap = await app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/v1/agents/${agentId}/host-firewall`,
|
||||
})
|
||||
expect(snap.statusCode).toBe(200)
|
||||
const body = snap.json() as {
|
||||
collected_at: string | null
|
||||
rules: { ownership: string }[]
|
||||
listeners: { port: number }[]
|
||||
}
|
||||
expect(body.collected_at).toBeTruthy()
|
||||
expect(body.rules).toHaveLength(2)
|
||||
expect(body.rules.some((r) => r.ownership === 'evofw')).toBe(true)
|
||||
expect(body.listeners[0]?.port).toBe(22)
|
||||
})
|
||||
|
||||
it('rejects port ACL on non-linux agents', async () => {
|
||||
const app = await appPromise
|
||||
await app.ready()
|
||||
|
||||
const created = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/install-links',
|
||||
payload: { name: 'mt-no-acl', platform: 'mikrotik' },
|
||||
})
|
||||
const agentId = (created.json() as { agent_id: string }).agent_id
|
||||
await app.inject({
|
||||
method: 'POST',
|
||||
url: `/api/v1/agents/${agentId}/approve`,
|
||||
})
|
||||
|
||||
const create = await app.inject({
|
||||
method: 'POST',
|
||||
url: `/api/v1/agents/${agentId}/port-rules`,
|
||||
payload: {
|
||||
action: 'open',
|
||||
protocol: 'tcp',
|
||||
port_start: 443,
|
||||
src_kind: 'all',
|
||||
},
|
||||
})
|
||||
expect(create.statusCode).toBe(400)
|
||||
})
|
||||
})
|
||||
@@ -35,6 +35,9 @@ import { AgentFactsPanel } from '@/components/agents/agent-facts-panel'
|
||||
import { AgentEffectiveCidrs } from '@/components/agents/agent-effective-cidrs'
|
||||
import { AgentBlockedIps } from '@/components/agents/agent-blocked-ips'
|
||||
import { AgentBlockedPorts } from '@/components/agents/agent-blocked-ports'
|
||||
import { AgentHostFirewall } from '@/components/agents/agent-host-firewall'
|
||||
import { AgentPortAcl } from '@/components/agents/agent-port-acl'
|
||||
import { CountedLineTabs } from '@/components/counted-line-tabs'
|
||||
import {
|
||||
AgentCloneSetsSheet,
|
||||
AgentOverrideSheet,
|
||||
@@ -47,6 +50,7 @@ import { apiFetch } from '@/lib/api'
|
||||
import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard'
|
||||
import { Button } from '@evofw/ui/components/button'
|
||||
import { Skeleton } from '@evofw/ui/components/skeleton'
|
||||
import { TabsContent } from '@evofw/ui/components/tabs'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -75,6 +79,7 @@ export function AgentDetailView({ agentId, onDelete }: AgentDetailViewProps) {
|
||||
const installRef = useRef<HTMLDivElement>(null)
|
||||
const [overrideOpen, setOverrideOpen] = useState(false)
|
||||
const [cloneOpen, setCloneOpen] = useState(false)
|
||||
const [fwTab, setFwTab] = useState('host')
|
||||
|
||||
const revoke = useMutation({
|
||||
mutationFn: () =>
|
||||
@@ -323,10 +328,37 @@ export function AgentDetailView({ agentId, onDelete }: AgentDetailViewProps) {
|
||||
/>
|
||||
|
||||
{a.platform === 'linux' ? (
|
||||
<AgentBlockedPorts agentId={agentId} />
|
||||
) : null}
|
||||
|
||||
<AgentBlockedIps agentId={agentId} platform={a.platform} />
|
||||
<div className="flex flex-col gap-3">
|
||||
<CountedLineTabs
|
||||
value={fwTab}
|
||||
onValueChange={setFwTab}
|
||||
tabs={[
|
||||
{ id: 'host', label: 'Host firewall' },
|
||||
{ id: 'acl', label: 'Port ACL' },
|
||||
{ id: 'hits', label: 'Blocked' },
|
||||
]}
|
||||
>
|
||||
<TabsContent value="host" className="mt-3">
|
||||
<AgentHostFirewall agentId={agentId} />
|
||||
</TabsContent>
|
||||
<TabsContent value="acl" className="mt-3">
|
||||
<AgentPortAcl agentId={agentId} />
|
||||
</TabsContent>
|
||||
<TabsContent
|
||||
value="hits"
|
||||
className="mt-3 flex flex-col gap-4"
|
||||
>
|
||||
<AgentBlockedPorts agentId={agentId} />
|
||||
<AgentBlockedIps
|
||||
agentId={agentId}
|
||||
platform={a.platform}
|
||||
/>
|
||||
</TabsContent>
|
||||
</CountedLineTabs>
|
||||
</div>
|
||||
) : (
|
||||
<AgentBlockedIps agentId={agentId} platform={a.platform} />
|
||||
)}
|
||||
</div>
|
||||
</DetailPanel.Section>
|
||||
</DetailPanel>
|
||||
|
||||
@@ -0,0 +1,329 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import {
|
||||
getCoreRowModel,
|
||||
useReactTable,
|
||||
type ColumnDef,
|
||||
} from '@tanstack/react-table'
|
||||
import { ShieldIcon } from 'lucide-react'
|
||||
import {
|
||||
Frame,
|
||||
FrameDescription,
|
||||
FrameHeader,
|
||||
FramePanel,
|
||||
FrameTitle,
|
||||
} from '@/components/reui/frame'
|
||||
import { DataGrid } from '@/components/reui/data-grid/data-grid'
|
||||
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
||||
import { DataGridTable } from '@/components/reui/data-grid/data-grid-table'
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
import { EmptyState } from '@/components/empty-state'
|
||||
import { CountedLineTabs } from '@/components/counted-line-tabs'
|
||||
import {
|
||||
agentHostFirewallQueryOptions,
|
||||
type HostFwRuleDto,
|
||||
type HostListenerDto,
|
||||
} from '@/queries'
|
||||
import { Skeleton } from '@evofw/ui/components/skeleton'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@evofw/ui/components/select'
|
||||
import { TabsContent } from '@evofw/ui/components/tabs'
|
||||
|
||||
/**
|
||||
* Observed host firewall + listeners (Linux).
|
||||
* Preview: https://reui.io/preview/base/data-grid-filtering-2
|
||||
* · https://reui.io/preview/base/empty-state-12
|
||||
*/
|
||||
|
||||
type AgentHostFirewallProps = {
|
||||
agentId: string
|
||||
}
|
||||
|
||||
export function AgentHostFirewall({ agentId }: AgentHostFirewallProps) {
|
||||
const q = useQuery(agentHostFirewallQueryOptions(agentId))
|
||||
const [tab, setTab] = useState('rules')
|
||||
const [ownership, setOwnership] = useState<'all' | 'evofw' | 'foreign'>('all')
|
||||
const [backend, setBackend] = useState<string>('all')
|
||||
|
||||
const rules = useMemo(() => {
|
||||
let items = q.data?.rules ?? []
|
||||
if (ownership !== 'all') {
|
||||
items = items.filter((r) => r.ownership === ownership)
|
||||
}
|
||||
if (backend !== 'all') {
|
||||
items = items.filter((r) => r.backend === backend)
|
||||
}
|
||||
return items
|
||||
}, [q.data?.rules, ownership, backend])
|
||||
|
||||
const listeners = q.data?.listeners ?? []
|
||||
const backends = useMemo(() => {
|
||||
const s = new Set((q.data?.rules ?? []).map((r) => r.backend))
|
||||
return Array.from(s).sort()
|
||||
}, [q.data?.rules])
|
||||
|
||||
const ruleCols = useMemo<ColumnDef<HostFwRuleDto>[]>(
|
||||
() => [
|
||||
{
|
||||
id: 'ownership',
|
||||
accessorKey: 'ownership',
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader column={column} title="Owner" />
|
||||
),
|
||||
cell: ({ row }) =>
|
||||
row.original.ownership === 'evofw' ? (
|
||||
<Badge variant="success" size="sm">
|
||||
EvoFW
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="secondary" size="sm">
|
||||
foreign
|
||||
</Badge>
|
||||
),
|
||||
meta: { headerTitle: 'Owner' },
|
||||
},
|
||||
{
|
||||
accessorKey: 'backend',
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader column={column} title="Backend" />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<span className="font-mono text-xs">{row.original.backend}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'chain',
|
||||
accessorFn: (r) => r.chain || r.table || '—',
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader column={column} title="Chain" />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<span className="font-mono text-muted-foreground text-xs">
|
||||
{row.original.chain || row.original.table || '—'}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: 'action',
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader column={column} title="Action" />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<span className="text-xs">{row.original.action || '—'}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'ports',
|
||||
accessorFn: (r) =>
|
||||
[r.protocol, r.dport].filter(Boolean).join('/') || '—',
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader column={column} title="Proto/Port" />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<span className="font-mono text-xs tabular-nums">
|
||||
{[row.original.protocol, row.original.dport]
|
||||
.filter(Boolean)
|
||||
.join('/') || '—'}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: 'saddr',
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader column={column} title="Src" />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<span className="font-mono text-xs">
|
||||
{row.original.saddr || '—'}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: 'raw',
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader column={column} title="Raw" />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<span
|
||||
className="text-muted-foreground block max-w-[280px] truncate font-mono text-[11px]"
|
||||
title={row.original.raw}
|
||||
>
|
||||
{row.original.raw}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
],
|
||||
[],
|
||||
)
|
||||
|
||||
const listenerCols = useMemo<ColumnDef<HostListenerDto>[]>(
|
||||
() => [
|
||||
{
|
||||
accessorKey: 'protocol',
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader column={column} title="Proto" />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<span className="font-mono text-xs uppercase">
|
||||
{row.original.protocol}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: 'port',
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader column={column} title="Port" />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<span className="font-mono text-xs tabular-nums">
|
||||
{row.original.port}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: 'address',
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader column={column} title="Address" />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<span className="font-mono text-xs">{row.original.address}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: 'process',
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader column={column} title="Process" />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{row.original.process || '—'}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
],
|
||||
[],
|
||||
)
|
||||
|
||||
const rulesTable = useReactTable({
|
||||
data: rules,
|
||||
columns: ruleCols,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getRowId: (r, i) => `${r.backend}-${r.chain}-${i}-${r.raw.slice(0, 40)}`,
|
||||
})
|
||||
const listenersTable = useReactTable({
|
||||
data: listeners,
|
||||
columns: listenerCols,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getRowId: (r, i) => `${r.protocol}-${r.address}-${r.port}-${i}`,
|
||||
})
|
||||
|
||||
return (
|
||||
<Frame dense spacing="sm">
|
||||
<FrameHeader>
|
||||
<FrameTitle>Host firewall</FrameTitle>
|
||||
<FrameDescription>
|
||||
Снимок nft/iptables/ufw/firewalld + listeners. EvoFW vs foreign.
|
||||
{q.data?.collected_at
|
||||
? ` Обновлено: ${new Date(q.data.collected_at).toLocaleString('ru-RU')}`
|
||||
: ' Пока нет снимка — дождитесь sync агента.'}
|
||||
</FrameDescription>
|
||||
</FrameHeader>
|
||||
<FramePanel className="flex flex-col gap-3 p-4">
|
||||
<CountedLineTabs
|
||||
value={tab}
|
||||
onValueChange={setTab}
|
||||
tabs={[
|
||||
{ id: 'rules', label: 'Rules', count: rules.length },
|
||||
{ id: 'listeners', label: 'Listeners', count: listeners.length },
|
||||
]}
|
||||
>
|
||||
<TabsContent value="rules" className="mt-3 flex flex-col gap-3">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Select
|
||||
value={ownership}
|
||||
onValueChange={(v) => {
|
||||
if (v) setOwnership(v as typeof ownership)
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-[140px]">
|
||||
<SelectValue placeholder="Owner" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All owners</SelectItem>
|
||||
<SelectItem value="evofw">EvoFW</SelectItem>
|
||||
<SelectItem value="foreign">Foreign</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select
|
||||
value={backend}
|
||||
onValueChange={(v) => {
|
||||
if (v) setBackend(v)
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-[140px]">
|
||||
<SelectValue placeholder="Backend" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All backends</SelectItem>
|
||||
{backends.map((b) => (
|
||||
<SelectItem key={b} value={b}>
|
||||
{b}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
{q.isLoading ? (
|
||||
<div className="flex flex-col gap-2">
|
||||
<Skeleton className="h-8 w-full" />
|
||||
<Skeleton className="h-8 w-full" />
|
||||
</div>
|
||||
) : rules.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={ShieldIcon}
|
||||
title="Нет правил в снимке"
|
||||
description="После sync Linux-агент пришлёт host_firewall."
|
||||
centered={false}
|
||||
className="py-6"
|
||||
/>
|
||||
) : (
|
||||
<DataGrid
|
||||
table={rulesTable}
|
||||
recordCount={rules.length}
|
||||
tableLayout={{ dense: true }}
|
||||
>
|
||||
<DataGridTable />
|
||||
</DataGrid>
|
||||
)}
|
||||
</TabsContent>
|
||||
<TabsContent value="listeners" className="mt-3">
|
||||
{q.isLoading ? (
|
||||
<Skeleton className="h-8 w-full" />
|
||||
) : listeners.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={ShieldIcon}
|
||||
title="Нет listeners"
|
||||
description="ss -lntu не вернул сокеты или снимок пуст."
|
||||
centered={false}
|
||||
className="py-6"
|
||||
/>
|
||||
) : (
|
||||
<DataGrid
|
||||
table={listenersTable}
|
||||
recordCount={listeners.length}
|
||||
tableLayout={{ dense: true }}
|
||||
>
|
||||
<DataGridTable />
|
||||
</DataGrid>
|
||||
)}
|
||||
</TabsContent>
|
||||
</CountedLineTabs>
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,697 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import {
|
||||
getCoreRowModel,
|
||||
useReactTable,
|
||||
type ColumnDef,
|
||||
} from '@tanstack/react-table'
|
||||
import { toast } from 'sonner'
|
||||
import { NetworkIcon, PencilIcon, PlusIcon, Trash2Icon } from 'lucide-react'
|
||||
import {
|
||||
Frame,
|
||||
FrameDescription,
|
||||
FrameHeader,
|
||||
FramePanel,
|
||||
FrameTitle,
|
||||
} from '@/components/reui/frame'
|
||||
import { DataGrid } from '@/components/reui/data-grid/data-grid'
|
||||
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
||||
import { DataGridTable } from '@/components/reui/data-grid/data-grid-table'
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
import { EmptyState } from '@/components/empty-state'
|
||||
import {
|
||||
agentPortRulesQueryOptions,
|
||||
listsQueryOptions,
|
||||
policySetsQueryOptions,
|
||||
type AgentPortRuleDto,
|
||||
} from '@/queries'
|
||||
import { apiFetch } from '@/lib/api'
|
||||
import { Button } from '@evofw/ui/components/button'
|
||||
import { Switch } from '@evofw/ui/components/switch'
|
||||
import { Skeleton } from '@evofw/ui/components/skeleton'
|
||||
import { Field, FieldLabel } from '@evofw/ui/components/field'
|
||||
import { Input } from '@evofw/ui/components/input'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@evofw/ui/components/select'
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetFooter,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from '@evofw/ui/components/sheet'
|
||||
import { ScrollArea } from '@evofw/ui/components/scroll-area'
|
||||
|
||||
/**
|
||||
* Desired Port ACL for Linux agent.
|
||||
* Preview: https://reui.io/preview/base/data-grid-filtering-2
|
||||
* · https://reui.io/preview/base/sheet-8
|
||||
*/
|
||||
|
||||
type AgentPortAclProps = {
|
||||
agentId: string
|
||||
}
|
||||
|
||||
type FormState = {
|
||||
action: 'open' | 'close'
|
||||
protocol: 'tcp' | 'udp' | 'both'
|
||||
port_start: string
|
||||
port_end: string
|
||||
src_kind: 'all' | 'cidr' | 'list'
|
||||
src_cidr: string
|
||||
list_id: string
|
||||
comment: string
|
||||
enabled: boolean
|
||||
}
|
||||
|
||||
const emptyForm = (): FormState => ({
|
||||
action: 'open',
|
||||
protocol: 'tcp',
|
||||
port_start: '',
|
||||
port_end: '',
|
||||
src_kind: 'all',
|
||||
src_cidr: '',
|
||||
list_id: '',
|
||||
comment: '',
|
||||
enabled: true,
|
||||
})
|
||||
|
||||
function formatPorts(r: AgentPortRuleDto): string {
|
||||
return r.port_start === r.port_end
|
||||
? String(r.port_start)
|
||||
: `${r.port_start}-${r.port_end}`
|
||||
}
|
||||
|
||||
function formatSrc(r: AgentPortRuleDto): string {
|
||||
if (r.src_kind === 'all') return 'all'
|
||||
if (r.src_kind === 'cidr') return r.src_cidr || '—'
|
||||
return r.list_name || r.list_id || 'list'
|
||||
}
|
||||
|
||||
export function AgentPortAcl({ agentId }: AgentPortAclProps) {
|
||||
const qc = useQueryClient()
|
||||
const q = useQuery(agentPortRulesQueryOptions(agentId))
|
||||
const listsQ = useQuery(listsQueryOptions())
|
||||
const setsQ = useQuery(policySetsQueryOptions())
|
||||
const [formOpen, setFormOpen] = useState(false)
|
||||
const [editing, setEditing] = useState<AgentPortRuleDto | null>(null)
|
||||
const [form, setForm] = useState<FormState>(emptyForm)
|
||||
const [importOpen, setImportOpen] = useState(false)
|
||||
const [impFrom, setImpFrom] = useState<'list' | 'set'>('list')
|
||||
const [impListId, setImpListId] = useState('')
|
||||
const [impSetId, setImpSetId] = useState('')
|
||||
const [impAction, setImpAction] = useState<'open' | 'close'>('open')
|
||||
const [impProtocol, setImpProtocol] = useState<'tcp' | 'udp' | 'both'>('tcp')
|
||||
const [impPorts, setImpPorts] = useState('22,80,443')
|
||||
|
||||
const invalidate = () => {
|
||||
void qc.invalidateQueries({ queryKey: ['agents', agentId, 'port-rules'] })
|
||||
void qc.invalidateQueries({ queryKey: ['agents', agentId] })
|
||||
void qc.invalidateQueries({ queryKey: ['agents', agentId, 'preview'] })
|
||||
}
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: async () => {
|
||||
const portStart = Number(form.port_start)
|
||||
const portEnd = form.port_end ? Number(form.port_end) : portStart
|
||||
const body = {
|
||||
action: form.action,
|
||||
protocol: form.protocol,
|
||||
port_start: portStart,
|
||||
port_end: portEnd,
|
||||
src_kind: form.src_kind,
|
||||
src_cidr: form.src_kind === 'cidr' ? form.src_cidr : undefined,
|
||||
list_id: form.src_kind === 'list' ? form.list_id : undefined,
|
||||
enabled: form.enabled,
|
||||
comment: form.comment || undefined,
|
||||
}
|
||||
if (editing) {
|
||||
return apiFetch(`/api/v1/agents/${agentId}/port-rules/${editing.id}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
}
|
||||
return apiFetch(`/api/v1/agents/${agentId}/port-rules`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success(editing ? 'Правило обновлено' : 'Правило создано')
|
||||
setFormOpen(false)
|
||||
setEditing(null)
|
||||
setForm(emptyForm())
|
||||
invalidate()
|
||||
},
|
||||
onError: (e: Error) => toast.error(e.message),
|
||||
})
|
||||
|
||||
const toggle = useMutation({
|
||||
mutationFn: (row: AgentPortRuleDto) =>
|
||||
apiFetch(`/api/v1/agents/${agentId}/port-rules/${row.id}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ enabled: !row.enabled }),
|
||||
}),
|
||||
onSuccess: () => {
|
||||
toast.success('Состояние обновлено')
|
||||
invalidate()
|
||||
},
|
||||
onError: (e: Error) => toast.error(e.message),
|
||||
})
|
||||
|
||||
const remove = useMutation({
|
||||
mutationFn: (id: string) =>
|
||||
apiFetch(`/api/v1/agents/${agentId}/port-rules/${id}`, {
|
||||
method: 'DELETE',
|
||||
}),
|
||||
onSuccess: () => {
|
||||
toast.success('Правило удалено')
|
||||
invalidate()
|
||||
},
|
||||
onError: (e: Error) => toast.error(e.message),
|
||||
})
|
||||
|
||||
const doImport = useMutation({
|
||||
mutationFn: async () => {
|
||||
const ports = impPorts
|
||||
.split(/[,\s]+/)
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean)
|
||||
.map((s) => {
|
||||
if (s.includes('-')) {
|
||||
const [a, b] = s.split('-')
|
||||
return {
|
||||
port_start: Number(a),
|
||||
port_end: Number(b),
|
||||
}
|
||||
}
|
||||
return { port_start: Number(s) }
|
||||
})
|
||||
return apiFetch(`/api/v1/agents/${agentId}/port-rules/import`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
from: impFrom,
|
||||
list_id: impFrom === 'list' ? impListId : undefined,
|
||||
set_id: impFrom === 'set' ? impSetId : undefined,
|
||||
action: impAction,
|
||||
protocol: impProtocol,
|
||||
ports,
|
||||
}),
|
||||
})
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success('Импорт выполнен')
|
||||
setImportOpen(false)
|
||||
invalidate()
|
||||
},
|
||||
onError: (e: Error) => toast.error(e.message),
|
||||
})
|
||||
|
||||
const openCreate = () => {
|
||||
setEditing(null)
|
||||
setForm(emptyForm())
|
||||
setFormOpen(true)
|
||||
}
|
||||
|
||||
const openEdit = (row: AgentPortRuleDto) => {
|
||||
setEditing(row)
|
||||
setForm({
|
||||
action: row.action,
|
||||
protocol: row.protocol,
|
||||
port_start: String(row.port_start),
|
||||
port_end:
|
||||
row.port_end !== row.port_start ? String(row.port_end) : '',
|
||||
src_kind: row.src_kind,
|
||||
src_cidr: row.src_cidr || '',
|
||||
list_id: row.list_id || '',
|
||||
comment: row.comment || '',
|
||||
enabled: row.enabled,
|
||||
})
|
||||
setFormOpen(true)
|
||||
}
|
||||
|
||||
const columns = useMemo<ColumnDef<AgentPortRuleDto>[]>(
|
||||
() => [
|
||||
{
|
||||
accessorKey: 'action',
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader column={column} title="Action" />
|
||||
),
|
||||
cell: ({ row }) =>
|
||||
row.original.action === 'open' ? (
|
||||
<Badge variant="success" size="sm">
|
||||
open
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="destructive" size="sm">
|
||||
close
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: 'protocol',
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader column={column} title="Proto" />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<span className="font-mono text-xs uppercase">
|
||||
{row.original.protocol}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'ports',
|
||||
accessorFn: formatPorts,
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader column={column} title="Ports" />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<span className="font-mono text-xs tabular-nums">
|
||||
{formatPorts(row.original)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'src',
|
||||
accessorFn: formatSrc,
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader column={column} title="Src" />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<span className="font-mono text-xs">{formatSrc(row.original)}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'enabled',
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader column={column} title="On" />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<Switch
|
||||
checked={row.original.enabled}
|
||||
onCheckedChange={() => toggle.mutate(row.original)}
|
||||
aria-label="toggle enabled"
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
header: () => <span className="sr-only">Actions</span>,
|
||||
cell: ({ row }) => (
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="size-7"
|
||||
onClick={() => openEdit(row.original)}
|
||||
aria-label="Edit"
|
||||
>
|
||||
<PencilIcon className="size-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="size-7"
|
||||
onClick={() => remove.mutate(row.original.id)}
|
||||
aria-label="Delete"
|
||||
>
|
||||
<Trash2Icon className="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
],
|
||||
[toggle, remove],
|
||||
)
|
||||
|
||||
const data = q.data?.items ?? []
|
||||
const table = useReactTable({
|
||||
data,
|
||||
columns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getRowId: (r) => r.id,
|
||||
})
|
||||
|
||||
const lists = listsQ.data?.items ?? []
|
||||
const sets = setsQ.data?.items ?? []
|
||||
|
||||
return (
|
||||
<>
|
||||
<Frame dense spacing="sm">
|
||||
<FrameHeader>
|
||||
<div className="flex flex-wrap items-start justify-between gap-2">
|
||||
<div className="flex flex-col gap-px">
|
||||
<FrameTitle>Port ACL</FrameTitle>
|
||||
<FrameDescription>
|
||||
Open/close портов для all / CIDR / IP-list. Apply через nft
|
||||
(upgrade install-ссылкой).
|
||||
</FrameDescription>
|
||||
</div>
|
||||
<div className="flex shrink-0 flex-wrap gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => setImportOpen(true)}
|
||||
>
|
||||
Импорт
|
||||
</Button>
|
||||
<Button type="button" size="sm" onClick={openCreate}>
|
||||
<PlusIcon className="size-4" />
|
||||
Добавить
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</FrameHeader>
|
||||
<FramePanel className="p-0">
|
||||
{q.isLoading ? (
|
||||
<div className="flex flex-col gap-2 p-4">
|
||||
<Skeleton className="h-8 w-full" />
|
||||
<Skeleton className="h-8 w-full" />
|
||||
</div>
|
||||
) : data.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={NetworkIcon}
|
||||
title="Нет Port ACL"
|
||||
description="Добавьте open/close или импортируйте источники из списка/набора."
|
||||
centered={false}
|
||||
className="py-8"
|
||||
action={
|
||||
<Button type="button" size="sm" onClick={openCreate}>
|
||||
Добавить
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<DataGrid
|
||||
table={table}
|
||||
recordCount={data.length}
|
||||
tableLayout={{ dense: true }}
|
||||
>
|
||||
<DataGridTable />
|
||||
</DataGrid>
|
||||
)}
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
|
||||
<Sheet open={formOpen} onOpenChange={setFormOpen}>
|
||||
<SheetContent className="flex flex-col gap-0 overflow-hidden sm:max-w-md">
|
||||
<SheetHeader className="shrink-0">
|
||||
<SheetTitle>
|
||||
{editing ? 'Редактировать Port ACL' : 'Новое Port ACL'}
|
||||
</SheetTitle>
|
||||
<SheetDescription>
|
||||
Preview: https://reui.io/preview/base/sheet-8
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
<ScrollArea className="flex-1 px-4">
|
||||
<div className="flex flex-col gap-3 py-2 pb-4">
|
||||
<Field>
|
||||
<FieldLabel>Action</FieldLabel>
|
||||
<Select
|
||||
value={form.action}
|
||||
onValueChange={(v) =>
|
||||
v && setForm((f) => ({ ...f, action: v as 'open' | 'close' }))
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="open">open</SelectItem>
|
||||
<SelectItem value="close">close</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel>Protocol</FieldLabel>
|
||||
<Select
|
||||
value={form.protocol}
|
||||
onValueChange={(v) =>
|
||||
v &&
|
||||
setForm((f) => ({
|
||||
...f,
|
||||
protocol: v as FormState['protocol'],
|
||||
}))
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="tcp">tcp</SelectItem>
|
||||
<SelectItem value="udp">udp</SelectItem>
|
||||
<SelectItem value="both">both</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<Field>
|
||||
<FieldLabel>Port start</FieldLabel>
|
||||
<Input
|
||||
value={form.port_start}
|
||||
onChange={(e) =>
|
||||
setForm((f) => ({ ...f, port_start: e.target.value }))
|
||||
}
|
||||
inputMode="numeric"
|
||||
placeholder="443"
|
||||
/>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel>Port end</FieldLabel>
|
||||
<Input
|
||||
value={form.port_end}
|
||||
onChange={(e) =>
|
||||
setForm((f) => ({ ...f, port_end: e.target.value }))
|
||||
}
|
||||
inputMode="numeric"
|
||||
placeholder="optional"
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
<Field>
|
||||
<FieldLabel>Source</FieldLabel>
|
||||
<Select
|
||||
value={form.src_kind}
|
||||
onValueChange={(v) =>
|
||||
v &&
|
||||
setForm((f) => ({
|
||||
...f,
|
||||
src_kind: v as FormState['src_kind'],
|
||||
}))
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">all (0.0.0.0/0)</SelectItem>
|
||||
<SelectItem value="cidr">CIDR / IP</SelectItem>
|
||||
<SelectItem value="list">IP list</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
{form.src_kind === 'cidr' ? (
|
||||
<Field>
|
||||
<FieldLabel>CIDR</FieldLabel>
|
||||
<Input
|
||||
value={form.src_cidr}
|
||||
onChange={(e) =>
|
||||
setForm((f) => ({ ...f, src_cidr: e.target.value }))
|
||||
}
|
||||
placeholder="10.0.0.0/8"
|
||||
/>
|
||||
</Field>
|
||||
) : null}
|
||||
{form.src_kind === 'list' ? (
|
||||
<Field>
|
||||
<FieldLabel>List</FieldLabel>
|
||||
<Select
|
||||
value={form.list_id}
|
||||
onValueChange={(v) =>
|
||||
v && setForm((f) => ({ ...f, list_id: v }))
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Выберите список" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{lists.map((l) => (
|
||||
<SelectItem key={l.id} value={l.id}>
|
||||
{l.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
) : null}
|
||||
<Field>
|
||||
<FieldLabel>Comment</FieldLabel>
|
||||
<Input
|
||||
value={form.comment}
|
||||
onChange={(e) =>
|
||||
setForm((f) => ({ ...f, comment: e.target.value }))
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
<SheetFooter className="shrink-0 flex-row gap-2 border-t">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => setFormOpen(false)}
|
||||
>
|
||||
Отмена
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
disabled={save.isPending || !form.port_start}
|
||||
onClick={() => save.mutate()}
|
||||
>
|
||||
Сохранить
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
|
||||
<Sheet open={importOpen} onOpenChange={setImportOpen}>
|
||||
<SheetContent className="flex flex-col gap-0 overflow-hidden sm:max-w-md">
|
||||
<SheetHeader className="shrink-0">
|
||||
<SheetTitle>Импорт Port ACL</SheetTitle>
|
||||
<SheetDescription>
|
||||
Источник CIDR/list из набора или IP-list + порты.
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
<ScrollArea className="flex-1 px-4">
|
||||
<div className="flex flex-col gap-3 py-2 pb-4">
|
||||
<Field>
|
||||
<FieldLabel>From</FieldLabel>
|
||||
<Select
|
||||
value={impFrom}
|
||||
onValueChange={(v) =>
|
||||
v && setImpFrom(v as 'list' | 'set')
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="list">IP list</SelectItem>
|
||||
<SelectItem value="set">Policy set</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
{impFrom === 'list' ? (
|
||||
<Field>
|
||||
<FieldLabel>List</FieldLabel>
|
||||
<Select
|
||||
value={impListId}
|
||||
onValueChange={(v) => v && setImpListId(v)}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Список" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{lists.map((l) => (
|
||||
<SelectItem key={l.id} value={l.id}>
|
||||
{l.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
) : (
|
||||
<Field>
|
||||
<FieldLabel>Set</FieldLabel>
|
||||
<Select
|
||||
value={impSetId}
|
||||
onValueChange={(v) => v && setImpSetId(v)}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Набор" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{sets.map((s) => (
|
||||
<SelectItem key={s.id} value={s.id}>
|
||||
{s.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
)}
|
||||
<Field>
|
||||
<FieldLabel>Action</FieldLabel>
|
||||
<Select
|
||||
value={impAction}
|
||||
onValueChange={(v) =>
|
||||
v && setImpAction(v as 'open' | 'close')
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="open">open</SelectItem>
|
||||
<SelectItem value="close">close</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel>Protocol</FieldLabel>
|
||||
<Select
|
||||
value={impProtocol}
|
||||
onValueChange={(v) =>
|
||||
v && setImpProtocol(v as typeof impProtocol)
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="tcp">tcp</SelectItem>
|
||||
<SelectItem value="udp">udp</SelectItem>
|
||||
<SelectItem value="both">both</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel>Ports</FieldLabel>
|
||||
<Input
|
||||
value={impPorts}
|
||||
onChange={(e) => setImpPorts(e.target.value)}
|
||||
placeholder="22,80,443 или 8000-8010"
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
<SheetFooter className="shrink-0 flex-row gap-2 border-t">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => setImportOpen(false)}
|
||||
>
|
||||
Отмена
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
disabled={doImport.isPending}
|
||||
onClick={() => doImport.mutate()}
|
||||
>
|
||||
Импортировать
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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'],
|
||||
|
||||
@@ -81,6 +81,31 @@ IPv6 skipped.
|
||||
|
||||
IPv6 skipped.
|
||||
|
||||
## Host firewall snapshot + Port ACL (Linux)
|
||||
|
||||
### Observed (Host firewall)
|
||||
|
||||
Каждый sync агент собирает best-effort снимок и шлёт в `apply-report.host_firewall`:
|
||||
|
||||
- `nft list ruleset`, `iptables-save`, optional `ufw` / `firewall-cmd`, `ss -lntu`
|
||||
- Каждое правило: `ownership: evofw | foreign` (метка по имени table/chain/comment `evofw`)
|
||||
- CP: `agent_host_firewall_snapshots`; `GET /api/v1/agents/:id/host-firewall`
|
||||
- UI agent detail → tab **Host firewall** (Rules / Listeners)
|
||||
|
||||
Foreign правила **только отображаются** — с CP не редактируются.
|
||||
|
||||
### Desired (Port ACL)
|
||||
|
||||
Per-agent таблица `agent_port_rules`: `open|close`, `tcp|udp|both`, port range, `src_kind: all|cidr|list`.
|
||||
|
||||
- API: CRUD `/api/v1/agents/:id/port-rules`, import `/port-rules/import` (from list или policy set sources)
|
||||
- Policy `apply_version: 3` → `port_rules[]` с expanded `src_cidrs`
|
||||
- nft apply: после L3 allow — close drop, затем open accept (`comment "evofw-port-<id>"`)
|
||||
- UI: tab **Port ACL** (DataGrid + Sheet create/edit + Import)
|
||||
- ipset / MikroTik: без L4 apply; секции скрыты для non-linux
|
||||
|
||||
Мутация Port ACL бампит `policy_generation` → agent re-apply.
|
||||
|
||||
## MikroTik (RouterOS 7.21+)
|
||||
|
||||
В UI `/agents` → **Добавить агента** → platform **MikroTik**. Скопируйте one-liner:
|
||||
|
||||
@@ -17,8 +17,8 @@
|
||||
|
||||
1. **Enroll** — `POST /v1/agent/enroll` + `X-EvoFW-Seed` → pending agent
|
||||
2. **Approve** — UI/API → status approved
|
||||
3. **Policy** — `GET /v1/agent/policy` → deny/allow CIDRs + `default_action` + hash (`apply_version: 2`)
|
||||
4. **Apply** — agent пишет kernel rules, `POST /v1/agent/apply-report` + stats sample
|
||||
3. **Policy** — `GET /v1/agent/policy` → deny/allow CIDRs + `default_action` + optional `port_rules` + hash (`apply_version: 3`)
|
||||
4. **Apply** — agent пишет kernel rules (L3 + L4 port ACL на nft), `POST /v1/agent/apply-report` + stats + optional `host_firewall` snapshot
|
||||
5. **Lists refresh** — cron каждые 5 мин (json_url / domains / evobgp_community)
|
||||
|
||||
## Политика
|
||||
@@ -27,8 +27,9 @@
|
||||
- Правило в наборе: `action: deny | allow` + ровно один источник — IP-список (`list_id`), CIDR или DNS-имя (`hostname` → A/AAAA, кэш в `policy_rule_resolved`)
|
||||
- Evaluate: правила всех назначенных enabled-наборов (sort + priority) + `ip_overrides`
|
||||
- Цепочка ядра **всегда**: deny → allow → `default_action` (`accept` | `drop` на агенте)
|
||||
- На Linux nft: после allow — **Port ACL** (`close` drop, затем `open` accept) из `agent_port_rules`
|
||||
- Exact overlap: `allow \ deny` (`conflicts_dropped`); deny wins
|
||||
- Overrides, смена наборов, `default_action` и refresh DNS/lists бампят `policy_generation`
|
||||
- Overrides, смена наборов, `default_action`, Port ACL и refresh DNS/lists бампят `policy_generation`
|
||||
|
||||
## Auth
|
||||
|
||||
|
||||
+257
-1
@@ -457,6 +457,161 @@ paths:
|
||||
packets: { type: integer }
|
||||
last_seen_at: { type: string, format: date-time }
|
||||
|
||||
/api/v1/agents/{id}/port-rules:
|
||||
get:
|
||||
summary: Desired Port ACL rules (Linux)
|
||||
tags: [ops]
|
||||
security: [{ bearerAuth: [] }]
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/Id'
|
||||
responses:
|
||||
'200':
|
||||
description: Port ACL rules
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
items:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/AgentPortRule'
|
||||
post:
|
||||
summary: Create Port ACL rule
|
||||
tags: [ops]
|
||||
security: [{ bearerAuth: [] }]
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/Id'
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/CreateAgentPortRule'
|
||||
responses:
|
||||
'200':
|
||||
description: Created rule
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/AgentPortRule'
|
||||
'400':
|
||||
description: Validation / non-linux
|
||||
|
||||
/api/v1/agents/{id}/port-rules/import:
|
||||
post:
|
||||
summary: Import Port ACL from IP list or policy set sources
|
||||
tags: [ops]
|
||||
security: [{ bearerAuth: [] }]
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/Id'
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [from, action, ports]
|
||||
properties:
|
||||
from: { type: string, enum: [list, set] }
|
||||
list_id: { type: string }
|
||||
set_id: { type: string }
|
||||
action: { type: string, enum: [open, close] }
|
||||
protocol: { type: string, enum: [tcp, udp, both], default: tcp }
|
||||
ports:
|
||||
type: array
|
||||
minItems: 1
|
||||
maxItems: 50
|
||||
items:
|
||||
type: object
|
||||
required: [port_start]
|
||||
properties:
|
||||
port_start: { type: integer, minimum: 1, maximum: 65535 }
|
||||
port_end: { type: integer, minimum: 1, maximum: 65535 }
|
||||
enabled: { type: boolean, default: true }
|
||||
comment: { type: string, maxLength: 500 }
|
||||
responses:
|
||||
'200':
|
||||
description: Created rules
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
items:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/AgentPortRule'
|
||||
|
||||
/api/v1/agents/{id}/port-rules/{ruleId}:
|
||||
patch:
|
||||
summary: Update Port ACL rule
|
||||
tags: [ops]
|
||||
security: [{ bearerAuth: [] }]
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/Id'
|
||||
- name: ruleId
|
||||
in: path
|
||||
required: true
|
||||
schema: { type: string }
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/UpdateAgentPortRule'
|
||||
responses:
|
||||
'200':
|
||||
description: Updated rule
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/AgentPortRule'
|
||||
delete:
|
||||
summary: Delete Port ACL rule
|
||||
tags: [ops]
|
||||
security: [{ bearerAuth: [] }]
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/Id'
|
||||
- name: ruleId
|
||||
in: path
|
||||
required: true
|
||||
schema: { type: string }
|
||||
responses:
|
||||
'200':
|
||||
description: Deleted
|
||||
|
||||
/api/v1/agents/{id}/host-firewall:
|
||||
get:
|
||||
summary: Last observed host firewall snapshot (Linux)
|
||||
tags: [ops]
|
||||
security: [{ bearerAuth: [] }]
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/Id'
|
||||
responses:
|
||||
'200':
|
||||
description: Snapshot (empty if never reported)
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
collected_at:
|
||||
type: string
|
||||
format: date-time
|
||||
nullable: true
|
||||
raw_digest:
|
||||
type: string
|
||||
nullable: true
|
||||
rules:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/HostFwRule'
|
||||
listeners:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/HostListener'
|
||||
|
||||
/api/v1/integrations/evobgp/communities:
|
||||
get:
|
||||
summary: Proxy EvoBGP communities
|
||||
@@ -557,7 +712,7 @@ paths:
|
||||
|
||||
/v1/agent/apply-report:
|
||||
post:
|
||||
summary: Apply report + packet stats (+ optional ip_hits / port_hits)
|
||||
summary: Apply report + packet stats (+ optional ip_hits / port_hits / host_firewall)
|
||||
tags: [agent]
|
||||
security: [{ agentToken: [] }]
|
||||
requestBody:
|
||||
@@ -597,6 +752,8 @@ paths:
|
||||
port: { type: integer, minimum: 1, maximum: 65535 }
|
||||
protocol: { type: string, enum: [tcp, udp] }
|
||||
packets: { type: integer, minimum: 0 }
|
||||
host_firewall:
|
||||
$ref: '#/components/schemas/HostFirewallPayload'
|
||||
responses:
|
||||
'200':
|
||||
description: OK
|
||||
@@ -608,6 +765,105 @@ components:
|
||||
in: path
|
||||
required: true
|
||||
schema: { type: string }
|
||||
schemas:
|
||||
AgentPortRule:
|
||||
type: object
|
||||
required:
|
||||
[
|
||||
id,
|
||||
agent_id,
|
||||
action,
|
||||
protocol,
|
||||
port_start,
|
||||
port_end,
|
||||
src_kind,
|
||||
enabled,
|
||||
priority,
|
||||
created_at,
|
||||
updated_at,
|
||||
]
|
||||
properties:
|
||||
id: { type: string }
|
||||
agent_id: { type: string }
|
||||
action: { type: string, enum: [open, close] }
|
||||
protocol: { type: string, enum: [tcp, udp, both] }
|
||||
port_start: { type: integer, minimum: 1, maximum: 65535 }
|
||||
port_end: { type: integer, minimum: 1, maximum: 65535 }
|
||||
src_kind: { type: string, enum: [all, cidr, list] }
|
||||
src_cidr: { type: string, nullable: true }
|
||||
list_id: { type: string, nullable: true }
|
||||
list_name: { type: string, nullable: true }
|
||||
enabled: { type: boolean }
|
||||
comment: { type: string, nullable: true }
|
||||
priority: { type: integer }
|
||||
created_at: { type: string, format: date-time }
|
||||
updated_at: { type: string, format: date-time }
|
||||
CreateAgentPortRule:
|
||||
type: object
|
||||
required: [action, port_start]
|
||||
properties:
|
||||
action: { type: string, enum: [open, close] }
|
||||
protocol: { type: string, enum: [tcp, udp, both], default: tcp }
|
||||
port_start: { type: integer, minimum: 1, maximum: 65535 }
|
||||
port_end: { type: integer, minimum: 1, maximum: 65535 }
|
||||
src_kind: { type: string, enum: [all, cidr, list], default: all }
|
||||
src_cidr: { type: string }
|
||||
list_id: { type: string }
|
||||
enabled: { type: boolean, default: true }
|
||||
comment: { type: string, maxLength: 500 }
|
||||
priority: { type: integer, default: 100 }
|
||||
UpdateAgentPortRule:
|
||||
type: object
|
||||
properties:
|
||||
action: { type: string, enum: [open, close] }
|
||||
protocol: { type: string, enum: [tcp, udp, both] }
|
||||
port_start: { type: integer, minimum: 1, maximum: 65535 }
|
||||
port_end: { type: integer, minimum: 1, maximum: 65535 }
|
||||
src_kind: { type: string, enum: [all, cidr, list] }
|
||||
src_cidr: { type: string, nullable: true }
|
||||
list_id: { type: string, nullable: true }
|
||||
enabled: { type: boolean }
|
||||
comment: { type: string, maxLength: 500, nullable: true }
|
||||
priority: { type: integer }
|
||||
HostFwRule:
|
||||
type: object
|
||||
required: [ownership, backend, raw]
|
||||
properties:
|
||||
ownership: { type: string, enum: [evofw, foreign] }
|
||||
backend:
|
||||
type: string
|
||||
enum: [nft, iptables, ufw, firewalld, listener]
|
||||
table: { type: string }
|
||||
chain: { type: string }
|
||||
action: { type: string }
|
||||
protocol: { type: string }
|
||||
dport: { type: string }
|
||||
sport: { type: string }
|
||||
saddr: { type: string }
|
||||
daddr: { type: string }
|
||||
comment: { type: string }
|
||||
raw: { type: string, maxLength: 512 }
|
||||
HostListener:
|
||||
type: object
|
||||
required: [protocol, port, address]
|
||||
properties:
|
||||
protocol: { type: string }
|
||||
port: { type: integer, minimum: 0, maximum: 65535 }
|
||||
address: { type: string }
|
||||
process: { type: string }
|
||||
HostFirewallPayload:
|
||||
type: object
|
||||
properties:
|
||||
rules:
|
||||
type: array
|
||||
maxItems: 500
|
||||
items:
|
||||
$ref: '#/components/schemas/HostFwRule'
|
||||
listeners:
|
||||
type: array
|
||||
maxItems: 200
|
||||
items:
|
||||
$ref: '#/components/schemas/HostListener'
|
||||
securitySchemes:
|
||||
bearerAuth:
|
||||
type: http
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
-- Per-agent L4 port ACL (desired state) + host firewall snapshot (observed).
|
||||
|
||||
CREATE TABLE IF NOT EXISTS agent_port_rules (
|
||||
id TEXT PRIMARY KEY NOT NULL,
|
||||
agent_id TEXT NOT NULL REFERENCES agents(id) ON DELETE CASCADE,
|
||||
action TEXT NOT NULL,
|
||||
protocol TEXT NOT NULL,
|
||||
port_start INTEGER NOT NULL,
|
||||
port_end INTEGER NOT NULL,
|
||||
src_kind TEXT NOT NULL,
|
||||
src_cidr TEXT,
|
||||
list_id TEXT REFERENCES ip_lists(id) ON DELETE SET NULL,
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
comment TEXT,
|
||||
priority INTEGER NOT NULL DEFAULT 100,
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_agent_port_rules_agent_priority
|
||||
ON agent_port_rules(agent_id, priority);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_agent_port_rules_agent_enabled
|
||||
ON agent_port_rules(agent_id, enabled);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS agent_host_firewall_snapshots (
|
||||
agent_id TEXT PRIMARY KEY NOT NULL REFERENCES agents(id) ON DELETE CASCADE,
|
||||
collected_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
||||
payload_json TEXT NOT NULL,
|
||||
raw_digest TEXT
|
||||
);
|
||||
@@ -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,
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import { and, asc, eq } from 'drizzle-orm'
|
||||
import type { Db } from '../client.js'
|
||||
import { agentHostFirewallSnapshots, agentPortRules } from '../schema.js'
|
||||
|
||||
export type AgentPortRuleRow = typeof agentPortRules.$inferSelect
|
||||
export type AgentPortRuleInsert = typeof agentPortRules.$inferInsert
|
||||
|
||||
export function listAgentPortRules(db: Db, agentId: string) {
|
||||
return db
|
||||
.select()
|
||||
.from(agentPortRules)
|
||||
.where(eq(agentPortRules.agentId, agentId))
|
||||
.orderBy(asc(agentPortRules.priority), asc(agentPortRules.createdAt))
|
||||
.all()
|
||||
}
|
||||
|
||||
export function listEnabledAgentPortRules(db: Db, agentId: string) {
|
||||
return db
|
||||
.select()
|
||||
.from(agentPortRules)
|
||||
.where(
|
||||
and(eq(agentPortRules.agentId, agentId), eq(agentPortRules.enabled, 1)),
|
||||
)
|
||||
.orderBy(asc(agentPortRules.priority), asc(agentPortRules.createdAt))
|
||||
.all()
|
||||
}
|
||||
|
||||
export function getAgentPortRule(db: Db, id: string) {
|
||||
return db.select().from(agentPortRules).where(eq(agentPortRules.id, id)).get()
|
||||
}
|
||||
|
||||
export function insertAgentPortRule(db: Db, row: AgentPortRuleInsert) {
|
||||
db.insert(agentPortRules).values(row).run()
|
||||
return getAgentPortRule(db, row.id)
|
||||
}
|
||||
|
||||
export function updateAgentPortRule(
|
||||
db: Db,
|
||||
id: string,
|
||||
patch: Partial<
|
||||
Omit<AgentPortRuleInsert, 'id' | 'agentId' | 'createdAt'>
|
||||
>,
|
||||
) {
|
||||
db.update(agentPortRules)
|
||||
.set({
|
||||
...patch,
|
||||
updatedAt: new Date().toISOString(),
|
||||
})
|
||||
.where(eq(agentPortRules.id, id))
|
||||
.run()
|
||||
return getAgentPortRule(db, id)
|
||||
}
|
||||
|
||||
export function deleteAgentPortRule(db: Db, id: string) {
|
||||
db.delete(agentPortRules).where(eq(agentPortRules.id, id)).run()
|
||||
}
|
||||
|
||||
export function upsertHostFirewallSnapshot(
|
||||
db: Db,
|
||||
agentId: string,
|
||||
payloadJson: string,
|
||||
collectedAt = new Date().toISOString(),
|
||||
rawDigest: string | null = null,
|
||||
) {
|
||||
const existing = db
|
||||
.select()
|
||||
.from(agentHostFirewallSnapshots)
|
||||
.where(eq(agentHostFirewallSnapshots.agentId, agentId))
|
||||
.get()
|
||||
if (existing) {
|
||||
db.update(agentHostFirewallSnapshots)
|
||||
.set({ payloadJson, collectedAt, rawDigest })
|
||||
.where(eq(agentHostFirewallSnapshots.agentId, agentId))
|
||||
.run()
|
||||
} else {
|
||||
db.insert(agentHostFirewallSnapshots)
|
||||
.values({ agentId, payloadJson, collectedAt, rawDigest })
|
||||
.run()
|
||||
}
|
||||
return db
|
||||
.select()
|
||||
.from(agentHostFirewallSnapshots)
|
||||
.where(eq(agentHostFirewallSnapshots.agentId, agentId))
|
||||
.get()
|
||||
}
|
||||
|
||||
export function getHostFirewallSnapshot(db: Db, agentId: string) {
|
||||
return db
|
||||
.select()
|
||||
.from(agentHostFirewallSnapshots)
|
||||
.where(eq(agentHostFirewallSnapshots.agentId, agentId))
|
||||
.get()
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -233,6 +233,42 @@ export const applyReportPortHitSchema = z.object({
|
||||
packets: z.number().int().nonnegative(),
|
||||
})
|
||||
|
||||
export const hostFwOwnershipSchema = z.enum(['evofw', 'foreign'])
|
||||
export const hostFwBackendSchema = z.enum([
|
||||
'nft',
|
||||
'iptables',
|
||||
'ufw',
|
||||
'firewalld',
|
||||
'listener',
|
||||
])
|
||||
|
||||
export const hostFwRuleSchema = z.object({
|
||||
ownership: hostFwOwnershipSchema,
|
||||
backend: hostFwBackendSchema,
|
||||
table: z.string().max(128).optional(),
|
||||
chain: z.string().max(128).optional(),
|
||||
action: z.string().max(64).optional(),
|
||||
protocol: z.string().max(16).optional(),
|
||||
dport: z.string().max(64).optional(),
|
||||
sport: z.string().max(64).optional(),
|
||||
saddr: z.string().max(128).optional(),
|
||||
daddr: z.string().max(128).optional(),
|
||||
comment: z.string().max(256).optional(),
|
||||
raw: z.string().max(512),
|
||||
})
|
||||
|
||||
export const hostListenerSchema = z.object({
|
||||
protocol: z.string().max(16),
|
||||
port: z.number().int().min(0).max(65535),
|
||||
address: z.string().max(128),
|
||||
process: z.string().max(128).optional(),
|
||||
})
|
||||
|
||||
export const hostFirewallPayloadSchema = z.object({
|
||||
rules: z.array(hostFwRuleSchema).max(500).default([]),
|
||||
listeners: z.array(hostListenerSchema).max(200).default([]),
|
||||
})
|
||||
|
||||
export const applyReportBodySchema = z.object({
|
||||
status: z.string(),
|
||||
prefix_count: z.number().int().optional(),
|
||||
@@ -245,6 +281,129 @@ export const applyReportBodySchema = z.object({
|
||||
ip_hits: z.array(applyReportIpHitSchema).max(200).optional(),
|
||||
/** Linux nft dynamic set per-(ip, proto, dport) deny hits (top-N). */
|
||||
port_hits: z.array(applyReportPortHitSchema).max(500).optional(),
|
||||
/** Observed host firewall + listeners (Linux). */
|
||||
host_firewall: hostFirewallPayloadSchema.optional(),
|
||||
})
|
||||
|
||||
export const agentPortRuleActionSchema = z.enum(['open', 'close'])
|
||||
export const agentPortRuleProtocolSchema = z.enum(['tcp', 'udp', 'both'])
|
||||
export const agentPortRuleSrcKindSchema = z.enum(['all', 'cidr', 'list'])
|
||||
|
||||
export const agentPortRuleSchema = z.object({
|
||||
id: z.string(),
|
||||
agent_id: z.string(),
|
||||
action: agentPortRuleActionSchema,
|
||||
protocol: agentPortRuleProtocolSchema,
|
||||
port_start: z.number().int().min(1).max(65535),
|
||||
port_end: z.number().int().min(1).max(65535),
|
||||
src_kind: agentPortRuleSrcKindSchema,
|
||||
src_cidr: z.string().nullable().optional(),
|
||||
list_id: z.string().nullable().optional(),
|
||||
list_name: z.string().nullable().optional(),
|
||||
enabled: z.boolean(),
|
||||
comment: z.string().nullable().optional(),
|
||||
priority: z.number().int(),
|
||||
created_at: z.string(),
|
||||
updated_at: z.string(),
|
||||
})
|
||||
|
||||
export const createAgentPortRuleBodySchema = z
|
||||
.object({
|
||||
action: agentPortRuleActionSchema,
|
||||
protocol: agentPortRuleProtocolSchema.default('tcp'),
|
||||
port_start: z.number().int().min(1).max(65535),
|
||||
port_end: z.number().int().min(1).max(65535).optional(),
|
||||
src_kind: agentPortRuleSrcKindSchema.default('all'),
|
||||
src_cidr: z.string().min(1).max(64).optional(),
|
||||
list_id: z.string().min(1).optional(),
|
||||
enabled: z.boolean().optional().default(true),
|
||||
comment: z.string().max(500).optional(),
|
||||
priority: z.number().int().optional().default(100),
|
||||
})
|
||||
.superRefine((v, ctx) => {
|
||||
const end = v.port_end ?? v.port_start
|
||||
if (end < v.port_start) {
|
||||
ctx.addIssue({
|
||||
code: 'custom',
|
||||
message: 'port_end must be >= port_start',
|
||||
path: ['port_end'],
|
||||
})
|
||||
}
|
||||
if (v.src_kind === 'cidr' && !v.src_cidr?.trim()) {
|
||||
ctx.addIssue({
|
||||
code: 'custom',
|
||||
message: 'src_cidr required when src_kind=cidr',
|
||||
path: ['src_cidr'],
|
||||
})
|
||||
}
|
||||
if (v.src_kind === 'list' && !v.list_id?.trim()) {
|
||||
ctx.addIssue({
|
||||
code: 'custom',
|
||||
message: 'list_id required when src_kind=list',
|
||||
path: ['list_id'],
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
export const updateAgentPortRuleBodySchema = z
|
||||
.object({
|
||||
action: agentPortRuleActionSchema.optional(),
|
||||
protocol: agentPortRuleProtocolSchema.optional(),
|
||||
port_start: z.number().int().min(1).max(65535).optional(),
|
||||
port_end: z.number().int().min(1).max(65535).optional(),
|
||||
src_kind: agentPortRuleSrcKindSchema.optional(),
|
||||
src_cidr: z.string().min(1).max(64).nullable().optional(),
|
||||
list_id: z.string().min(1).nullable().optional(),
|
||||
enabled: z.boolean().optional(),
|
||||
comment: z.string().max(500).nullable().optional(),
|
||||
priority: z.number().int().optional(),
|
||||
})
|
||||
.refine((o) => Object.keys(o).length > 0, { message: 'empty update' })
|
||||
|
||||
export const importAgentPortRulesBodySchema = z
|
||||
.object({
|
||||
from: z.enum(['list', 'set']),
|
||||
list_id: z.string().min(1).optional(),
|
||||
set_id: z.string().min(1).optional(),
|
||||
action: agentPortRuleActionSchema,
|
||||
protocol: agentPortRuleProtocolSchema.default('tcp'),
|
||||
ports: z
|
||||
.array(
|
||||
z.object({
|
||||
port_start: z.number().int().min(1).max(65535),
|
||||
port_end: z.number().int().min(1).max(65535).optional(),
|
||||
}),
|
||||
)
|
||||
.min(1)
|
||||
.max(50),
|
||||
enabled: z.boolean().optional().default(true),
|
||||
comment: z.string().max(500).optional(),
|
||||
})
|
||||
.superRefine((v, ctx) => {
|
||||
if (v.from === 'list' && !v.list_id?.trim()) {
|
||||
ctx.addIssue({
|
||||
code: 'custom',
|
||||
message: 'list_id required when from=list',
|
||||
path: ['list_id'],
|
||||
})
|
||||
}
|
||||
if (v.from === 'set' && !v.set_id?.trim()) {
|
||||
ctx.addIssue({
|
||||
code: 'custom',
|
||||
message: 'set_id required when from=set',
|
||||
path: ['set_id'],
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
/** Expanded port rule for agent policy apply_version >= 3. */
|
||||
export const agentPolicyPortRuleSchema = z.object({
|
||||
id: z.string(),
|
||||
action: agentPortRuleActionSchema,
|
||||
protocol: z.enum(['tcp', 'udp']),
|
||||
port_start: z.number().int(),
|
||||
port_end: z.number().int(),
|
||||
src_cidrs: z.array(z.string()),
|
||||
})
|
||||
|
||||
export const agentIpPortStatSchema = z.object({
|
||||
@@ -277,6 +436,7 @@ export const agentPolicySchema = z.object({
|
||||
policy_mode: policyModeSchema.optional(),
|
||||
deny_cidrs: z.array(z.string()),
|
||||
allow_cidrs: z.array(z.string()),
|
||||
port_rules: z.array(agentPolicyPortRuleSchema).optional(),
|
||||
sync_interval_sec: z.number().int(),
|
||||
})
|
||||
|
||||
@@ -285,7 +445,7 @@ export const agentPolicyPreviewSchema = z.object({
|
||||
hash: z.string(),
|
||||
generation: z.number().int(),
|
||||
sync_interval_sec: z.number().int(),
|
||||
apply_version: z.literal(2),
|
||||
apply_version: z.literal(3),
|
||||
summary: z.object({
|
||||
sets: z.number().int(),
|
||||
rules_deny: z.number().int(),
|
||||
@@ -294,6 +454,7 @@ export const agentPolicyPreviewSchema = z.object({
|
||||
cidrs_allow: z.number().int(),
|
||||
overrides: z.number().int(),
|
||||
conflicts_dropped: z.number().int(),
|
||||
port_rules: z.number().int().optional(),
|
||||
}),
|
||||
chain: z.array(
|
||||
z.object({
|
||||
@@ -310,6 +471,7 @@ export const agentPolicyPreviewSchema = z.object({
|
||||
allow_cidrs: z.array(z.string()),
|
||||
deny_cidrs_total: z.number().int(),
|
||||
allow_cidrs_total: z.number().int(),
|
||||
port_rules: z.array(agentPolicyPortRuleSchema).optional(),
|
||||
})
|
||||
|
||||
export const dashboardStatsSchema = z.object({
|
||||
@@ -387,6 +549,11 @@ export type PolicySet = z.infer<typeof policySetSchema>
|
||||
export type IpOverride = z.infer<typeof ipOverrideSchema>
|
||||
export type AgentPolicy = z.infer<typeof agentPolicySchema>
|
||||
export type AgentPolicyPreview = z.infer<typeof agentPolicyPreviewSchema>
|
||||
export type AgentPortRule = z.infer<typeof agentPortRuleSchema>
|
||||
export type CreateAgentPortRuleBody = z.infer<typeof createAgentPortRuleBodySchema>
|
||||
export type UpdateAgentPortRuleBody = z.infer<typeof updateAgentPortRuleBodySchema>
|
||||
export type ImportAgentPortRulesBody = z.infer<typeof importAgentPortRulesBodySchema>
|
||||
export type HostFirewallPayload = z.infer<typeof hostFirewallPayloadSchema>
|
||||
export type DashboardStats = z.infer<typeof dashboardStatsSchema>
|
||||
export type InstallLink = z.infer<typeof installLinkSchema>
|
||||
export type EvobgpCommunity = z.infer<typeof evobgpCommunitySchema>
|
||||
|
||||
Reference in New Issue
Block a user