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)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user