feat: реализовать EvoFirewall V1 control plane
API, UI, Linux/MikroTik agents, IP lists, политики, stats, CI и интеграция с auth-portal/EvoBGP. Co-authored-by: Cursor <[email protected]>
This commit is contained in:
+28
-5
@@ -1,12 +1,35 @@
|
||||
{
|
||||
"name": "@evofw/api",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "echo \"api: scaffold — Fastify app not initialized yet\"",
|
||||
"build": "echo \"api: scaffold — skip\"",
|
||||
"lint": "echo \"api: scaffold — skip\"",
|
||||
"test": "echo \"api: scaffold — skip\""
|
||||
"dev": "tsx watch src/server.ts",
|
||||
"build": "tsup src/server.ts --format esm --dts --publicDir src/agent-scripts && node -e \"const fs=require('fs');const p='dist/agent-scripts';fs.mkdirSync(p,{recursive:true});for(const f of fs.readdirSync('src/agent-scripts'))fs.copyFileSync('src/agent-scripts/'+f,p+'/'+f)\"",
|
||||
"start": "node dist/server.js",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@evofw/db": "workspace:*",
|
||||
"@evofw/shared": "workspace:*",
|
||||
"@fastify/cors": "^11.0.1",
|
||||
"@fastify/helmet": "^13.0.1",
|
||||
"@fastify/jwt": "^9.1.0",
|
||||
"@fastify/rate-limit": "^10.3.0",
|
||||
"@fastify/schedule": "^6.0.0",
|
||||
"@fastify/sensible": "^6.0.3",
|
||||
"@fastify/static": "^8.2.0",
|
||||
"@fastify/type-provider-zod": "^1.0.0",
|
||||
"fastify": "^5.4.0",
|
||||
"fastify-plugin": "^5.0.1",
|
||||
"toad-scheduler": "^3.0.1",
|
||||
"zod": "^4.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.15.32",
|
||||
"tsup": "^8.5.0",
|
||||
"tsx": "^4.20.3",
|
||||
"typescript": "^5.8.3",
|
||||
"vitest": "^3.2.4"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
#!/usr/bin/env bash
|
||||
# EvoFirewall Linux sync agent — nft / ipset / iptables
|
||||
set -euo pipefail
|
||||
|
||||
CONF_FILE=/etc/evofw/agent.conf
|
||||
LOG_FILE=/var/log/evofw-firewall.log
|
||||
STATE_DIR=/var/lib/evofw
|
||||
HASH_FILE="${STATE_DIR}/last_hash"
|
||||
POLICY_FILE="${STATE_DIR}/last_policy.json"
|
||||
|
||||
log() { echo "$(date -u +%Y-%m-%dT%H:%M:%SZ) $*" | tee -a "$LOG_FILE"; }
|
||||
|
||||
if [[ ! -f "$CONF_FILE" ]]; then
|
||||
log "missing $CONF_FILE"
|
||||
exit 1
|
||||
fi
|
||||
# shellcheck disable=SC1090
|
||||
source "$CONF_FILE"
|
||||
|
||||
: "${EVOFW_CP_URL:?}"
|
||||
: "${CLIENT_TOKEN:?}"
|
||||
CLIENT_TOKEN="${CLIENT_TOKEN//$'\r'/}"
|
||||
CLIENT_TOKEN="${CLIENT_TOKEN//$'\n'/}"
|
||||
BACKEND="${KERNEL_BACKEND:-auto}"
|
||||
|
||||
mkdir -p "$STATE_DIR"
|
||||
|
||||
curl_policy() {
|
||||
local dest="$1"
|
||||
local code
|
||||
code=$(curl -sS -o "$dest" -w "%{http_code}" \
|
||||
-H "Authorization: Bearer ${CLIENT_TOKEN}" \
|
||||
-H "Accept: application/json" \
|
||||
"${EVOFW_CP_URL%/}/v1/agent/policy") || return 1
|
||||
if [[ "$code" == "403" ]]; then
|
||||
log "pending approval"
|
||||
return 2
|
||||
fi
|
||||
if [[ "$code" != "200" ]]; then
|
||||
log "policy HTTP $code"
|
||||
return 1
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
|
||||
if ! curl_policy "$POLICY_FILE"; then
|
||||
rc=$?
|
||||
[[ "$rc" == "2" ]] && exit 0
|
||||
exit 1
|
||||
fi
|
||||
|
||||
parse_policy() {
|
||||
local f="$1"
|
||||
if command -v jq >/dev/null 2>&1; then
|
||||
HASH=$(jq -r '.hash // empty' "$f")
|
||||
MODE=$(jq -r '.policy_mode // "blacklist"' "$f")
|
||||
mapfile -t DENY < <(jq -r '.deny_cidrs[]? // empty' "$f")
|
||||
mapfile -t ALLOW < <(jq -r '.allow_cidrs[]? // empty' "$f")
|
||||
return 0
|
||||
fi
|
||||
if command -v python3 >/dev/null 2>&1; then
|
||||
eval "$(python3 - "$f" <<'PY'
|
||||
import json,sys
|
||||
d=json.load(open(sys.argv[1],encoding="utf-8"))
|
||||
print(f'HASH={d.get("hash") or ""}')
|
||||
print(f'MODE={d.get("policy_mode") or "blacklist"}')
|
||||
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 []))+")")
|
||||
PY
|
||||
)"
|
||||
return 0
|
||||
fi
|
||||
log "need jq or python3"
|
||||
exit 1
|
||||
}
|
||||
|
||||
HASH=""; MODE=blacklist; DENY=(); ALLOW=()
|
||||
parse_policy "$POLICY_FILE"
|
||||
log "mode=$MODE deny=${#DENY[@]} allow=${#ALLOW[@]} hash=$HASH"
|
||||
|
||||
PACKETS_DROPPED=0
|
||||
PACKETS_ACCEPTED=0
|
||||
KERNEL_METHOD=""
|
||||
APPLIED=0
|
||||
|
||||
nft_join() {
|
||||
local out="" p
|
||||
for p in "$@"; do
|
||||
[[ -n "$out" ]] && out+=", "
|
||||
out+="$p"
|
||||
done
|
||||
printf '%s' "$out"
|
||||
}
|
||||
|
||||
nft_add_chunk() {
|
||||
local table=$1 name=$2 setname=$3
|
||||
shift 3
|
||||
local joined; joined=$(nft_join "$@")
|
||||
nft add element "$table" "$name" "$setname" "{ ${joined} }" 2>>"$LOG_FILE" || {
|
||||
for p in "$@"; do nft add element "$table" "$name" "$setname" "{ $p }" 2>>"$LOG_FILE" || true; done
|
||||
}
|
||||
}
|
||||
|
||||
collect_nft_stats() {
|
||||
PACKETS_DROPPED=0; PACKETS_ACCEPTED=0
|
||||
local line
|
||||
while IFS= read -r line; do
|
||||
if [[ "$line" == *drop* && "$line" =~ packets[[:space:]]+([0-9]+) ]]; then
|
||||
PACKETS_DROPPED="${BASH_REMATCH[1]}"
|
||||
elif [[ "$line" == *accept* && "$line" =~ packets[[:space:]]+([0-9]+) ]]; then
|
||||
PACKETS_ACCEPTED="${BASH_REMATCH[1]}"
|
||||
fi
|
||||
done < <(nft list chain inet evofw input 2>/dev/null || true)
|
||||
}
|
||||
|
||||
apply_nft() {
|
||||
local table=inet name=evofw
|
||||
local deny_v4=() allow_v4=() p
|
||||
for p in "${DENY[@]}"; do [[ "$p" == *:* ]] && continue; deny_v4+=("$p"); done
|
||||
for p in "${ALLOW[@]}"; do [[ "$p" == *:* ]] && continue; allow_v4+=("$p"); done
|
||||
|
||||
nft list table "$table" "$name" >/dev/null 2>&1 || nft add table "$table" "$name"
|
||||
nft list set "$table" "$name" deny_v4 >/dev/null 2>&1 || \
|
||||
nft add set "$table" "$name" deny_v4 '{ type ipv4_addr; flags interval; }'
|
||||
nft list set "$table" "$name" allow_v4 >/dev/null 2>&1 || \
|
||||
nft add set "$table" "$name" allow_v4 '{ type ipv4_addr; flags interval; }'
|
||||
nft flush set "$table" "$name" deny_v4
|
||||
nft flush set "$table" "$name" allow_v4
|
||||
|
||||
local batch=() chunk=64
|
||||
for p in "${deny_v4[@]}"; do
|
||||
batch+=("$p")
|
||||
if ((${#batch[@]} >= chunk)); then nft_add_chunk "$table" "$name" deny_v4 "${batch[@]}"; batch=(); fi
|
||||
done
|
||||
((${#batch[@]})) && nft_add_chunk "$table" "$name" deny_v4 "${batch[@]}"
|
||||
batch=()
|
||||
for p in "${allow_v4[@]}"; do
|
||||
batch+=("$p")
|
||||
if ((${#batch[@]} >= chunk)); then nft_add_chunk "$table" "$name" allow_v4 "${batch[@]}"; batch=(); fi
|
||||
done
|
||||
((${#batch[@]})) && nft_add_chunk "$table" "$name" allow_v4 "${batch[@]}"
|
||||
|
||||
nft delete chain "$table" "$name" input 2>/dev/null || true
|
||||
if [[ "$MODE" == "whitelist" ]]; then
|
||||
nft add chain "$table" "$name" input '{ type filter hook input priority 0; policy drop; }'
|
||||
nft add rule "$table" "$name" input ct state established,related counter accept
|
||||
nft add rule "$table" "$name" input iif lo counter accept
|
||||
nft add rule "$table" "$name" input ip saddr @allow_v4 counter accept
|
||||
nft add rule "$table" "$name" input counter drop
|
||||
else
|
||||
nft add chain "$table" "$name" input '{ type filter hook input priority 0; policy accept; }'
|
||||
nft add rule "$table" "$name" input ip saddr @deny_v4 counter drop
|
||||
nft add rule "$table" "$name" input counter accept
|
||||
fi
|
||||
KERNEL_METHOD=nft
|
||||
APPLIED=$((${#deny_v4[@]} + ${#allow_v4[@]}))
|
||||
}
|
||||
|
||||
apply_ipset() {
|
||||
local dset=evofw_deny_v4 aset=evofw_allow_v4
|
||||
ipset list "$dset" >/dev/null 2>&1 || ipset create "$dset" hash:net family inet
|
||||
ipset list "$aset" >/dev/null 2>&1 || ipset create "$aset" hash:net family inet
|
||||
ipset flush "$dset"; ipset flush "$aset"
|
||||
local p n=0
|
||||
for p in "${DENY[@]}"; do [[ "$p" == *:* ]] && continue; ipset add "$dset" "$p" -exist; n=$((n+1)); done
|
||||
for p in "${ALLOW[@]}"; do [[ "$p" == *:* ]] && continue; ipset add "$aset" "$p" -exist; n=$((n+1)); done
|
||||
iptables -D INPUT -m set --match-set "$dset" src -j DROP 2>/dev/null || true
|
||||
iptables -D INPUT -m set --match-set "$aset" src -j ACCEPT 2>/dev/null || true
|
||||
if [[ "$MODE" == "whitelist" ]]; then
|
||||
iptables -I INPUT -m set --match-set "$aset" src -j ACCEPT
|
||||
iptables -A INPUT -j DROP 2>/dev/null || true
|
||||
else
|
||||
iptables -I INPUT -m set --match-set "$dset" src -j DROP
|
||||
fi
|
||||
KERNEL_METHOD=ipset
|
||||
APPLIED=$n
|
||||
}
|
||||
|
||||
send_report() {
|
||||
if [[ "$KERNEL_METHOD" == "nft" ]] || command -v nft >/dev/null 2>&1; then
|
||||
collect_nft_stats
|
||||
fi
|
||||
local report
|
||||
report=$(printf '{"status":"ok","prefix_count":%s,"packets_dropped":%s,"packets_accepted":%s,"kernel_method":"%s","source":"agent"}' \
|
||||
"${APPLIED:-0}" "${PACKETS_DROPPED:-0}" "${PACKETS_ACCEPTED:-0}" "${KERNEL_METHOD:-$BACKEND}")
|
||||
curl -fsS -X POST "${EVOFW_CP_URL%/}/v1/agent/apply-report" \
|
||||
-H "Authorization: Bearer ${CLIENT_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "$report" >/dev/null 2>&1 || true
|
||||
curl -fsS -X POST "${EVOFW_CP_URL%/}/v1/agent/heartbeat" \
|
||||
-H "Authorization: Bearer ${CLIENT_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"source":"agent"}' >/dev/null 2>&1 || true
|
||||
}
|
||||
|
||||
if [[ -f "$HASH_FILE" && "$(tr -d '\r\n' <"$HASH_FILE")" == "$HASH" && -n "$HASH" ]]; then
|
||||
log "unchanged hash $HASH — skip apply"
|
||||
KERNEL_METHOD="${BACKEND}"
|
||||
send_report
|
||||
exit 0
|
||||
fi
|
||||
|
||||
case "$BACKEND" in
|
||||
nft|auto)
|
||||
if command -v nft >/dev/null 2>&1; then apply_nft
|
||||
elif command -v ipset >/dev/null 2>&1; then apply_ipset
|
||||
else log "no backend"; exit 1; fi
|
||||
;;
|
||||
ipset) apply_ipset ;;
|
||||
*) apply_nft ;;
|
||||
esac
|
||||
|
||||
echo "$HASH" >"$HASH_FILE"
|
||||
log "applied mode=$MODE count=$APPLIED method=$KERNEL_METHOD"
|
||||
send_report
|
||||
@@ -0,0 +1,125 @@
|
||||
#!/usr/bin/env bash
|
||||
# EvoFirewall Linux install one-liner
|
||||
set -euo pipefail
|
||||
|
||||
if [[ "${EUID:-$(id -u)}" -ne 0 ]]; then
|
||||
echo "evofw install: run as root" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
for cmd in curl bash; do
|
||||
command -v "$cmd" >/dev/null 2>&1 || { echo "missing $cmd" >&2; exit 1; }
|
||||
done
|
||||
|
||||
if ! command -v jq >/dev/null 2>&1 && ! command -v python3 >/dev/null 2>&1; then
|
||||
if command -v apt-get >/dev/null 2>&1; then
|
||||
apt-get update -qq && apt-get install -y -qq jq || true
|
||||
fi
|
||||
fi
|
||||
|
||||
: "${EVOFW_CP_URL:?EVOFW_CP_URL required}"
|
||||
: "${EVOFW_SEED:?EVOFW_SEED required}"
|
||||
: "${EVOFW_CLIENT_NAME:?EVOFW_CLIENT_NAME required}"
|
||||
|
||||
CONF_DIR=/etc/evofw
|
||||
CONF_FILE="${CONF_DIR}/agent.conf"
|
||||
SYNC_SCRIPT=/usr/local/sbin/evofw-firewall.sh
|
||||
PLATFORM="${EVOFW_PLATFORM:-linux}"
|
||||
|
||||
if [[ -f "$CONF_FILE" && "${EVOFW_INSTALL_FORCE:-}" != "1" ]]; then
|
||||
echo "Already installed ($CONF_FILE). Set EVOFW_INSTALL_FORCE=1 to reinstall." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
gen_token() {
|
||||
if command -v openssl >/dev/null 2>&1; then
|
||||
echo -n "evofw_$(openssl rand -base64 32 | tr '+/' '-_' | tr -d '=')"
|
||||
else
|
||||
echo -n "evofw_$(head -c 32 /dev/urandom | base64 | tr '+/' '-_' | tr -d '=\n')"
|
||||
fi
|
||||
}
|
||||
|
||||
CLIENT_TOKEN="$(gen_token)"
|
||||
HOSTNAME="$(hostname -f 2>/dev/null || hostname)"
|
||||
CP_URL="${EVOFW_CP_URL%/}"
|
||||
|
||||
ENROLL_BODY=$(printf '{"name":"%s","hostname":"%s","platform":"%s","token":"%s","client_version":"install.sh/1"}' \
|
||||
"$EVOFW_CLIENT_NAME" "$HOSTNAME" "$PLATFORM" "$CLIENT_TOKEN")
|
||||
|
||||
ENROLL_TMP=$(mktemp)
|
||||
trap 'rm -f "$ENROLL_TMP"' EXIT
|
||||
ENROLL_CODE=$(curl -sS -o "$ENROLL_TMP" -w "%{http_code}" -X POST "${CP_URL}/v1/agent/enroll" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "X-EvoFW-Seed: ${EVOFW_SEED}" \
|
||||
-d "$ENROLL_BODY")
|
||||
if [[ "$ENROLL_CODE" != "201" && "$ENROLL_CODE" != "200" ]]; then
|
||||
echo "enroll failed: HTTP ${ENROLL_CODE}" >&2
|
||||
cat "$ENROLL_TMP" >&2
|
||||
exit 1
|
||||
fi
|
||||
RESP=$(cat "$ENROLL_TMP")
|
||||
CLIENT_ID=""
|
||||
if command -v jq >/dev/null 2>&1; then
|
||||
CLIENT_ID=$(echo "$RESP" | jq -r '.client_id // .id')
|
||||
else
|
||||
CLIENT_ID=$(echo "$RESP" | sed -n 's/.*"client_id"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p')
|
||||
fi
|
||||
|
||||
mkdir -p "$CONF_DIR"
|
||||
chmod 700 "$CONF_DIR"
|
||||
cat >"$CONF_FILE" <<EOF
|
||||
EVOFW_CP_URL=${CP_URL}
|
||||
CLIENT_ID=${CLIENT_ID}
|
||||
CLIENT_TOKEN=${CLIENT_TOKEN}
|
||||
CLIENT_NAME=${EVOFW_CLIENT_NAME}
|
||||
KERNEL_BACKEND=auto
|
||||
EOF
|
||||
chmod 600 "$CONF_FILE"
|
||||
|
||||
curl -fsSL "${CP_URL}/v1/agent/sync-script" -o "$SYNC_SCRIPT"
|
||||
chmod 755 "$SYNC_SCRIPT"
|
||||
|
||||
if command -v nft >/dev/null 2>&1; then
|
||||
BACKEND=nft
|
||||
elif command -v ipset >/dev/null 2>&1 && command -v iptables >/dev/null 2>&1; then
|
||||
BACKEND=ipset
|
||||
elif command -v iptables >/dev/null 2>&1; then
|
||||
BACKEND=iptables
|
||||
else
|
||||
echo "no supported firewall backend" >&2
|
||||
exit 1
|
||||
fi
|
||||
sed -i "s/^KERNEL_BACKEND=.*/KERNEL_BACKEND=${BACKEND}/" "$CONF_FILE" 2>/dev/null || \
|
||||
echo "KERNEL_BACKEND=${BACKEND}" >>"$CONF_FILE"
|
||||
|
||||
INTERVAL="${EVOFW_SYNC_INTERVAL:-1min}"
|
||||
if command -v systemctl >/dev/null 2>&1; then
|
||||
cat >/etc/systemd/system/evofw-firewall.service <<'UNIT'
|
||||
[Unit]
|
||||
Description=EvoFirewall sync
|
||||
After=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
ExecStart=/usr/local/sbin/evofw-firewall.sh
|
||||
UNIT
|
||||
cat >/etc/systemd/system/evofw-firewall.timer <<UNIT
|
||||
[Unit]
|
||||
Description=EvoFirewall sync timer
|
||||
|
||||
[Timer]
|
||||
OnBootSec=30s
|
||||
OnUnitActiveSec=${INTERVAL}
|
||||
AccuracySec=5s
|
||||
Unit=evofw-firewall.service
|
||||
|
||||
[Install]
|
||||
WantedBy=timers.target
|
||||
UNIT
|
||||
systemctl daemon-reload
|
||||
systemctl enable --now evofw-firewall.timer
|
||||
else
|
||||
(crontab -l 2>/dev/null | grep -v evofw-firewall; echo "*/1 * * * * $SYNC_SCRIPT") | crontab -
|
||||
fi
|
||||
|
||||
echo "Installed. Client id=${CLIENT_ID}. Approve in EvoFirewall UI, then: $SYNC_SCRIPT"
|
||||
@@ -0,0 +1,46 @@
|
||||
# EvoFirewall MikroTik install (RouterOS 7+)
|
||||
# Usage: import after setting globals, or paste into terminal.
|
||||
# Required globals before import (or edit below):
|
||||
# :global EvofwCpUrl "https://fw.example.com"
|
||||
# :global EvofwSeed "YOUR_SEED"
|
||||
# :global EvofwName "mt-01"
|
||||
|
||||
:global EvofwCpUrl
|
||||
:global EvofwSeed
|
||||
:global EvofwName
|
||||
|
||||
:if ([:typeof $EvofwCpUrl] = "nothing") do={ :error "EvofwCpUrl required" }
|
||||
:if ([:typeof $EvofwSeed] = "nothing") do={ :error "EvofwSeed required" }
|
||||
:if ([:typeof $EvofwName] = "nothing") do={ :set EvofwName [/system identity get name] }
|
||||
|
||||
:local token ("evofw_" . [/certificate scep-server nonce generate])
|
||||
:if ([:len $token] < 20) do={
|
||||
:set token ("evofw_" . [:tostr [/system clock get time]] . [:tostr [/system resource get cpu-load]])
|
||||
}
|
||||
|
||||
:local body ("{\"name\":\"" . $EvofwName . "\",\"hostname\":\"" . [/system identity get name] . "\",\"platform\":\"mikrotik\",\"token\":\"" . $token . "\",\"client_version\":\"rsc/1\"}")
|
||||
|
||||
/tool fetch url=($EvofwCpUrl . "/v1/agent/enroll") http-method=post http-header-field=("Content-Type: application/json,X-EvoFW-Seed: " . $EvofwSeed) http-data=$body keep-result=no
|
||||
|
||||
# Persist credentials for scheduler script
|
||||
/system script remove [find name="evofw-env"]
|
||||
/system script add name=evofw-env source=(" :global EvofwCpUrl \"" . $EvofwCpUrl . "\"; :global EvofwToken \"" . $token . "\" ")
|
||||
|
||||
/system script remove [find name="evofw-sync"]
|
||||
/system script add name=evofw-sync policy=read,write,policy,test source={
|
||||
:global EvofwCpUrl
|
||||
:global EvofwToken
|
||||
:if ([:typeof $EvofwCpUrl] = "nothing" || [:typeof $EvofwToken] = "nothing") do={ /system script run evofw-env }
|
||||
:local tmp [/file get [find name="evofw-policy.json"] name]
|
||||
/tool fetch url=($EvofwCpUrl . "/v1/agent/policy") http-header-field=("Authorization: Bearer " . $EvofwToken) dst-path=evofw-policy.json
|
||||
# Address-lists: EVOFW_DENY / EVOFW_ALLOW — operator should map filter rules once:
|
||||
# /ip firewall filter add chain=input src-address-list=EVOFW_DENY action=drop comment=evofw
|
||||
# whitelist: policy drop + accept EVOFW_ALLOW
|
||||
:log info "evofw: policy fetched — apply address-lists via controller export or manual parse"
|
||||
/tool fetch url=($EvofwCpUrl . "/v1/agent/heartbeat") http-method=post http-header-field=("Authorization: Bearer " . $EvofwToken . ",Content-Type: application/json") http-data="{\"source\":\"mikrotik\"}" keep-result=no
|
||||
}
|
||||
|
||||
/system scheduler remove [find name="evofw-sync"]
|
||||
/system scheduler add name=evofw-sync interval=1m on-event=evofw-sync
|
||||
|
||||
:put ("EvoFirewall enrolled as " . $EvofwName . " — approve in UI, ensure filter rules for EVOFW_* lists")
|
||||
@@ -0,0 +1,96 @@
|
||||
import { resolve } from 'node:path'
|
||||
import Fastify from 'fastify'
|
||||
import {
|
||||
serializerCompiler,
|
||||
validatorCompiler,
|
||||
type ZodTypeProvider,
|
||||
} from '@fastify/type-provider-zod'
|
||||
import { AsyncTask, CronJob } from 'toad-scheduler'
|
||||
import type { AppConfig } from './config.js'
|
||||
import { loadConfig } from './config.js'
|
||||
import authPlugin from './plugins/auth.js'
|
||||
import corsPlugin from './plugins/cors.js'
|
||||
import dbPlugin from './plugins/db.js'
|
||||
import errorHandlerPlugin from './plugins/error-handler.js'
|
||||
import { healthRoutes } from './routes/health.js'
|
||||
import { controlRoutes } from './routes/control.js'
|
||||
import { agentRoutes } from './routes/agent.js'
|
||||
import { refreshAllLists } from './services/lists/refresh.js'
|
||||
import { repos } from '@evofw/db'
|
||||
|
||||
export interface BuildAppOptions {
|
||||
config?: AppConfig
|
||||
memory?: boolean
|
||||
}
|
||||
|
||||
export async function buildApp(opts: BuildAppOptions = {}) {
|
||||
const config = opts.config ?? loadConfig()
|
||||
|
||||
const app = Fastify({
|
||||
logger: { level: config.logLevel },
|
||||
}).withTypeProvider<ZodTypeProvider>()
|
||||
|
||||
app.setValidatorCompiler(validatorCompiler)
|
||||
app.setSerializerCompiler(serializerCompiler)
|
||||
|
||||
await app.register(import('@fastify/sensible'))
|
||||
await app.register(import('@fastify/helmet'), {
|
||||
contentSecurityPolicy: false,
|
||||
})
|
||||
await app.register(import('@fastify/rate-limit'), {
|
||||
max: 300,
|
||||
timeWindow: '1 minute',
|
||||
})
|
||||
await app.register(corsPlugin)
|
||||
await app.register(errorHandlerPlugin)
|
||||
await app.register(dbPlugin, { config, memory: opts.memory })
|
||||
await app.register(authPlugin, { config })
|
||||
|
||||
// Seed enroll_seed into settings if empty
|
||||
if (!repos.getSetting(app.db, 'enroll_seed')) {
|
||||
repos.setSetting(app.db, 'enroll_seed', config.enrollSeed)
|
||||
}
|
||||
|
||||
await app.register(healthRoutes)
|
||||
await app.register(agentRoutes, { config })
|
||||
|
||||
await app.register(
|
||||
async (protectedApi) => {
|
||||
protectedApi.addHook('onRequest', app.requireAuth)
|
||||
await protectedApi.register(controlRoutes, { config })
|
||||
},
|
||||
{ prefix: '/api/v1' },
|
||||
)
|
||||
|
||||
const staticDir = config.staticDir ?? resolve(process.cwd(), 'static')
|
||||
if (config.staticDir !== null) {
|
||||
await app.register(import('@fastify/static'), {
|
||||
root: staticDir,
|
||||
wildcard: false,
|
||||
})
|
||||
app.setNotFoundHandler(async (_request, reply) => {
|
||||
return reply.sendFile('index.html')
|
||||
})
|
||||
}
|
||||
|
||||
if (!opts.memory) {
|
||||
await app.register(import('@fastify/schedule'))
|
||||
const task = new AsyncTask(
|
||||
'list-refresh',
|
||||
async () => {
|
||||
await refreshAllLists(app.db)
|
||||
app.log.info('list refresh completed')
|
||||
},
|
||||
(err) => {
|
||||
app.log.warn({ err }, 'list refresh failed')
|
||||
},
|
||||
)
|
||||
app.scheduler.addCronJob(
|
||||
new CronJob({ cronExpression: '0 */5 * * * *' }, task, {
|
||||
preventOverrun: true,
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
return app
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { resolve } from 'node:path'
|
||||
|
||||
export interface AppConfig {
|
||||
databaseUrl: string
|
||||
jwtSecret: string
|
||||
jwtTtlHours: number
|
||||
serverPort: number
|
||||
staticDir: string | null
|
||||
logLevel: string
|
||||
authRequired: boolean
|
||||
authIssuer: string
|
||||
authPortalUrl: string
|
||||
publicBaseUrl: string
|
||||
enrollSeed: string
|
||||
}
|
||||
|
||||
function boolEnv(v: string | undefined, fallback: boolean): boolean {
|
||||
if (v === undefined || v === '') return fallback
|
||||
return v === '1' || v.toLowerCase() === 'true'
|
||||
}
|
||||
|
||||
export function loadConfig(): AppConfig {
|
||||
const isProd = process.env.NODE_ENV === 'production'
|
||||
const jwtSecret =
|
||||
process.env.AUTH_JWT_SECRET ??
|
||||
process.env.JWT_SECRET ??
|
||||
(isProd ? '' : 'dev-secret-change-me')
|
||||
|
||||
return {
|
||||
databaseUrl: process.env.DATABASE_URL ?? 'sqlite:data/app.db',
|
||||
jwtSecret: jwtSecret || 'dev-secret-change-me',
|
||||
jwtTtlHours: Number(process.env.JWT_TTL_HOURS ?? '24') || 24,
|
||||
serverPort: Number(process.env.SERVER_PORT ?? '8080') || 8080,
|
||||
staticDir: process.env.STATIC_DIR
|
||||
? resolve(process.env.STATIC_DIR)
|
||||
: null,
|
||||
logLevel: process.env.LOG_LEVEL ?? 'info',
|
||||
authRequired: boolEnv(process.env.AUTH_REQUIRED, false),
|
||||
authIssuer:
|
||||
process.env.AUTH_ISSUER ?? process.env.ISSUER ?? 'https://auth.shnt.top',
|
||||
authPortalUrl: (
|
||||
process.env.AUTH_PORTAL_URL ??
|
||||
process.env.VITE_AUTH_PORTAL_URL ??
|
||||
'http://localhost:5175'
|
||||
).replace(/\/$/, ''),
|
||||
publicBaseUrl: (
|
||||
process.env.PUBLIC_BASE_URL ??
|
||||
`http://localhost:${process.env.SERVER_PORT ?? '8080'}`
|
||||
).replace(/\/$/, ''),
|
||||
enrollSeed:
|
||||
process.env.EVOFW_ENROLL_SEED ??
|
||||
process.env.BUNDLE_SEED_HEX ??
|
||||
'dev-enroll-seed-change-me',
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify'
|
||||
import fp from 'fastify-plugin'
|
||||
import {
|
||||
hasPermission,
|
||||
permissionForRequest,
|
||||
type AuthUser,
|
||||
} from '@evofw/shared'
|
||||
import type { AppConfig } from '../config.js'
|
||||
import { createHash } from 'node:crypto'
|
||||
import { repos } from '@evofw/db'
|
||||
|
||||
declare module 'fastify' {
|
||||
interface FastifyRequest {
|
||||
authUser?: AuthUser
|
||||
agentId?: string
|
||||
}
|
||||
}
|
||||
|
||||
declare module '@fastify/jwt' {
|
||||
interface FastifyJWT {
|
||||
payload: {
|
||||
sub: string
|
||||
email?: string
|
||||
name?: string
|
||||
apps?: string[]
|
||||
permissions?: string[]
|
||||
is_admin?: boolean
|
||||
iss?: string
|
||||
}
|
||||
user: {
|
||||
sub: string
|
||||
email?: string
|
||||
name?: string
|
||||
apps?: string[]
|
||||
permissions?: string[]
|
||||
is_admin?: boolean
|
||||
iss?: string
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function hashToken(token: string): string {
|
||||
return createHash('sha256').update(token).digest('hex')
|
||||
}
|
||||
|
||||
function isPublicPath(url: string): boolean {
|
||||
const path = url.split('?')[0] ?? url
|
||||
if (path === '/health' || path === '/ready') return true
|
||||
if (path === '/api/auth/config' || path === '/api/v1/auth/config') return true
|
||||
if (path.startsWith('/v1/agent/enroll')) return true
|
||||
if (path.startsWith('/v1/agent/install')) return true
|
||||
if (path.startsWith('/v1/agent/sync-script')) return true
|
||||
if (path.startsWith('/v1/agent/mikrotik')) return true
|
||||
return false
|
||||
}
|
||||
|
||||
function isAgentPath(url: string): boolean {
|
||||
const path = url.split('?')[0] ?? url
|
||||
return (
|
||||
path === '/v1/agent/policy' ||
|
||||
path === '/v1/agent/apply-report' ||
|
||||
path === '/v1/agent/heartbeat'
|
||||
)
|
||||
}
|
||||
|
||||
async function authPlugin(
|
||||
app: FastifyInstance,
|
||||
opts: { config: AppConfig },
|
||||
) {
|
||||
const { config } = opts
|
||||
|
||||
app.get('/api/auth/config', async () => ({
|
||||
required: config.authRequired,
|
||||
portal_url: config.authPortalUrl,
|
||||
}))
|
||||
app.get('/api/v1/auth/config', async () => ({
|
||||
required: config.authRequired,
|
||||
portal_url: config.authPortalUrl,
|
||||
}))
|
||||
|
||||
if (config.authRequired) {
|
||||
if (!config.jwtSecret || config.jwtSecret.length < 8) {
|
||||
throw new Error(
|
||||
'AUTH_JWT_SECRET / JWT_SECRET required when AUTH_REQUIRED=true',
|
||||
)
|
||||
}
|
||||
await app.register(import('@fastify/jwt'), {
|
||||
secret: config.jwtSecret,
|
||||
verify: { allowedIss: [config.authIssuer] },
|
||||
})
|
||||
} else {
|
||||
await app.register(import('@fastify/jwt'), {
|
||||
secret: config.jwtSecret || 'dev-secret-change-me',
|
||||
})
|
||||
}
|
||||
|
||||
app.decorate(
|
||||
'requireAuth',
|
||||
async (request: FastifyRequest, reply: FastifyReply) => {
|
||||
if (!config.authRequired) {
|
||||
request.authUser = {
|
||||
id: 'dev',
|
||||
email: 'dev@local',
|
||||
name: 'Dev',
|
||||
apps: ['fw'],
|
||||
permissions: [
|
||||
'fw:dashboard:read',
|
||||
'fw:agents:write',
|
||||
'fw:lists:write',
|
||||
'fw:policies:write',
|
||||
'fw:stats:read',
|
||||
'fw:settings:admin',
|
||||
],
|
||||
isAdmin: true,
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await request.jwtVerify()
|
||||
} catch {
|
||||
return reply.code(401).send({
|
||||
error: { code: 'UNAUTHORIZED', message: 'Требуется авторизация' },
|
||||
})
|
||||
}
|
||||
|
||||
const payload = request.user
|
||||
const apps = Array.isArray(payload.apps)
|
||||
? payload.apps.map(String)
|
||||
: []
|
||||
const permissions = Array.isArray(payload.permissions)
|
||||
? payload.permissions.map(String)
|
||||
: []
|
||||
|
||||
if (!apps.includes('fw') && !payload.is_admin) {
|
||||
return reply.code(403).send({
|
||||
error: {
|
||||
code: 'FORBIDDEN',
|
||||
message: 'Нет доступа к приложению EvoFirewall',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
request.authUser = {
|
||||
id: String(payload.sub),
|
||||
email: String(payload.email ?? ''),
|
||||
name: String(payload.name ?? ''),
|
||||
apps,
|
||||
permissions: payload.is_admin
|
||||
? [
|
||||
'fw:dashboard:read',
|
||||
'fw:agents:write',
|
||||
'fw:lists:write',
|
||||
'fw:policies:write',
|
||||
'fw:stats:read',
|
||||
'fw:settings:admin',
|
||||
]
|
||||
: permissions,
|
||||
isAdmin: Boolean(payload.is_admin),
|
||||
}
|
||||
|
||||
const required = permissionForRequest(request.method, request.url)
|
||||
if (
|
||||
required &&
|
||||
!request.authUser.isAdmin &&
|
||||
!hasPermission(request.authUser.permissions, required)
|
||||
) {
|
||||
return reply.code(403).send({
|
||||
error: {
|
||||
code: 'FORBIDDEN',
|
||||
message: `Недостаточно прав: ${required}`,
|
||||
},
|
||||
})
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
app.addHook('onRequest', async (request, reply) => {
|
||||
if (isPublicPath(request.url)) return
|
||||
|
||||
if (isAgentPath(request.url)) {
|
||||
const auth = request.headers.authorization
|
||||
if (!auth?.startsWith('Bearer ')) {
|
||||
return reply.code(401).send({
|
||||
error: { code: 'UNAUTHORIZED', message: 'Agent token required' },
|
||||
})
|
||||
}
|
||||
const token = auth.slice('Bearer '.length).trim()
|
||||
const agent = repos.getAgentByTokenHash(app.db, hashToken(token))
|
||||
if (!agent || agent.status !== 'approved') {
|
||||
return reply.code(agent?.status === 'pending' ? 403 : 401).send({
|
||||
error: {
|
||||
code: agent?.status === 'pending' ? 'FORBIDDEN' : 'UNAUTHORIZED',
|
||||
message:
|
||||
agent?.status === 'pending'
|
||||
? 'Agent pending approval'
|
||||
: 'Invalid agent token',
|
||||
},
|
||||
})
|
||||
}
|
||||
request.agentId = agent.id
|
||||
return
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
declare module 'fastify' {
|
||||
interface FastifyInstance {
|
||||
requireAuth: (
|
||||
request: FastifyRequest,
|
||||
reply: FastifyReply,
|
||||
) => Promise<void | FastifyReply>
|
||||
}
|
||||
}
|
||||
|
||||
export default fp(authPlugin, { name: 'auth' })
|
||||
export { hashToken }
|
||||
@@ -0,0 +1,8 @@
|
||||
import type { FastifyInstance } from 'fastify'
|
||||
import fp from 'fastify-plugin'
|
||||
|
||||
async function corsPlugin(app: FastifyInstance) {
|
||||
await app.register(import('@fastify/cors'), { origin: true })
|
||||
}
|
||||
|
||||
export default fp(corsPlugin, { name: 'cors' })
|
||||
@@ -0,0 +1,38 @@
|
||||
import type { FastifyInstance } from 'fastify'
|
||||
import fp from 'fastify-plugin'
|
||||
import {
|
||||
createDb,
|
||||
createMemoryDb,
|
||||
runMigrations,
|
||||
type Db,
|
||||
type Sqlite,
|
||||
} from '@evofw/db'
|
||||
import type { AppConfig } from '../config.js'
|
||||
|
||||
declare module 'fastify' {
|
||||
interface FastifyInstance {
|
||||
db: Db
|
||||
sqlite: Sqlite
|
||||
}
|
||||
}
|
||||
|
||||
export interface DbPluginOptions {
|
||||
config?: AppConfig
|
||||
memory?: boolean
|
||||
}
|
||||
|
||||
async function dbPlugin(app: FastifyInstance, opts: DbPluginOptions) {
|
||||
const { db, sqlite } = opts.memory
|
||||
? createMemoryDb()
|
||||
: createDb(opts.config!.databaseUrl)
|
||||
|
||||
runMigrations(sqlite)
|
||||
app.decorate('db', db)
|
||||
app.decorate('sqlite', sqlite)
|
||||
|
||||
app.addHook('onClose', async () => {
|
||||
sqlite.close()
|
||||
})
|
||||
}
|
||||
|
||||
export default fp(dbPlugin, { name: 'db' })
|
||||
@@ -0,0 +1,38 @@
|
||||
import type { FastifyInstance } from 'fastify'
|
||||
import fp from 'fastify-plugin'
|
||||
|
||||
export class AppError extends Error {
|
||||
constructor(
|
||||
public code: string,
|
||||
message: string,
|
||||
public statusCode = 400,
|
||||
) {
|
||||
super(message)
|
||||
this.name = 'AppError'
|
||||
}
|
||||
}
|
||||
|
||||
async function errorHandlerPlugin(app: FastifyInstance) {
|
||||
app.setErrorHandler((err, _req, reply) => {
|
||||
if (err instanceof AppError) {
|
||||
return reply.code(err.statusCode).send({
|
||||
error: { code: err.code, message: err.message },
|
||||
})
|
||||
}
|
||||
const e = err as { statusCode?: number; message?: string }
|
||||
const status = e.statusCode ?? 500
|
||||
const message =
|
||||
status >= 500
|
||||
? 'Внутренняя ошибка сервера'
|
||||
: e.message || 'Ошибка запроса'
|
||||
app.log.error(err)
|
||||
return reply.code(status).send({
|
||||
error: {
|
||||
code: status >= 500 ? 'INTERNAL_ERROR' : 'VALIDATION_ERROR',
|
||||
message,
|
||||
},
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
export default fp(errorHandlerPlugin, { name: 'error-handler' })
|
||||
@@ -0,0 +1,136 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import type { FastifyPluginAsync } from 'fastify'
|
||||
import { repos } from '@evofw/db'
|
||||
import { enrollBodySchema, applyReportBodySchema } from '@evofw/shared'
|
||||
import type { AppConfig } from '../config.js'
|
||||
import { hashToken } from '../plugins/auth.js'
|
||||
import { evaluateAgentPolicy } from '../services/policy/evaluate.js'
|
||||
import { AppError } from '../plugins/error-handler.js'
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||
const scriptsDir = join(__dirname, '../agent-scripts')
|
||||
|
||||
export const agentRoutes: FastifyPluginAsync<{ config: AppConfig }> = async (
|
||||
app,
|
||||
opts,
|
||||
) => {
|
||||
const { config } = opts
|
||||
|
||||
app.get('/v1/agent/install.sh', async (_req, reply) => {
|
||||
const body = readFileSync(join(scriptsDir, 'install.sh'), 'utf-8')
|
||||
return reply.type('text/x-shellscript').send(body)
|
||||
})
|
||||
|
||||
app.get('/v1/agent/sync-script', async (_req, reply) => {
|
||||
const body = readFileSync(join(scriptsDir, 'evofw-firewall.sh'), 'utf-8')
|
||||
return reply.type('text/x-shellscript').send(body)
|
||||
})
|
||||
|
||||
app.get('/v1/agent/mikrotik-install.rsc', async (_req, reply) => {
|
||||
const body = readFileSync(
|
||||
join(scriptsDir, 'mikrotik-install.rsc'),
|
||||
'utf-8',
|
||||
)
|
||||
return reply.type('text/plain').send(body)
|
||||
})
|
||||
|
||||
app.post('/v1/agent/enroll', async (req, reply) => {
|
||||
const seed = req.headers['x-evofw-seed']
|
||||
const expected =
|
||||
repos.getSetting(app.db, 'enroll_seed') || config.enrollSeed
|
||||
if (!seed || String(seed) !== expected) {
|
||||
throw new AppError('UNAUTHORIZED', 'Invalid enroll seed', 401)
|
||||
}
|
||||
const body = enrollBodySchema.parse(req.body)
|
||||
const id = crypto.randomUUID()
|
||||
const tokenHash = hashToken(body.token)
|
||||
const existing = repos.getAgentByTokenHash(app.db, tokenHash)
|
||||
if (existing) {
|
||||
throw new AppError('CONFLICT', 'Token already enrolled', 409)
|
||||
}
|
||||
const agent = repos.insertAgent(app.db, {
|
||||
id,
|
||||
name: body.name,
|
||||
hostname: body.hostname ?? null,
|
||||
platform: body.platform ?? 'linux',
|
||||
tokenPrefix: body.token.slice(0, 12),
|
||||
tokenHash,
|
||||
status: 'pending',
|
||||
policyMode: 'blacklist',
|
||||
policyGeneration: 1,
|
||||
clientVersion: body.client_version ?? null,
|
||||
settingsJson: '{}',
|
||||
createdAt: new Date().toISOString(),
|
||||
})
|
||||
return reply.code(201).send({
|
||||
client_id: agent!.id,
|
||||
id: agent!.id,
|
||||
status: agent!.status,
|
||||
name: agent!.name,
|
||||
})
|
||||
})
|
||||
|
||||
app.get('/v1/agent/policy', async (req) => {
|
||||
const agentId = req.agentId!
|
||||
const policy = evaluateAgentPolicy(app.db, agentId)
|
||||
repos.updateAgent(app.db, agentId, {
|
||||
lastSeenAt: new Date().toISOString(),
|
||||
lastSeenIp: req.ip,
|
||||
})
|
||||
return {
|
||||
generation: policy.generation,
|
||||
hash: policy.hash,
|
||||
policy_mode: policy.policyMode,
|
||||
deny_cidrs: policy.denyCidrs,
|
||||
allow_cidrs: policy.allowCidrs,
|
||||
sync_interval_sec: policy.syncIntervalSec,
|
||||
// compat aliases for simple clients
|
||||
prefixes:
|
||||
policy.policyMode === 'blacklist'
|
||||
? policy.denyCidrs
|
||||
: policy.allowCidrs,
|
||||
total:
|
||||
policy.policyMode === 'blacklist'
|
||||
? policy.denyCidrs.length
|
||||
: policy.allowCidrs.length,
|
||||
}
|
||||
})
|
||||
|
||||
app.post('/v1/agent/apply-report', async (req) => {
|
||||
const agentId = req.agentId!
|
||||
const body = applyReportBodySchema.parse(req.body)
|
||||
const now = new Date().toISOString()
|
||||
repos.updateAgent(app.db, agentId, {
|
||||
lastApplyAt: now,
|
||||
lastApplyStatus: body.status,
|
||||
lastApplyError: body.error ?? null,
|
||||
lastApplyPrefixCount: body.prefix_count ?? 0,
|
||||
lastApplyPacketsDropped: body.packets_dropped ?? 0,
|
||||
lastApplyPacketsAccepted: body.packets_accepted ?? 0,
|
||||
lastApplyKernelMethod: body.kernel_method ?? null,
|
||||
lastSeenAt: now,
|
||||
lastSeenIp: req.ip,
|
||||
})
|
||||
repos.insertStatsSample(app.db, {
|
||||
id: crypto.randomUUID(),
|
||||
agentId,
|
||||
packetsDropped: body.packets_dropped ?? 0,
|
||||
packetsAccepted: body.packets_accepted ?? 0,
|
||||
prefixCount: body.prefix_count ?? 0,
|
||||
kernelMethod: body.kernel_method ?? null,
|
||||
recordedAt: now,
|
||||
})
|
||||
return { ok: true }
|
||||
})
|
||||
|
||||
app.post('/v1/agent/heartbeat', async (req) => {
|
||||
const agentId = req.agentId!
|
||||
repos.updateAgent(app.db, agentId, {
|
||||
lastSeenAt: new Date().toISOString(),
|
||||
lastSeenIp: req.ip,
|
||||
})
|
||||
return { ok: true }
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,413 @@
|
||||
import type { FastifyPluginAsync } from 'fastify'
|
||||
import { repos } from '@evofw/db'
|
||||
import {
|
||||
createOverrideBodySchema,
|
||||
createIpListBodySchema,
|
||||
createPolicyRuleBodySchema,
|
||||
patchAgentBodySchema,
|
||||
cloneFromBodySchema,
|
||||
} from '@evofw/shared'
|
||||
import { AppError } from '../plugins/error-handler.js'
|
||||
import { refreshIpList } from '../services/lists/refresh.js'
|
||||
import { evaluateAgentPolicy } from '../services/policy/evaluate.js'
|
||||
import type { AppConfig } from '../config.js'
|
||||
|
||||
function mapAgent(a: NonNullable<ReturnType<typeof repos.getAgent>>) {
|
||||
return {
|
||||
id: a.id,
|
||||
name: a.name,
|
||||
hostname: a.hostname,
|
||||
platform: a.platform,
|
||||
token_prefix: a.tokenPrefix,
|
||||
status: a.status,
|
||||
policy_mode: a.policyMode,
|
||||
policy_generation: a.policyGeneration,
|
||||
last_seen_at: a.lastSeenAt,
|
||||
last_seen_ip: a.lastSeenIp,
|
||||
last_apply_at: a.lastApplyAt,
|
||||
last_apply_status: a.lastApplyStatus,
|
||||
last_apply_error: a.lastApplyError,
|
||||
last_apply_prefix_count: a.lastApplyPrefixCount,
|
||||
last_apply_packets_dropped: a.lastApplyPacketsDropped,
|
||||
last_apply_packets_accepted: a.lastApplyPacketsAccepted,
|
||||
last_apply_kernel_method: a.lastApplyKernelMethod,
|
||||
client_version: a.clientVersion,
|
||||
created_at: a.createdAt,
|
||||
approved_at: a.approvedAt,
|
||||
revoked_at: a.revokedAt,
|
||||
}
|
||||
}
|
||||
|
||||
export const controlRoutes: FastifyPluginAsync<{ config: AppConfig }> = async (
|
||||
app,
|
||||
opts,
|
||||
) => {
|
||||
const { config } = opts
|
||||
|
||||
app.get('/dashboard', async () => {
|
||||
const all = repos.listAgents(app.db)
|
||||
const now = Date.now()
|
||||
const online = all.filter((a) => {
|
||||
if (!a.lastSeenAt || a.status !== 'approved') return false
|
||||
return now - Date.parse(a.lastSeenAt) < 5 * 60_000
|
||||
})
|
||||
return {
|
||||
agents_total: all.length,
|
||||
agents_approved: all.filter((a) => a.status === 'approved').length,
|
||||
agents_online: online.length,
|
||||
agents_pending: all.filter((a) => a.status === 'pending').length,
|
||||
packets_dropped: all.reduce(
|
||||
(s, a) => s + (a.lastApplyPacketsDropped ?? 0),
|
||||
0,
|
||||
),
|
||||
packets_accepted: all.reduce(
|
||||
(s, a) => s + (a.lastApplyPacketsAccepted ?? 0),
|
||||
0,
|
||||
),
|
||||
lists_total: repos.listIpLists(app.db).length,
|
||||
}
|
||||
})
|
||||
|
||||
app.get('/install-context', async () => {
|
||||
const seed =
|
||||
repos.getSetting(app.db, 'enroll_seed') || config.enrollSeed
|
||||
return {
|
||||
suggested_cp_url: config.publicBaseUrl,
|
||||
enroll_seed: seed,
|
||||
install_sh_url: `${config.publicBaseUrl}/v1/agent/install.sh`,
|
||||
mikrotik_url: `${config.publicBaseUrl}/v1/agent/mikrotik-install.rsc`,
|
||||
sync_interval_sec: Number(
|
||||
repos.getSetting(app.db, 'agent_sync_interval_sec') || '60',
|
||||
),
|
||||
}
|
||||
})
|
||||
|
||||
// Agents
|
||||
app.get('/agents', async () => ({
|
||||
items: repos.listAgents(app.db).map(mapAgent),
|
||||
}))
|
||||
|
||||
app.get<{ Params: { id: string } }>('/agents/:id', async (req) => {
|
||||
const a = repos.getAgent(app.db, req.params.id)
|
||||
if (!a) throw new AppError('NOT_FOUND', 'Agent not found', 404)
|
||||
return mapAgent(a)
|
||||
})
|
||||
|
||||
app.get<{ Params: { id: string } }>('/agents/:id/preview', async (req) => {
|
||||
const a = repos.getAgent(app.db, req.params.id)
|
||||
if (!a) throw new AppError('NOT_FOUND', 'Agent not found', 404)
|
||||
const policy = evaluateAgentPolicy(app.db, a.id)
|
||||
return {
|
||||
...policy,
|
||||
deny_cidrs: policy.denyCidrs,
|
||||
allow_cidrs: policy.allowCidrs,
|
||||
policy_mode: policy.policyMode,
|
||||
sync_interval_sec: policy.syncIntervalSec,
|
||||
}
|
||||
})
|
||||
|
||||
app.patch<{ Params: { id: string } }>('/agents/:id', async (req) => {
|
||||
const body = patchAgentBodySchema.parse(req.body)
|
||||
const a = repos.getAgent(app.db, req.params.id)
|
||||
if (!a) throw new AppError('NOT_FOUND', 'Agent not found', 404)
|
||||
const updated = repos.updateAgent(app.db, a.id, {
|
||||
name: body.name,
|
||||
policyMode: body.policy_mode,
|
||||
settingsJson: body.settings
|
||||
? JSON.stringify(body.settings)
|
||||
: undefined,
|
||||
policyGeneration:
|
||||
body.policy_mode && body.policy_mode !== a.policyMode
|
||||
? a.policyGeneration + 1
|
||||
: a.policyGeneration,
|
||||
})
|
||||
return mapAgent(updated!)
|
||||
})
|
||||
|
||||
app.post<{ Params: { id: string } }>('/agents/:id/approve', async (req) => {
|
||||
const a = repos.getAgent(app.db, req.params.id)
|
||||
if (!a) throw new AppError('NOT_FOUND', 'Agent not found', 404)
|
||||
const updated = repos.updateAgent(app.db, a.id, {
|
||||
status: 'approved',
|
||||
approvedAt: new Date().toISOString(),
|
||||
})
|
||||
return mapAgent(updated!)
|
||||
})
|
||||
|
||||
app.post<{ Params: { id: string } }>('/agents/:id/revoke', async (req) => {
|
||||
const a = repos.getAgent(app.db, req.params.id)
|
||||
if (!a) throw new AppError('NOT_FOUND', 'Agent not found', 404)
|
||||
const updated = repos.updateAgent(app.db, a.id, {
|
||||
status: 'revoked',
|
||||
revokedAt: new Date().toISOString(),
|
||||
})
|
||||
return mapAgent(updated!)
|
||||
})
|
||||
|
||||
app.delete<{ Params: { id: string } }>('/agents/:id', async (req) => {
|
||||
repos.deleteAgent(app.db, req.params.id)
|
||||
return { ok: true }
|
||||
})
|
||||
|
||||
app.post<{ Params: { id: string; sourceId: string } }>(
|
||||
'/agents/:id/clone-from/:sourceId',
|
||||
async (req) => {
|
||||
const body = cloneFromBodySchema.parse(req.body ?? {})
|
||||
const updated = repos.cloneRulesFrom(
|
||||
app.db,
|
||||
req.params.sourceId,
|
||||
req.params.id,
|
||||
body.include_overrides ?? false,
|
||||
)
|
||||
if (!updated) throw new AppError('NOT_FOUND', 'Agent not found', 404)
|
||||
return mapAgent(updated)
|
||||
},
|
||||
)
|
||||
|
||||
// Overrides
|
||||
app.get<{ Params: { id: string } }>(
|
||||
'/agents/:id/overrides',
|
||||
async (req) => ({
|
||||
items: repos.listOverrides(app.db, req.params.id).map((o) => ({
|
||||
id: o.id,
|
||||
agent_id: o.agentId,
|
||||
cidr: o.cidr,
|
||||
action: o.action,
|
||||
comment: o.comment,
|
||||
created_at: o.createdAt,
|
||||
})),
|
||||
}),
|
||||
)
|
||||
|
||||
app.post<{ Params: { id: string } }>(
|
||||
'/agents/:id/overrides',
|
||||
async (req) => {
|
||||
const body = createOverrideBodySchema.parse(req.body)
|
||||
const a = repos.getAgent(app.db, req.params.id)
|
||||
if (!a) throw new AppError('NOT_FOUND', 'Agent not found', 404)
|
||||
const row = repos.insertOverride(app.db, {
|
||||
id: crypto.randomUUID(),
|
||||
agentId: a.id,
|
||||
cidr: body.cidr,
|
||||
action: body.action,
|
||||
comment: body.comment ?? null,
|
||||
createdByUserId: req.authUser?.id,
|
||||
createdAt: new Date().toISOString(),
|
||||
})
|
||||
repos.bumpAgentGeneration(app.db, a.id)
|
||||
return {
|
||||
id: row!.id,
|
||||
agent_id: row!.agentId,
|
||||
cidr: row!.cidr,
|
||||
action: row!.action,
|
||||
comment: row!.comment,
|
||||
created_at: row!.createdAt,
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
app.delete<{ Params: { id: string; overrideId: string } }>(
|
||||
'/agents/:id/overrides/:overrideId',
|
||||
async (req) => {
|
||||
repos.deleteOverride(app.db, req.params.overrideId)
|
||||
repos.bumpAgentGeneration(app.db, req.params.id)
|
||||
return { ok: true }
|
||||
},
|
||||
)
|
||||
|
||||
// Lists
|
||||
app.get('/lists', async () => {
|
||||
const items = repos.listIpLists(app.db).map((l) => ({
|
||||
id: l.id,
|
||||
name: l.name,
|
||||
type: l.type,
|
||||
config_json: l.configJson,
|
||||
content_hash: l.contentHash,
|
||||
refreshed_at: l.refreshedAt,
|
||||
last_error: l.lastError,
|
||||
entry_count: repos.listIpListEntries(app.db, l.id).length,
|
||||
created_at: l.createdAt,
|
||||
updated_at: l.updatedAt,
|
||||
}))
|
||||
return { items }
|
||||
})
|
||||
|
||||
app.post('/lists', async (req) => {
|
||||
const body = createIpListBodySchema.parse(req.body)
|
||||
const id = crypto.randomUUID()
|
||||
const list = repos.insertIpList(app.db, {
|
||||
id,
|
||||
name: body.name,
|
||||
type: body.type,
|
||||
configJson: JSON.stringify(body.config ?? {}),
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
})
|
||||
if (body.entries?.length) {
|
||||
repos.replaceIpListEntries(app.db, id, body.entries)
|
||||
}
|
||||
if (body.type !== 'static') {
|
||||
await refreshIpList(app.db, id)
|
||||
}
|
||||
return {
|
||||
id: list!.id,
|
||||
name: list!.name,
|
||||
type: list!.type,
|
||||
config_json: list!.configJson,
|
||||
created_at: list!.createdAt,
|
||||
updated_at: list!.updatedAt,
|
||||
}
|
||||
})
|
||||
|
||||
app.get<{ Params: { id: string } }>('/lists/:id', async (req) => {
|
||||
const l = repos.getIpList(app.db, req.params.id)
|
||||
if (!l) throw new AppError('NOT_FOUND', 'List not found', 404)
|
||||
return {
|
||||
id: l.id,
|
||||
name: l.name,
|
||||
type: l.type,
|
||||
config_json: l.configJson,
|
||||
content_hash: l.contentHash,
|
||||
refreshed_at: l.refreshedAt,
|
||||
last_error: l.lastError,
|
||||
entries: repos.listIpListEntries(app.db, l.id).map((e) => e.cidr),
|
||||
created_at: l.createdAt,
|
||||
updated_at: l.updatedAt,
|
||||
}
|
||||
})
|
||||
|
||||
app.post<{ Params: { id: string } }>('/lists/:id/refresh', async (req) => {
|
||||
await refreshIpList(app.db, req.params.id)
|
||||
const l = repos.getIpList(app.db, req.params.id)
|
||||
if (!l) throw new AppError('NOT_FOUND', 'List not found', 404)
|
||||
return {
|
||||
id: l.id,
|
||||
content_hash: l.contentHash,
|
||||
refreshed_at: l.refreshedAt,
|
||||
last_error: l.lastError,
|
||||
entry_count: repos.listIpListEntries(app.db, l.id).length,
|
||||
}
|
||||
})
|
||||
|
||||
app.delete<{ Params: { id: string } }>('/lists/:id', async (req) => {
|
||||
repos.deleteIpList(app.db, req.params.id)
|
||||
return { ok: true }
|
||||
})
|
||||
|
||||
// Rules
|
||||
app.get<{ Querystring: { agent_id?: string } }>('/rules', async (req) => {
|
||||
const agentId =
|
||||
req.query.agent_id === 'tenant' || req.query.agent_id === ''
|
||||
? null
|
||||
: req.query.agent_id
|
||||
const items = (
|
||||
agentId === undefined
|
||||
? repos.listPolicyRules(app.db)
|
||||
: repos.listPolicyRules(app.db, agentId)
|
||||
).map((r) => ({
|
||||
id: r.id,
|
||||
agent_id: r.agentId,
|
||||
priority: r.priority,
|
||||
action: r.action,
|
||||
list_id: r.listId,
|
||||
cidr: r.cidr,
|
||||
comment: r.comment,
|
||||
created_at: r.createdAt,
|
||||
updated_at: r.updatedAt,
|
||||
}))
|
||||
return { items }
|
||||
})
|
||||
|
||||
app.post('/rules', async (req) => {
|
||||
const body = createPolicyRuleBodySchema.parse(req.body)
|
||||
if (!body.list_id && !body.cidr) {
|
||||
throw new AppError('VALIDATION_ERROR', 'list_id or cidr required')
|
||||
}
|
||||
const row = repos.insertPolicyRule(app.db, {
|
||||
id: crypto.randomUUID(),
|
||||
agentId: body.agent_id ?? null,
|
||||
priority: body.priority,
|
||||
action: body.action,
|
||||
listId: body.list_id ?? null,
|
||||
cidr: body.cidr ?? null,
|
||||
comment: body.comment ?? null,
|
||||
createdByUserId: req.authUser?.id,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
})
|
||||
if (body.agent_id) repos.bumpAgentGeneration(app.db, body.agent_id)
|
||||
else {
|
||||
for (const a of repos.listAgents(app.db)) {
|
||||
if (a.status === 'approved') repos.bumpAgentGeneration(app.db, a.id)
|
||||
}
|
||||
}
|
||||
return {
|
||||
id: row!.id,
|
||||
agent_id: row!.agentId,
|
||||
priority: row!.priority,
|
||||
action: row!.action,
|
||||
list_id: row!.listId,
|
||||
cidr: row!.cidr,
|
||||
comment: row!.comment,
|
||||
created_at: row!.createdAt,
|
||||
updated_at: row!.updatedAt,
|
||||
}
|
||||
})
|
||||
|
||||
app.delete<{ Params: { id: string } }>('/rules/:id', async (req) => {
|
||||
const rule = repos.getPolicyRule(app.db, req.params.id)
|
||||
repos.deletePolicyRule(app.db, req.params.id)
|
||||
if (rule?.agentId) repos.bumpAgentGeneration(app.db, rule.agentId)
|
||||
return { ok: true }
|
||||
})
|
||||
|
||||
// Stats
|
||||
app.get<{ Params: { id: string } }>('/agents/:id/stats', async (req) => ({
|
||||
items: repos.listStatsSamples(app.db, req.params.id).map((s) => ({
|
||||
id: s.id,
|
||||
agent_id: s.agentId,
|
||||
packets_dropped: s.packetsDropped,
|
||||
packets_accepted: s.packetsAccepted,
|
||||
prefix_count: s.prefixCount,
|
||||
kernel_method: s.kernelMethod,
|
||||
recorded_at: s.recordedAt,
|
||||
})),
|
||||
}))
|
||||
|
||||
app.get('/stats/recent', async () => ({
|
||||
items: repos.listRecentStats(app.db).map((s) => ({
|
||||
id: s.id,
|
||||
agent_id: s.agentId,
|
||||
packets_dropped: s.packetsDropped,
|
||||
packets_accepted: s.packetsAccepted,
|
||||
prefix_count: s.prefixCount,
|
||||
kernel_method: s.kernelMethod,
|
||||
recorded_at: s.recordedAt,
|
||||
})),
|
||||
}))
|
||||
|
||||
// Settings
|
||||
app.get('/settings', async () => {
|
||||
const rows = repos.listSettings(app.db)
|
||||
const map: Record<string, string> = {}
|
||||
for (const r of rows) {
|
||||
if (r.key === 'evobgp_api_token' && r.value) {
|
||||
map[r.key] = '********'
|
||||
} else {
|
||||
map[r.key] = r.value
|
||||
}
|
||||
}
|
||||
if (!map.enroll_seed) map.enroll_seed = config.enrollSeed
|
||||
return map
|
||||
})
|
||||
|
||||
app.put('/settings', async (req) => {
|
||||
const body = req.body as Record<string, string>
|
||||
for (const [k, v] of Object.entries(body)) {
|
||||
if (typeof v !== 'string') continue
|
||||
if (k === 'evobgp_api_token' && v === '********') continue
|
||||
repos.setSetting(app.db, k, v)
|
||||
}
|
||||
return { ok: true }
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import type { FastifyPluginAsync } from 'fastify'
|
||||
import { healthCheck } from '@evofw/db'
|
||||
|
||||
export const healthRoutes: FastifyPluginAsync = async (app) => {
|
||||
app.get('/health', async () => ({ status: 'ok', service: 'evofirewall' }))
|
||||
app.get('/ready', async (_req, reply) => {
|
||||
try {
|
||||
healthCheck(app.sqlite)
|
||||
return { status: 'ready' }
|
||||
} catch {
|
||||
return reply.code(503).send({ status: 'not_ready' })
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { readFileSync, existsSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
import { buildApp } from './app.js'
|
||||
import { loadConfig } from './config.js'
|
||||
|
||||
for (const path of [
|
||||
resolve(import.meta.dirname, '../../../.env'),
|
||||
'.env',
|
||||
'../.env',
|
||||
]) {
|
||||
if (!existsSync(path)) continue
|
||||
const content = readFileSync(path, 'utf-8')
|
||||
for (const line of content.split('\n')) {
|
||||
const trimmed = line.trim()
|
||||
if (!trimmed || trimmed.startsWith('#')) continue
|
||||
const eq = trimmed.indexOf('=')
|
||||
if (eq === -1) continue
|
||||
const key = trimmed.slice(0, eq).trim()
|
||||
let value = trimmed.slice(eq + 1).trim()
|
||||
if (
|
||||
(value.startsWith('"') && value.endsWith('"')) ||
|
||||
(value.startsWith("'") && value.endsWith("'"))
|
||||
) {
|
||||
value = value.slice(1, -1)
|
||||
}
|
||||
if (!(key in process.env)) process.env[key] = value
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
const config = loadConfig()
|
||||
const app = await buildApp({ config })
|
||||
|
||||
try {
|
||||
await app.listen({ port: config.serverPort, host: '0.0.0.0' })
|
||||
app.log.info(`listening on ${config.serverPort}`)
|
||||
} catch (err) {
|
||||
app.log.error(err)
|
||||
process.exit(1)
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
import { createHash } from 'node:crypto'
|
||||
import { resolve4, resolve6 } from 'node:dns/promises'
|
||||
import type { Db } from '@evofw/db'
|
||||
import { repos } from '@evofw/db'
|
||||
|
||||
function uniq(cidrs: string[]): string[] {
|
||||
return [...new Set(cidrs.map((c) => c.trim()).filter(Boolean))].sort()
|
||||
}
|
||||
|
||||
function hashCidrs(cidrs: string[]): string {
|
||||
return `sha256:${createHash('sha256').update(cidrs.join('\n')).digest('hex')}`
|
||||
}
|
||||
|
||||
async function fetchJsonUrl(url: string): Promise<string[]> {
|
||||
const res = await fetch(url, {
|
||||
headers: { Accept: 'application/json' },
|
||||
signal: AbortSignal.timeout(30_000),
|
||||
})
|
||||
if (!res.ok) throw new Error(`JSON URL HTTP ${res.status}`)
|
||||
const data = (await res.json()) as unknown
|
||||
const out: string[] = []
|
||||
const push = (v: unknown) => {
|
||||
if (typeof v === 'string' && v.trim()) out.push(v.trim())
|
||||
}
|
||||
if (Array.isArray(data)) {
|
||||
for (const item of data) {
|
||||
if (typeof item === 'string') push(item)
|
||||
else if (item && typeof item === 'object') {
|
||||
const o = item as Record<string, unknown>
|
||||
push(o.cidr ?? o.prefix ?? o.ip ?? o.network)
|
||||
}
|
||||
}
|
||||
} else if (data && typeof data === 'object') {
|
||||
const o = data as Record<string, unknown>
|
||||
const arr = (o.prefixes ?? o.cidrs ?? o.ips ?? o.items) as unknown
|
||||
if (Array.isArray(arr)) {
|
||||
for (const item of arr) {
|
||||
if (typeof item === 'string') push(item)
|
||||
else if (item && typeof item === 'object') {
|
||||
const x = item as Record<string, unknown>
|
||||
push(x.cidr ?? x.prefix ?? x.ip)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return uniq(out)
|
||||
}
|
||||
|
||||
async function resolveDomains(domains: string[]): Promise<string[]> {
|
||||
const out: string[] = []
|
||||
for (const d of domains) {
|
||||
const host = d.trim().replace(/\.$/, '')
|
||||
if (!host) continue
|
||||
try {
|
||||
const a = await resolve4(host)
|
||||
out.push(...a.map((ip) => `${ip}/32`))
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
try {
|
||||
const aaaa = await resolve6(host)
|
||||
out.push(...aaaa.map((ip) => `${ip}/128`))
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
return uniq(out)
|
||||
}
|
||||
|
||||
async function fetchEvobgpCommunity(
|
||||
apiUrl: string,
|
||||
token: string,
|
||||
communityId: string,
|
||||
): Promise<string[]> {
|
||||
const base = apiUrl.replace(/\/$/, '')
|
||||
// Prefer published revision prefixes filtered by community when available.
|
||||
const url = `${base}/v1/directories/communities/${encodeURIComponent(communityId)}/prefixes`
|
||||
const res = await fetch(url, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
Accept: 'application/json',
|
||||
},
|
||||
signal: AbortSignal.timeout(45_000),
|
||||
})
|
||||
if (res.ok) {
|
||||
const data = (await res.json()) as { items?: { prefix?: string }[]; prefixes?: string[] }
|
||||
if (Array.isArray(data.prefixes)) return uniq(data.prefixes)
|
||||
if (Array.isArray(data.items)) {
|
||||
return uniq(data.items.map((i) => i.prefix ?? '').filter(Boolean))
|
||||
}
|
||||
}
|
||||
// Fallback: modules lookup / openapi-compatible list
|
||||
const alt = `${base}/v1/lookup?q=${encodeURIComponent(communityId)}`
|
||||
const res2 = await fetch(alt, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
Accept: 'application/json',
|
||||
},
|
||||
signal: AbortSignal.timeout(45_000),
|
||||
})
|
||||
if (!res2.ok) {
|
||||
throw new Error(`EvoBGP community fetch failed: ${res.status}/${res2.status}`)
|
||||
}
|
||||
const data2 = (await res2.json()) as { prefixes?: string[] }
|
||||
return uniq(data2.prefixes ?? [])
|
||||
}
|
||||
|
||||
export async function refreshIpList(db: Db, listId: string): Promise<void> {
|
||||
const list = repos.getIpList(db, listId)
|
||||
if (!list) return
|
||||
|
||||
let config: Record<string, unknown> = {}
|
||||
try {
|
||||
config = JSON.parse(list.configJson || '{}') as Record<string, unknown>
|
||||
} catch {
|
||||
config = {}
|
||||
}
|
||||
|
||||
try {
|
||||
let cidrs: string[] = []
|
||||
if (list.type === 'static') {
|
||||
cidrs = repos.listIpListEntries(db, listId).map((e) => e.cidr)
|
||||
} else if (list.type === 'json_url') {
|
||||
const url = String(config.url ?? '')
|
||||
if (!url) throw new Error('config.url required')
|
||||
cidrs = await fetchJsonUrl(url)
|
||||
repos.replaceIpListEntries(db, listId, cidrs)
|
||||
} else if (list.type === 'domains') {
|
||||
const domains = Array.isArray(config.domains)
|
||||
? (config.domains as string[])
|
||||
: String(config.domains ?? '')
|
||||
.split(/[\s,]+/)
|
||||
.filter(Boolean)
|
||||
cidrs = await resolveDomains(domains)
|
||||
repos.replaceIpListEntries(db, listId, cidrs)
|
||||
} else if (list.type === 'evobgp_community') {
|
||||
const apiUrl =
|
||||
String(config.api_url ?? '') || repos.getSetting(db, 'evobgp_api_url')
|
||||
const token =
|
||||
String(config.api_token ?? '') ||
|
||||
repos.getSetting(db, 'evobgp_api_token')
|
||||
const communityId = String(config.community_id ?? '')
|
||||
if (!apiUrl || !token || !communityId) {
|
||||
throw new Error('evobgp_api_url, token and community_id required')
|
||||
}
|
||||
cidrs = await fetchEvobgpCommunity(apiUrl, token, communityId)
|
||||
repos.replaceIpListEntries(db, listId, cidrs)
|
||||
}
|
||||
|
||||
const contentHash = hashCidrs(cidrs)
|
||||
repos.updateIpList(db, listId, {
|
||||
contentHash,
|
||||
refreshedAt: new Date().toISOString(),
|
||||
lastError: null,
|
||||
})
|
||||
|
||||
// Bump all agents so they re-fetch policy
|
||||
for (const a of repos.listAgents(db)) {
|
||||
if (a.status === 'approved') repos.bumpAgentGeneration(db, a.id)
|
||||
}
|
||||
} catch (err) {
|
||||
repos.updateIpList(db, listId, {
|
||||
lastError: err instanceof Error ? err.message : String(err),
|
||||
refreshedAt: new Date().toISOString(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export async function refreshAllLists(db: Db): Promise<void> {
|
||||
for (const list of repos.listIpLists(db)) {
|
||||
if (list.type === 'static') continue
|
||||
await refreshIpList(db, list.id)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import { createHash } from 'node:crypto'
|
||||
import type { Db } from '@evofw/db'
|
||||
import { repos } from '@evofw/db'
|
||||
|
||||
export type EvaluatedPolicy = {
|
||||
generation: number
|
||||
hash: string
|
||||
policyMode: 'blacklist' | 'whitelist'
|
||||
denyCidrs: string[]
|
||||
allowCidrs: string[]
|
||||
syncIntervalSec: number
|
||||
}
|
||||
|
||||
function uniq(cidrs: string[]): string[] {
|
||||
const seen = new Set<string>()
|
||||
const out: string[] = []
|
||||
for (const c of cidrs) {
|
||||
const t = c.trim()
|
||||
if (!t || seen.has(t)) continue
|
||||
seen.add(t)
|
||||
out.push(t)
|
||||
}
|
||||
return out.sort()
|
||||
}
|
||||
|
||||
function expandList(db: Db, listId: string | null | undefined): string[] {
|
||||
if (!listId) return []
|
||||
return repos.listIpListEntries(db, listId).map((e) => e.cidr)
|
||||
}
|
||||
|
||||
/** Evaluate allow/deny sets for an agent. */
|
||||
export function evaluateAgentPolicy(db: Db, agentId: string): EvaluatedPolicy {
|
||||
const agent = repos.getAgent(db, agentId)
|
||||
if (!agent) {
|
||||
throw new Error(`agent not found: ${agentId}`)
|
||||
}
|
||||
|
||||
const agentRules = repos.listPolicyRules(db, agentId)
|
||||
const tenantRules = repos.listPolicyRules(db, null)
|
||||
const ordered = [...agentRules, ...tenantRules].sort(
|
||||
(a, b) => a.priority - b.priority,
|
||||
)
|
||||
|
||||
const deny: string[] = []
|
||||
const allow: string[] = []
|
||||
|
||||
for (const rule of ordered) {
|
||||
const cidrs = rule.cidr
|
||||
? [rule.cidr]
|
||||
: expandList(db, rule.listId)
|
||||
if (rule.action === 'deny') deny.push(...cidrs)
|
||||
else allow.push(...cidrs)
|
||||
}
|
||||
|
||||
for (const o of repos.listOverrides(db, agentId)) {
|
||||
if (o.action === 'deny') deny.push(o.cidr)
|
||||
else allow.push(o.cidr)
|
||||
}
|
||||
|
||||
const denyCidrs = uniq(deny)
|
||||
const allowCidrs = uniq(allow)
|
||||
const policyMode = (agent.policyMode === 'whitelist'
|
||||
? 'whitelist'
|
||||
: 'blacklist') as 'blacklist' | 'whitelist'
|
||||
|
||||
const payload = JSON.stringify({
|
||||
generation: agent.policyGeneration,
|
||||
policyMode,
|
||||
denyCidrs,
|
||||
allowCidrs,
|
||||
})
|
||||
const hash = `sha256:${createHash('sha256').update(payload).digest('hex')}`
|
||||
|
||||
const syncIntervalSec =
|
||||
Number(repos.getSetting(db, 'agent_sync_interval_sec') || '60') || 60
|
||||
|
||||
return {
|
||||
generation: agent.policyGeneration,
|
||||
hash,
|
||||
policyMode,
|
||||
denyCidrs,
|
||||
allowCidrs,
|
||||
syncIntervalSec,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>EvoFirewall</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
+35
-5
@@ -1,12 +1,42 @@
|
||||
{
|
||||
"name": "@evofw/web",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "echo \"web: scaffold — Vite app not initialized yet\"",
|
||||
"build": "echo \"web: scaffold — skip\"",
|
||||
"lint": "echo \"web: scaffold — skip\"",
|
||||
"test": "echo \"web: scaffold — skip\""
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@base-ui/react": "^1.5.0",
|
||||
"@evofw/shared": "workspace:*",
|
||||
"@evofw/ui": "workspace:*",
|
||||
"@hookform/resolvers": "^5.0.0",
|
||||
"@tailwindcss/vite": "^4.1.0",
|
||||
"@tanstack/react-query": "^5.80.0",
|
||||
"@tanstack/react-router": "^1.120.0",
|
||||
"@tanstack/react-table": "^8.21.0",
|
||||
"@tanstack/router-plugin": "^1.120.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"lucide-react": "^0.468.0",
|
||||
"next-themes": "^0.4.6",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"react-hook-form": "^7.56.0",
|
||||
"recharts": "^2.15.0",
|
||||
"sonner": "^1.7.0",
|
||||
"tailwindcss": "^4.1.0",
|
||||
"zod": "^4.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.15.0",
|
||||
"@types/react": "^19.1.0",
|
||||
"@types/react-dom": "^19.1.0",
|
||||
"@vitejs/plugin-react": "^4.5.0",
|
||||
"typescript": "^5.8.3",
|
||||
"vite": "^6.3.0",
|
||||
"vitest": "^3.2.4"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
import { Link, useRouterState } from '@tanstack/react-router'
|
||||
import {
|
||||
LayoutDashboard,
|
||||
Server,
|
||||
List,
|
||||
Shield,
|
||||
BarChart3,
|
||||
Settings,
|
||||
LogOut,
|
||||
} from 'lucide-react'
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
SidebarFooter,
|
||||
SidebarGroup,
|
||||
SidebarGroupContent,
|
||||
SidebarHeader,
|
||||
SidebarInset,
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
SidebarProvider,
|
||||
SidebarTrigger,
|
||||
} from '@evofw/ui/components/sidebar'
|
||||
import { Button } from '@evofw/ui/components/button'
|
||||
import { Separator } from '@evofw/ui/components/separator'
|
||||
import { logout, CURRENT_APP_ID } from '@/lib/auth'
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
const NAV = [
|
||||
{ to: '/', label: 'Дашборд', icon: LayoutDashboard },
|
||||
{ to: '/agents', label: 'Агенты', icon: Server },
|
||||
{ to: '/lists', label: 'Списки IP', icon: List },
|
||||
{ to: '/rules', label: 'Правила', icon: Shield },
|
||||
{ to: '/stats', label: 'Статистика', icon: BarChart3 },
|
||||
{ to: '/settings', label: 'Настройки', icon: Settings },
|
||||
] as const
|
||||
|
||||
export function AppShell({ children }: { children: ReactNode }) {
|
||||
const pathname = useRouterState({ select: (s) => s.location.pathname })
|
||||
|
||||
return (
|
||||
<SidebarProvider
|
||||
style={
|
||||
{
|
||||
'--sidebar-width': '240px',
|
||||
} as React.CSSProperties
|
||||
}
|
||||
>
|
||||
<Sidebar collapsible="icon">
|
||||
<SidebarHeader className="border-b px-3 py-3">
|
||||
<div className="flex items-center gap-2 px-1">
|
||||
<Shield className="size-5" />
|
||||
<div className="flex flex-col">
|
||||
<span className="text-sm font-semibold">EvoFirewall</span>
|
||||
<span className="text-muted-foreground text-[10px] uppercase">
|
||||
{CURRENT_APP_ID}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</SidebarHeader>
|
||||
<SidebarContent>
|
||||
<SidebarGroup>
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
{NAV.map((item) => {
|
||||
const Icon = item.icon
|
||||
const active =
|
||||
item.to === '/'
|
||||
? pathname === '/'
|
||||
: pathname.startsWith(item.to)
|
||||
return (
|
||||
<SidebarMenuItem key={item.to}>
|
||||
<SidebarMenuButton
|
||||
isActive={active}
|
||||
render={<Link to={item.to} />}
|
||||
>
|
||||
<Icon />
|
||||
<span>{item.label}</span>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
)
|
||||
})}
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
</SidebarContent>
|
||||
<SidebarFooter className="border-t p-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="w-full justify-start"
|
||||
onClick={() => logout()}
|
||||
>
|
||||
<LogOut className="size-4" />
|
||||
Выйти
|
||||
</Button>
|
||||
</SidebarFooter>
|
||||
</Sidebar>
|
||||
<SidebarInset>
|
||||
<header className="bg-background sticky top-0 z-10 flex h-12 items-center gap-2 border-b px-4">
|
||||
<SidebarTrigger />
|
||||
<Separator orientation="vertical" className="h-4" />
|
||||
<span className="text-muted-foreground text-sm">Control plane</span>
|
||||
</header>
|
||||
<main className="flex flex-1 flex-col gap-4 px-4 py-4 md:gap-6 md:px-6 md:py-5">
|
||||
{children}
|
||||
</main>
|
||||
</SidebarInset>
|
||||
</SidebarProvider>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { Link } from '@tanstack/react-router'
|
||||
import { Frame } from '@/components/reui/frame'
|
||||
import { cn } from '@evofw/ui/lib/utils'
|
||||
|
||||
export type KpiItem = {
|
||||
id: string
|
||||
label: string
|
||||
value: string | number
|
||||
hint?: string
|
||||
to?: string
|
||||
}
|
||||
|
||||
/** KPI grid — preview: https://reui.io/preview/base/stats-12 */
|
||||
export function KpiStatGrid({
|
||||
items,
|
||||
className,
|
||||
}: {
|
||||
items: KpiItem[]
|
||||
className?: string
|
||||
}) {
|
||||
return (
|
||||
<div className={cn('grid gap-3 sm:grid-cols-2 lg:grid-cols-4', className)}>
|
||||
{items.map((item) => {
|
||||
const inner = (
|
||||
<Frame dense className="h-full transition-colors hover:bg-muted/40">
|
||||
<div className="text-muted-foreground text-xs font-medium uppercase tracking-wide">
|
||||
{item.label}
|
||||
</div>
|
||||
<div className="mt-1 text-2xl font-semibold tabular-nums">{item.value}</div>
|
||||
{item.hint ? (
|
||||
<div className="text-muted-foreground mt-1 text-xs">{item.hint}</div>
|
||||
) : null}
|
||||
</Frame>
|
||||
)
|
||||
return item.to ? (
|
||||
<Link key={item.id} to={item.to} className="block no-underline">
|
||||
{inner}
|
||||
</Link>
|
||||
) : (
|
||||
<div key={item.id}>{inner}</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function PageShell({
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
children: ReactNode
|
||||
className?: string
|
||||
}) {
|
||||
return (
|
||||
<div className={cn('flex flex-col gap-4 md:gap-6', className)}>{children}</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function PageHeader({
|
||||
title,
|
||||
description,
|
||||
actions,
|
||||
}: {
|
||||
title: string
|
||||
description?: string
|
||||
actions?: ReactNode
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div className="flex flex-col gap-px">
|
||||
<h1 className="text-xl font-semibold tracking-tight">{title}</h1>
|
||||
{description ? (
|
||||
<p className="text-muted-foreground text-sm">{description}</p>
|
||||
) : null}
|
||||
</div>
|
||||
{actions ? (
|
||||
<div className="flex shrink-0 flex-wrap items-center justify-end gap-2">
|
||||
{actions}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function EmptyState({
|
||||
title,
|
||||
description,
|
||||
action,
|
||||
}: {
|
||||
title: string
|
||||
description?: string
|
||||
action?: ReactNode
|
||||
}) {
|
||||
return (
|
||||
<Frame className="flex flex-col items-center justify-center gap-2 py-12 text-center">
|
||||
<div className="font-medium">{title}</div>
|
||||
{description ? (
|
||||
<p className="text-muted-foreground max-w-md text-sm">{description}</p>
|
||||
) : null}
|
||||
{action}
|
||||
</Frame>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { cn } from '@evofw/ui/lib/utils'
|
||||
|
||||
/** Minimal Frame surface (ReUI Frame contract) — preview: https://reui.io/docs/components/base/frame */
|
||||
export function Frame({
|
||||
children,
|
||||
className,
|
||||
dense,
|
||||
}: {
|
||||
children: ReactNode
|
||||
className?: string
|
||||
dense?: boolean
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'bg-card text-card-foreground rounded-xl border shadow-xs',
|
||||
dense ? 'p-3' : 'p-4 md:p-5',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function FrameHeader({
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
children: ReactNode
|
||||
className?: string
|
||||
}) {
|
||||
return (
|
||||
<div className={cn('mb-3 flex flex-wrap items-start justify-between gap-2', className)}>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function FrameTitle({
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
children: ReactNode
|
||||
className?: string
|
||||
}) {
|
||||
return <h2 className={cn('text-base font-semibold tracking-tight', className)}>{children}</h2>
|
||||
}
|
||||
|
||||
export function FrameDescription({
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
children: ReactNode
|
||||
className?: string
|
||||
}) {
|
||||
return <p className={cn('text-muted-foreground text-sm', className)}>{children}</p>
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { getToken, clearToken, redirectToPortalLogin, isAuthEnabled } from './auth'
|
||||
|
||||
const API_BASE = import.meta.env.VITE_API_URL ?? ''
|
||||
|
||||
export async function apiFetch<T = unknown>(
|
||||
path: string,
|
||||
init: RequestInit = {},
|
||||
): Promise<T> {
|
||||
const headers = new Headers(init.headers)
|
||||
if (!headers.has('Content-Type') && init.body) {
|
||||
headers.set('Content-Type', 'application/json')
|
||||
}
|
||||
const token = getToken()
|
||||
if (token) headers.set('Authorization', `Bearer ${token}`)
|
||||
|
||||
const res = await fetch(`${API_BASE}${path}`, { ...init, headers })
|
||||
if (res.status === 401 && isAuthEnabled()) {
|
||||
clearToken()
|
||||
redirectToPortalLogin(`${window.location.origin}/auth/callback`)
|
||||
throw new Error('Unauthorized')
|
||||
}
|
||||
if (!res.ok) {
|
||||
let message = res.statusText
|
||||
try {
|
||||
const body = (await res.json()) as { error?: { message?: string } }
|
||||
message = body.error?.message ?? message
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
throw new Error(message)
|
||||
}
|
||||
if (res.status === 204) return undefined as T
|
||||
return (await res.json()) as T
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
/** Portal JWT storage for EvoFirewall (app id `fw`). */
|
||||
|
||||
const TOKEN_KEY = 'fw_auth_token'
|
||||
const HANDOFF_AT_KEY = 'fw_portal_handoff_at'
|
||||
const HANDOFF_COOLDOWN_MS = 12_000
|
||||
const API_BASE = import.meta.env.VITE_API_URL ?? ''
|
||||
|
||||
export type AccessClaims = {
|
||||
sub: string
|
||||
email: string
|
||||
name: string
|
||||
apps: string[]
|
||||
permissions: string[]
|
||||
is_admin?: boolean
|
||||
exp?: number
|
||||
}
|
||||
|
||||
export type RuntimeAuthConfig = {
|
||||
required: boolean
|
||||
portalUrl: string
|
||||
}
|
||||
|
||||
let runtimeConfig: RuntimeAuthConfig | null = null
|
||||
|
||||
function viteAuthEnabled(): boolean {
|
||||
return (
|
||||
import.meta.env.VITE_AUTH_ENABLED === 'true' ||
|
||||
import.meta.env.VITE_AUTH_ENABLED === '1'
|
||||
)
|
||||
}
|
||||
|
||||
function vitePortalUrl(): string {
|
||||
return (import.meta.env.VITE_AUTH_PORTAL_URL ?? 'http://localhost:5175').replace(
|
||||
/\/$/,
|
||||
'',
|
||||
)
|
||||
}
|
||||
|
||||
export async function ensureAuthConfig(): Promise<RuntimeAuthConfig> {
|
||||
if (runtimeConfig) return runtimeConfig
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/auth/config`)
|
||||
if (res.ok) {
|
||||
const data = (await res.json()) as {
|
||||
required?: boolean
|
||||
portal_url?: string
|
||||
}
|
||||
runtimeConfig = {
|
||||
required: Boolean(data.required) || viteAuthEnabled(),
|
||||
portalUrl: (data.portal_url || vitePortalUrl()).replace(/\/$/, ''),
|
||||
}
|
||||
return runtimeConfig
|
||||
}
|
||||
} catch {
|
||||
/* fallback */
|
||||
}
|
||||
runtimeConfig = {
|
||||
required: viteAuthEnabled(),
|
||||
portalUrl: vitePortalUrl(),
|
||||
}
|
||||
return runtimeConfig
|
||||
}
|
||||
|
||||
export function getToken(): string | null {
|
||||
return localStorage.getItem(TOKEN_KEY)
|
||||
}
|
||||
|
||||
export function setToken(token: string) {
|
||||
localStorage.setItem(TOKEN_KEY, token)
|
||||
}
|
||||
|
||||
export function clearToken() {
|
||||
localStorage.removeItem(TOKEN_KEY)
|
||||
}
|
||||
|
||||
export function isAuthEnabled(): boolean {
|
||||
return runtimeConfig?.required ?? viteAuthEnabled()
|
||||
}
|
||||
|
||||
export function authPortalUrl(): string {
|
||||
return runtimeConfig?.portalUrl ?? vitePortalUrl()
|
||||
}
|
||||
|
||||
export function isPortalHandoffCoolingDown(): boolean {
|
||||
const raw = sessionStorage.getItem(HANDOFF_AT_KEY)
|
||||
if (!raw) return false
|
||||
return Date.now() - Number(raw) < HANDOFF_COOLDOWN_MS
|
||||
}
|
||||
|
||||
export function markPortalHandoff(): void {
|
||||
sessionStorage.setItem(HANDOFF_AT_KEY, String(Date.now()))
|
||||
}
|
||||
|
||||
export function parseClaims(token: string): AccessClaims | null {
|
||||
try {
|
||||
const payload = token.split('.')[1]
|
||||
if (!payload) return null
|
||||
const json = atob(payload.replace(/-/g, '+').replace(/_/g, '/'))
|
||||
return JSON.parse(json) as AccessClaims
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function isTokenValid(): boolean {
|
||||
const t = getToken()
|
||||
if (!t) return false
|
||||
const c = parseClaims(t)
|
||||
if (!c?.exp) return !!t
|
||||
return c.exp * 1000 > Date.now() + 5_000
|
||||
}
|
||||
|
||||
export function redirectToPortalLogin(returnTo: string) {
|
||||
if (isPortalHandoffCoolingDown()) return
|
||||
markPortalHandoff()
|
||||
const portal = authPortalUrl()
|
||||
const url = `${portal}/?return_to=${encodeURIComponent(returnTo)}`
|
||||
window.location.href = url
|
||||
}
|
||||
|
||||
export function logout() {
|
||||
clearToken()
|
||||
window.location.href = `${authPortalUrl()}/logout`
|
||||
}
|
||||
|
||||
export const CURRENT_APP_ID = 'fw'
|
||||
@@ -0,0 +1,39 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { RouterProvider, createRouter } from '@tanstack/react-router'
|
||||
import { ThemeProvider } from 'next-themes'
|
||||
import { Toaster } from '@evofw/ui/components/sonner'
|
||||
import { TooltipProvider } from '@evofw/ui/components/tooltip'
|
||||
import { routeTree } from './routeTree.gen'
|
||||
import '@evofw/ui/globals.css'
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { staleTime: 10_000, retry: 1 },
|
||||
},
|
||||
})
|
||||
|
||||
const router = createRouter({
|
||||
routeTree,
|
||||
context: { queryClient },
|
||||
})
|
||||
|
||||
declare module '@tanstack/react-router' {
|
||||
interface Register {
|
||||
router: typeof router
|
||||
}
|
||||
}
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
|
||||
<TooltipProvider>
|
||||
<RouterProvider router={router} />
|
||||
<Toaster />
|
||||
</TooltipProvider>
|
||||
</ThemeProvider>
|
||||
</QueryClientProvider>
|
||||
</StrictMode>,
|
||||
)
|
||||
@@ -0,0 +1,82 @@
|
||||
import { queryOptions } from '@tanstack/react-query'
|
||||
import { apiFetch } from '@/lib/api'
|
||||
import type { Agent, DashboardStats, IpList, PolicyRule } from '@evofw/shared'
|
||||
|
||||
export const dashboardQueryOptions = () =>
|
||||
queryOptions({
|
||||
queryKey: ['dashboard'],
|
||||
queryFn: () => apiFetch<DashboardStats>('/api/v1/dashboard'),
|
||||
})
|
||||
|
||||
export const agentsQueryOptions = () =>
|
||||
queryOptions({
|
||||
queryKey: ['agents'],
|
||||
queryFn: () => apiFetch<{ items: Agent[] }>('/api/v1/agents'),
|
||||
})
|
||||
|
||||
export const agentQueryOptions = (id: string) =>
|
||||
queryOptions({
|
||||
queryKey: ['agents', id],
|
||||
queryFn: () => apiFetch<Agent>(`/api/v1/agents/${id}`),
|
||||
})
|
||||
|
||||
export const listsQueryOptions = () =>
|
||||
queryOptions({
|
||||
queryKey: ['lists'],
|
||||
queryFn: () => apiFetch<{ items: IpList[] }>('/api/v1/lists'),
|
||||
})
|
||||
|
||||
export const rulesQueryOptions = (agentId?: string) =>
|
||||
queryOptions({
|
||||
queryKey: ['rules', agentId ?? 'all'],
|
||||
queryFn: () =>
|
||||
apiFetch<{ items: PolicyRule[] }>(
|
||||
`/api/v1/rules${agentId ? `?agent_id=${encodeURIComponent(agentId)}` : ''}`,
|
||||
),
|
||||
})
|
||||
|
||||
export const installContextQueryOptions = () =>
|
||||
queryOptions({
|
||||
queryKey: ['install-context'],
|
||||
queryFn: () =>
|
||||
apiFetch<{
|
||||
suggested_cp_url: string
|
||||
enroll_seed: string
|
||||
install_sh_url: string
|
||||
mikrotik_url: string
|
||||
sync_interval_sec: number
|
||||
}>('/api/v1/install-context'),
|
||||
})
|
||||
|
||||
export const settingsQueryOptions = () =>
|
||||
queryOptions({
|
||||
queryKey: ['settings'],
|
||||
queryFn: () => apiFetch<Record<string, string>>('/api/v1/settings'),
|
||||
})
|
||||
|
||||
export const agentStatsQueryOptions = (id: string) =>
|
||||
queryOptions({
|
||||
queryKey: ['agent-stats', id],
|
||||
queryFn: () =>
|
||||
apiFetch<{
|
||||
items: {
|
||||
packets_dropped: number
|
||||
packets_accepted: number
|
||||
recorded_at: string
|
||||
}[]
|
||||
}>(`/api/v1/agents/${id}/stats`),
|
||||
})
|
||||
|
||||
export const recentStatsQueryOptions = () =>
|
||||
queryOptions({
|
||||
queryKey: ['stats-recent'],
|
||||
queryFn: () =>
|
||||
apiFetch<{
|
||||
items: {
|
||||
agent_id: string
|
||||
packets_dropped: number
|
||||
packets_accepted: number
|
||||
recorded_at: string
|
||||
}[]
|
||||
}>('/api/v1/stats/recent'),
|
||||
})
|
||||
@@ -0,0 +1,244 @@
|
||||
/* eslint-disable */
|
||||
|
||||
// @ts-nocheck
|
||||
|
||||
// noinspection JSUnusedGlobalSymbols
|
||||
|
||||
// This file was automatically generated by TanStack Router.
|
||||
// You should NOT make any changes in this file as it will be overwritten.
|
||||
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
|
||||
|
||||
import { Route as rootRouteImport } from './routes/__root'
|
||||
import { Route as AuthRouteImport } from './routes/_auth'
|
||||
import { Route as AuthIndexRouteImport } from './routes/_auth/index'
|
||||
import { Route as AuthAgentsRouteImport } from './routes/_auth/agents'
|
||||
import { Route as AuthListsRouteImport } from './routes/_auth/lists'
|
||||
import { Route as AuthRulesRouteImport } from './routes/_auth/rules'
|
||||
import { Route as AuthSettingsRouteImport } from './routes/_auth/settings'
|
||||
import { Route as AuthStatsRouteImport } from './routes/_auth/stats'
|
||||
import { Route as AuthCallbackRouteImport } from './routes/auth.callback'
|
||||
import { Route as AuthAgentsIdRouteImport } from './routes/_auth/agents.$id'
|
||||
|
||||
const AuthRoute = AuthRouteImport.update({
|
||||
id: '/_auth',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const AuthIndexRoute = AuthIndexRouteImport.update({
|
||||
id: '/',
|
||||
path: '/',
|
||||
getParentRoute: () => AuthRoute,
|
||||
} as any)
|
||||
const AuthAgentsRoute = AuthAgentsRouteImport.update({
|
||||
id: '/agents',
|
||||
path: '/agents',
|
||||
getParentRoute: () => AuthRoute,
|
||||
} as any)
|
||||
const AuthListsRoute = AuthListsRouteImport.update({
|
||||
id: '/lists',
|
||||
path: '/lists',
|
||||
getParentRoute: () => AuthRoute,
|
||||
} as any)
|
||||
const AuthRulesRoute = AuthRulesRouteImport.update({
|
||||
id: '/rules',
|
||||
path: '/rules',
|
||||
getParentRoute: () => AuthRoute,
|
||||
} as any)
|
||||
const AuthSettingsRoute = AuthSettingsRouteImport.update({
|
||||
id: '/settings',
|
||||
path: '/settings',
|
||||
getParentRoute: () => AuthRoute,
|
||||
} as any)
|
||||
const AuthStatsRoute = AuthStatsRouteImport.update({
|
||||
id: '/stats',
|
||||
path: '/stats',
|
||||
getParentRoute: () => AuthRoute,
|
||||
} as any)
|
||||
const AuthCallbackRoute = AuthCallbackRouteImport.update({
|
||||
id: '/auth/callback',
|
||||
path: '/auth/callback',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const AuthAgentsIdRoute = AuthAgentsIdRouteImport.update({
|
||||
id: '/$id',
|
||||
path: '/$id',
|
||||
getParentRoute: () => AuthAgentsRoute,
|
||||
} as any)
|
||||
|
||||
export interface FileRoutesByFullPath {
|
||||
'/': typeof AuthIndexRoute
|
||||
'/agents': typeof AuthAgentsRouteWithChildren
|
||||
'/lists': typeof AuthListsRoute
|
||||
'/rules': typeof AuthRulesRoute
|
||||
'/settings': typeof AuthSettingsRoute
|
||||
'/stats': typeof AuthStatsRoute
|
||||
'/auth/callback': typeof AuthCallbackRoute
|
||||
'/agents/$id': typeof AuthAgentsIdRoute
|
||||
}
|
||||
export interface FileRoutesByTo {
|
||||
'/agents': typeof AuthAgentsRouteWithChildren
|
||||
'/lists': typeof AuthListsRoute
|
||||
'/rules': typeof AuthRulesRoute
|
||||
'/settings': typeof AuthSettingsRoute
|
||||
'/stats': typeof AuthStatsRoute
|
||||
'/auth/callback': typeof AuthCallbackRoute
|
||||
'/': typeof AuthIndexRoute
|
||||
'/agents/$id': typeof AuthAgentsIdRoute
|
||||
}
|
||||
export interface FileRoutesById {
|
||||
__root__: typeof rootRouteImport
|
||||
'/_auth': typeof AuthRouteWithChildren
|
||||
'/_auth/agents': typeof AuthAgentsRouteWithChildren
|
||||
'/_auth/lists': typeof AuthListsRoute
|
||||
'/_auth/rules': typeof AuthRulesRoute
|
||||
'/_auth/settings': typeof AuthSettingsRoute
|
||||
'/_auth/stats': typeof AuthStatsRoute
|
||||
'/auth/callback': typeof AuthCallbackRoute
|
||||
'/_auth/': typeof AuthIndexRoute
|
||||
'/_auth/agents/$id': typeof AuthAgentsIdRoute
|
||||
}
|
||||
export interface FileRouteTypes {
|
||||
fileRoutesByFullPath: FileRoutesByFullPath
|
||||
fullPaths:
|
||||
| '/'
|
||||
| '/agents'
|
||||
| '/lists'
|
||||
| '/rules'
|
||||
| '/settings'
|
||||
| '/stats'
|
||||
| '/auth/callback'
|
||||
| '/agents/$id'
|
||||
fileRoutesByTo: FileRoutesByTo
|
||||
to:
|
||||
| '/agents'
|
||||
| '/lists'
|
||||
| '/rules'
|
||||
| '/settings'
|
||||
| '/stats'
|
||||
| '/auth/callback'
|
||||
| '/'
|
||||
| '/agents/$id'
|
||||
id:
|
||||
| '__root__'
|
||||
| '/_auth'
|
||||
| '/_auth/agents'
|
||||
| '/_auth/lists'
|
||||
| '/_auth/rules'
|
||||
| '/_auth/settings'
|
||||
| '/_auth/stats'
|
||||
| '/auth/callback'
|
||||
| '/_auth/'
|
||||
| '/_auth/agents/$id'
|
||||
fileRoutesById: FileRoutesById
|
||||
}
|
||||
export interface RootRouteChildren {
|
||||
AuthRoute: typeof AuthRouteWithChildren
|
||||
AuthCallbackRoute: typeof AuthCallbackRoute
|
||||
}
|
||||
|
||||
declare module '@tanstack/react-router' {
|
||||
interface FileRoutesByPath {
|
||||
'/_auth': {
|
||||
id: '/_auth'
|
||||
path: ''
|
||||
fullPath: '/'
|
||||
preLoaderRoute: typeof AuthRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/_auth/': {
|
||||
id: '/_auth/'
|
||||
path: '/'
|
||||
fullPath: '/'
|
||||
preLoaderRoute: typeof AuthIndexRouteImport
|
||||
parentRoute: typeof AuthRoute
|
||||
}
|
||||
'/_auth/agents': {
|
||||
id: '/_auth/agents'
|
||||
path: '/agents'
|
||||
fullPath: '/agents'
|
||||
preLoaderRoute: typeof AuthAgentsRouteImport
|
||||
parentRoute: typeof AuthRoute
|
||||
}
|
||||
'/_auth/lists': {
|
||||
id: '/_auth/lists'
|
||||
path: '/lists'
|
||||
fullPath: '/lists'
|
||||
preLoaderRoute: typeof AuthListsRouteImport
|
||||
parentRoute: typeof AuthRoute
|
||||
}
|
||||
'/_auth/rules': {
|
||||
id: '/_auth/rules'
|
||||
path: '/rules'
|
||||
fullPath: '/rules'
|
||||
preLoaderRoute: typeof AuthRulesRouteImport
|
||||
parentRoute: typeof AuthRoute
|
||||
}
|
||||
'/_auth/settings': {
|
||||
id: '/_auth/settings'
|
||||
path: '/settings'
|
||||
fullPath: '/settings'
|
||||
preLoaderRoute: typeof AuthSettingsRouteImport
|
||||
parentRoute: typeof AuthRoute
|
||||
}
|
||||
'/_auth/stats': {
|
||||
id: '/_auth/stats'
|
||||
path: '/stats'
|
||||
fullPath: '/stats'
|
||||
preLoaderRoute: typeof AuthStatsRouteImport
|
||||
parentRoute: typeof AuthRoute
|
||||
}
|
||||
'/auth/callback': {
|
||||
id: '/auth/callback'
|
||||
path: '/auth/callback'
|
||||
fullPath: '/auth/callback'
|
||||
preLoaderRoute: typeof AuthCallbackRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/_auth/agents/$id': {
|
||||
id: '/_auth/agents/$id'
|
||||
path: '/$id'
|
||||
fullPath: '/agents/$id'
|
||||
preLoaderRoute: typeof AuthAgentsIdRouteImport
|
||||
parentRoute: typeof AuthAgentsRoute
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
interface AuthAgentsRouteChildren {
|
||||
AuthAgentsIdRoute: typeof AuthAgentsIdRoute
|
||||
}
|
||||
|
||||
const AuthAgentsRouteChildren: AuthAgentsRouteChildren = {
|
||||
AuthAgentsIdRoute: AuthAgentsIdRoute,
|
||||
}
|
||||
|
||||
const AuthAgentsRouteWithChildren = AuthAgentsRoute._addFileChildren(
|
||||
AuthAgentsRouteChildren,
|
||||
)
|
||||
|
||||
interface AuthRouteChildren {
|
||||
AuthAgentsRoute: typeof AuthAgentsRouteWithChildren
|
||||
AuthListsRoute: typeof AuthListsRoute
|
||||
AuthRulesRoute: typeof AuthRulesRoute
|
||||
AuthSettingsRoute: typeof AuthSettingsRoute
|
||||
AuthStatsRoute: typeof AuthStatsRoute
|
||||
AuthIndexRoute: typeof AuthIndexRoute
|
||||
}
|
||||
|
||||
const AuthRouteChildren: AuthRouteChildren = {
|
||||
AuthAgentsRoute: AuthAgentsRouteWithChildren,
|
||||
AuthListsRoute: AuthListsRoute,
|
||||
AuthRulesRoute: AuthRulesRoute,
|
||||
AuthSettingsRoute: AuthSettingsRoute,
|
||||
AuthStatsRoute: AuthStatsRoute,
|
||||
AuthIndexRoute: AuthIndexRoute,
|
||||
}
|
||||
|
||||
const AuthRouteWithChildren = AuthRoute._addFileChildren(AuthRouteChildren)
|
||||
|
||||
const rootRouteChildren: RootRouteChildren = {
|
||||
AuthRoute: AuthRouteWithChildren,
|
||||
AuthCallbackRoute: AuthCallbackRoute,
|
||||
}
|
||||
export const routeTree = rootRouteImport
|
||||
._addFileChildren(rootRouteChildren)
|
||||
._addFileTypes<FileRouteTypes>()
|
||||
@@ -0,0 +1,25 @@
|
||||
import { createRootRouteWithContext, Outlet } from '@tanstack/react-router'
|
||||
import type { QueryClient } from '@tanstack/react-query'
|
||||
import {
|
||||
ensureAuthConfig,
|
||||
isAuthEnabled,
|
||||
isTokenValid,
|
||||
redirectToPortalLogin,
|
||||
} from '@/lib/auth'
|
||||
|
||||
export type RouterContext = {
|
||||
queryClient: QueryClient
|
||||
}
|
||||
|
||||
export const Route = createRootRouteWithContext<RouterContext>()({
|
||||
beforeLoad: async ({ location }) => {
|
||||
await ensureAuthConfig()
|
||||
if (!isAuthEnabled()) return
|
||||
if (location.pathname.startsWith('/auth/')) return
|
||||
if (!isTokenValid()) {
|
||||
redirectToPortalLogin(`${window.location.origin}/auth/callback`)
|
||||
throw new Error('redirecting to portal')
|
||||
}
|
||||
},
|
||||
component: () => <Outlet />,
|
||||
})
|
||||
@@ -0,0 +1,10 @@
|
||||
import { createFileRoute, Outlet } from '@tanstack/react-router'
|
||||
import { AppShell } from '@/components/layout/app-shell'
|
||||
|
||||
export const Route = createFileRoute('/_auth')({
|
||||
component: () => (
|
||||
<AppShell>
|
||||
<Outlet />
|
||||
</AppShell>
|
||||
),
|
||||
})
|
||||
@@ -0,0 +1,236 @@
|
||||
import { createFileRoute, Link } from '@tanstack/react-router'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { toast } from 'sonner'
|
||||
import { useState } from 'react'
|
||||
import { PageHeader, PageShell } from '@/components/reui-kit'
|
||||
import { Frame, FrameHeader, FrameTitle, FrameDescription } from '@/components/reui/frame'
|
||||
import {
|
||||
agentQueryOptions,
|
||||
agentsQueryOptions,
|
||||
rulesQueryOptions,
|
||||
} from '@/queries'
|
||||
import { apiFetch } from '@/lib/api'
|
||||
import { Button } from '@evofw/ui/components/button'
|
||||
import { Input } from '@evofw/ui/components/input'
|
||||
import { Label } from '@evofw/ui/components/label'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@evofw/ui/components/select'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@evofw/ui/components/table'
|
||||
|
||||
export const Route = createFileRoute('/_auth/agents/$id')({
|
||||
component: AgentDetailPage,
|
||||
})
|
||||
|
||||
function AgentDetailPage() {
|
||||
const { id } = Route.useParams()
|
||||
const qc = useQueryClient()
|
||||
const agentQ = useQuery(agentQueryOptions(id))
|
||||
const rulesQ = useQuery(rulesQueryOptions(id))
|
||||
const agentsQ = useQuery(agentsQueryOptions())
|
||||
const [cidr, setCidr] = useState('')
|
||||
const [action, setAction] = useState<'allow' | 'deny'>('deny')
|
||||
const [cloneFrom, setCloneFrom] = useState('')
|
||||
|
||||
const patchMode = useMutation({
|
||||
mutationFn: (policy_mode: 'blacklist' | 'whitelist') =>
|
||||
apiFetch(`/api/v1/agents/${id}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ policy_mode }),
|
||||
}),
|
||||
onSuccess: () => {
|
||||
toast.success('Режим обновлён')
|
||||
void qc.invalidateQueries({ queryKey: ['agents'] })
|
||||
},
|
||||
})
|
||||
|
||||
const addOverride = useMutation({
|
||||
mutationFn: () =>
|
||||
apiFetch(`/api/v1/agents/${id}/overrides`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ cidr, action }),
|
||||
}),
|
||||
onSuccess: () => {
|
||||
toast.success('Override добавлен — подхватится на следующей итерации sync')
|
||||
setCidr('')
|
||||
void qc.invalidateQueries({ queryKey: ['agents', id] })
|
||||
},
|
||||
onError: (e: Error) => toast.error(e.message),
|
||||
})
|
||||
|
||||
const clone = useMutation({
|
||||
mutationFn: () =>
|
||||
apiFetch(`/api/v1/agents/${id}/clone-from/${cloneFrom}`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ include_overrides: true }),
|
||||
}),
|
||||
onSuccess: () => {
|
||||
toast.success('Правила скопированы')
|
||||
void qc.invalidateQueries({ queryKey: ['rules'] })
|
||||
void qc.invalidateQueries({ queryKey: ['agents'] })
|
||||
},
|
||||
onError: (e: Error) => toast.error(e.message),
|
||||
})
|
||||
|
||||
const a = agentQ.data
|
||||
if (!a) {
|
||||
return <PageShell><PageHeader title="Агент" description="Загрузка…" /></PageShell>
|
||||
}
|
||||
|
||||
return (
|
||||
<PageShell>
|
||||
<PageHeader
|
||||
title={a.name}
|
||||
description={`${a.platform} · ${a.status} · gen ${a.policy_generation}`}
|
||||
actions={
|
||||
<Link to="/agents" className="inline-flex">
|
||||
<Button variant="outline" type="button">
|
||||
К списку
|
||||
</Button>
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
<Frame>
|
||||
<FrameHeader>
|
||||
<FrameTitle>Политика</FrameTitle>
|
||||
<FrameDescription>
|
||||
blacklist = deny set; whitelist = allow set + default drop
|
||||
</FrameDescription>
|
||||
</FrameHeader>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
variant={a.policy_mode === 'blacklist' ? 'default' : 'outline'}
|
||||
onClick={() => patchMode.mutate('blacklist')}
|
||||
>
|
||||
Blacklist
|
||||
</Button>
|
||||
<Button
|
||||
variant={a.policy_mode === 'whitelist' ? 'default' : 'outline'}
|
||||
onClick={() => patchMode.mutate('whitelist')}
|
||||
>
|
||||
Whitelist
|
||||
</Button>
|
||||
</div>
|
||||
<dl className="mt-4 grid grid-cols-2 gap-2 text-sm">
|
||||
<dt className="text-muted-foreground">Dropped</dt>
|
||||
<dd className="tabular-nums">{a.last_apply_packets_dropped ?? 0}</dd>
|
||||
<dt className="text-muted-foreground">Accepted</dt>
|
||||
<dd className="tabular-nums">{a.last_apply_packets_accepted ?? 0}</dd>
|
||||
<dt className="text-muted-foreground">Kernel</dt>
|
||||
<dd>{a.last_apply_kernel_method ?? '—'}</dd>
|
||||
<dt className="text-muted-foreground">Last apply</dt>
|
||||
<dd className="text-xs">{a.last_apply_at ?? '—'}</dd>
|
||||
</dl>
|
||||
</Frame>
|
||||
|
||||
<Frame>
|
||||
<FrameHeader>
|
||||
<FrameTitle>Мгновенный IP override</FrameTitle>
|
||||
<FrameDescription>
|
||||
Обновится на агенте на следующей итерации sync (~1 мин)
|
||||
</FrameDescription>
|
||||
</FrameHeader>
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label>CIDR / IP</Label>
|
||||
<Input
|
||||
placeholder="1.2.3.4/32"
|
||||
value={cidr}
|
||||
onChange={(e) => setCidr(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label>Действие</Label>
|
||||
<Select
|
||||
value={action}
|
||||
onValueChange={(v) => setAction(v as 'allow' | 'deny')}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="deny">deny</SelectItem>
|
||||
<SelectItem value="allow">allow</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => addOverride.mutate()}
|
||||
disabled={!cidr || addOverride.isPending}
|
||||
>
|
||||
Добавить override
|
||||
</Button>
|
||||
</div>
|
||||
</Frame>
|
||||
|
||||
<Frame>
|
||||
<FrameHeader>
|
||||
<FrameTitle>Копировать правила</FrameTitle>
|
||||
</FrameHeader>
|
||||
<div className="flex flex-col gap-3">
|
||||
<Select value={cloneFrom} onValueChange={setCloneFrom}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Источник" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(agentsQ.data?.items ?? [])
|
||||
.filter((x) => x.id !== id)
|
||||
.map((x) => (
|
||||
<SelectItem key={x.id} value={x.id}>
|
||||
{x.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={!cloneFrom || clone.isPending}
|
||||
onClick={() => clone.mutate()}
|
||||
>
|
||||
Клонировать (с overrides)
|
||||
</Button>
|
||||
</div>
|
||||
</Frame>
|
||||
|
||||
<Frame>
|
||||
<FrameHeader>
|
||||
<FrameTitle>Правила агента</FrameTitle>
|
||||
</FrameHeader>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Prio</TableHead>
|
||||
<TableHead>Action</TableHead>
|
||||
<TableHead>Source</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{(rulesQ.data?.items ?? []).map((r) => (
|
||||
<TableRow key={r.id}>
|
||||
<TableCell>{r.priority}</TableCell>
|
||||
<TableCell>{r.action}</TableCell>
|
||||
<TableCell className="font-mono text-xs">
|
||||
{r.cidr ?? r.list_id ?? '—'}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Frame>
|
||||
</div>
|
||||
</PageShell>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
import { createFileRoute, Link } from '@tanstack/react-router'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { toast } from 'sonner'
|
||||
import { Copy, Check } from 'lucide-react'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { PageHeader, PageShell, EmptyState } from '@/components/reui-kit'
|
||||
import { Frame, FrameHeader, FrameTitle, FrameDescription } from '@/components/reui/frame'
|
||||
import {
|
||||
agentsQueryOptions,
|
||||
installContextQueryOptions,
|
||||
} from '@/queries'
|
||||
import { apiFetch } from '@/lib/api'
|
||||
import { Button } from '@evofw/ui/components/button'
|
||||
import { Input } from '@evofw/ui/components/input'
|
||||
import { Label } from '@evofw/ui/components/label'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@evofw/ui/components/table'
|
||||
import { Badge } from '@evofw/ui/components/badge'
|
||||
|
||||
export const Route = createFileRoute('/_auth/agents')({
|
||||
component: AgentsPage,
|
||||
})
|
||||
|
||||
function AgentsPage() {
|
||||
const qc = useQueryClient()
|
||||
const agentsQ = useQuery(agentsQueryOptions())
|
||||
const installQ = useQuery(installContextQueryOptions())
|
||||
const [name, setName] = useState('web-01')
|
||||
|
||||
const approve = useMutation({
|
||||
mutationFn: (id: string) =>
|
||||
apiFetch(`/api/v1/agents/${id}/approve`, { method: 'POST' }),
|
||||
onSuccess: () => {
|
||||
toast.success('Агент одобрен')
|
||||
void qc.invalidateQueries({ queryKey: ['agents'] })
|
||||
},
|
||||
onError: (e: Error) => toast.error(e.message),
|
||||
})
|
||||
|
||||
const revoke = useMutation({
|
||||
mutationFn: (id: string) =>
|
||||
apiFetch(`/api/v1/agents/${id}/revoke`, { method: 'POST' }),
|
||||
onSuccess: () => {
|
||||
toast.success('Агент отозван')
|
||||
void qc.invalidateQueries({ queryKey: ['agents'] })
|
||||
},
|
||||
})
|
||||
|
||||
const remove = useMutation({
|
||||
mutationFn: (id: string) =>
|
||||
apiFetch(`/api/v1/agents/${id}`, { method: 'DELETE' }),
|
||||
onSuccess: () => {
|
||||
toast.success('Удалён')
|
||||
void qc.invalidateQueries({ queryKey: ['agents'] })
|
||||
},
|
||||
})
|
||||
|
||||
const installCmd = useMemo(() => {
|
||||
const cp = installQ.data?.suggested_cp_url ?? 'https://fw.example.com'
|
||||
const seed = installQ.data?.enroll_seed ?? '<seed>'
|
||||
return `curl -fsSL ${cp}/v1/agent/install.sh | \\\n EVOFW_CP_URL=${cp} \\\n EVOFW_SEED=${seed} \\\n EVOFW_CLIENT_NAME="${name}" \\\n bash`
|
||||
}, [installQ.data, name])
|
||||
|
||||
const items = agentsQ.data?.items ?? []
|
||||
const pending = items.filter((a) => a.status === 'pending')
|
||||
|
||||
return (
|
||||
<PageShell>
|
||||
<PageHeader
|
||||
title="Агенты"
|
||||
description="Linux / MikroTik — enroll, approve, policy mode. Preview: data-grid-filtering-2"
|
||||
/>
|
||||
|
||||
<Frame>
|
||||
<FrameHeader>
|
||||
<div>
|
||||
<FrameTitle>Установка Linux</FrameTitle>
|
||||
<FrameDescription>
|
||||
One-liner. После enroll одобрите агента ниже.
|
||||
</FrameDescription>
|
||||
</div>
|
||||
</FrameHeader>
|
||||
<div className="mb-3 flex max-w-sm flex-col gap-2">
|
||||
<Label htmlFor="cname">Имя клиента</Label>
|
||||
<Input
|
||||
id="cname"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<pre className="bg-muted overflow-x-auto rounded-lg p-3 text-xs">
|
||||
{installCmd}
|
||||
</pre>
|
||||
<Button
|
||||
className="mt-2"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={async () => {
|
||||
await navigator.clipboard.writeText(installCmd.replace(/\\\n\s*/g, ' '))
|
||||
toast.success('Скопировано')
|
||||
}}
|
||||
>
|
||||
<Copy data-icon="inline-start" />
|
||||
Копировать
|
||||
</Button>
|
||||
{installQ.data?.mikrotik_url ? (
|
||||
<p className="text-muted-foreground mt-3 text-sm">
|
||||
MikroTik:{' '}
|
||||
<a className="underline" href={installQ.data.mikrotik_url}>
|
||||
mikrotik-install.rsc
|
||||
</a>
|
||||
</p>
|
||||
) : null}
|
||||
</Frame>
|
||||
|
||||
{pending.length > 0 ? (
|
||||
<Frame>
|
||||
<FrameHeader>
|
||||
<FrameTitle>Запросы ({pending.length})</FrameTitle>
|
||||
</FrameHeader>
|
||||
<div className="flex flex-col gap-2">
|
||||
{pending.map((a) => (
|
||||
<div
|
||||
key={a.id}
|
||||
className="flex flex-wrap items-center justify-between gap-2 border-b py-2 last:border-0"
|
||||
>
|
||||
<div>
|
||||
<div className="font-medium">{a.name}</div>
|
||||
<div className="text-muted-foreground text-xs">
|
||||
{a.platform} · {a.hostname ?? '—'} · {a.token_prefix}…
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => approve.mutate(a.id)}
|
||||
disabled={approve.isPending}
|
||||
>
|
||||
<Check data-icon="inline-start" />
|
||||
Approve
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => remove.mutate(a.id)}
|
||||
>
|
||||
Удалить
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Frame>
|
||||
) : null}
|
||||
|
||||
<Frame>
|
||||
<FrameHeader>
|
||||
<FrameTitle>Клиенты</FrameTitle>
|
||||
</FrameHeader>
|
||||
{items.length === 0 ? (
|
||||
<EmptyState
|
||||
title="Нет агентов"
|
||||
description="Установите agent на сервер и одобрите запрос."
|
||||
/>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Имя</TableHead>
|
||||
<TableHead>Платформа</TableHead>
|
||||
<TableHead>Статус</TableHead>
|
||||
<TableHead>Режим</TableHead>
|
||||
<TableHead>Seen</TableHead>
|
||||
<TableHead className="text-right">Действия</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{items.map((a) => (
|
||||
<TableRow key={a.id}>
|
||||
<TableCell>
|
||||
<Link
|
||||
to="/agents/$id"
|
||||
params={{ id: a.id }}
|
||||
className="font-medium underline-offset-4 hover:underline"
|
||||
>
|
||||
{a.name}
|
||||
</Link>
|
||||
</TableCell>
|
||||
<TableCell>{a.platform}</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="secondary">{a.status}</Badge>
|
||||
</TableCell>
|
||||
<TableCell>{a.policy_mode}</TableCell>
|
||||
<TableCell className="text-muted-foreground text-xs">
|
||||
{a.last_seen_at ?? '—'}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex justify-end gap-1">
|
||||
{a.status === 'approved' ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => revoke.mutate(a.id)}
|
||||
>
|
||||
Revoke
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => remove.mutate(a.id)}
|
||||
>
|
||||
Удалить
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</Frame>
|
||||
</PageShell>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { PageHeader, PageShell, KpiStatGrid } from '@/components/reui-kit'
|
||||
import { Frame, FrameHeader, FrameTitle } from '@/components/reui/frame'
|
||||
import { dashboardQueryOptions, agentsQueryOptions, recentStatsQueryOptions } from '@/queries'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@evofw/ui/components/table'
|
||||
import { Skeleton } from '@evofw/ui/components/skeleton'
|
||||
|
||||
export const Route = createFileRoute('/_auth/')({
|
||||
component: DashboardPage,
|
||||
})
|
||||
|
||||
function DashboardPage() {
|
||||
const dash = useQuery(dashboardQueryOptions())
|
||||
const agents = useQuery(agentsQueryOptions())
|
||||
const stats = useQuery(recentStatsQueryOptions())
|
||||
|
||||
const d = dash.data
|
||||
const items = [
|
||||
{
|
||||
id: 'agents',
|
||||
label: 'Агенты',
|
||||
value: d?.agents_approved ?? '—',
|
||||
hint: `${d?.agents_online ?? 0} online / ${d?.agents_pending ?? 0} pending`,
|
||||
to: '/agents',
|
||||
},
|
||||
{
|
||||
id: 'dropped',
|
||||
label: 'Dropped',
|
||||
value: d?.packets_dropped ?? '—',
|
||||
hint: 'сумма counters',
|
||||
to: '/stats',
|
||||
},
|
||||
{
|
||||
id: 'accepted',
|
||||
label: 'Accepted',
|
||||
value: d?.packets_accepted ?? '—',
|
||||
hint: 'сумма counters',
|
||||
to: '/stats',
|
||||
},
|
||||
{
|
||||
id: 'lists',
|
||||
label: 'Списки IP',
|
||||
value: d?.lists_total ?? '—',
|
||||
to: '/lists',
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<PageShell>
|
||||
<PageHeader
|
||||
title="Дашборд"
|
||||
description="Обзор агентов и пакетной статистики — ReUI stats-12 / dashboard-1"
|
||||
/>
|
||||
{dash.isLoading ? (
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-24 rounded-xl" />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<KpiStatGrid items={items} />
|
||||
)}
|
||||
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
<Frame>
|
||||
<FrameHeader>
|
||||
<FrameTitle>Агенты</FrameTitle>
|
||||
</FrameHeader>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Имя</TableHead>
|
||||
<TableHead>Статус</TableHead>
|
||||
<TableHead>Режим</TableHead>
|
||||
<TableHead className="text-right">Dropped</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{(agents.data?.items ?? []).slice(0, 8).map((a) => (
|
||||
<TableRow key={a.id}>
|
||||
<TableCell className="font-medium">{a.name}</TableCell>
|
||||
<TableCell>{a.status}</TableCell>
|
||||
<TableCell>{a.policy_mode}</TableCell>
|
||||
<TableCell className="text-right tabular-nums">
|
||||
{a.last_apply_packets_dropped ?? 0}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Frame>
|
||||
|
||||
<Frame>
|
||||
<FrameHeader>
|
||||
<FrameTitle>Последние samples</FrameTitle>
|
||||
</FrameHeader>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Время</TableHead>
|
||||
<TableHead className="text-right">Drop</TableHead>
|
||||
<TableHead className="text-right">Accept</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{(stats.data?.items ?? []).slice(0, 10).map((s, i) => (
|
||||
<TableRow key={`${s.agent_id}-${s.recorded_at}-${i}`}>
|
||||
<TableCell className="text-muted-foreground text-xs">
|
||||
{s.recorded_at}
|
||||
</TableCell>
|
||||
<TableCell className="text-right tabular-nums">
|
||||
{s.packets_dropped}
|
||||
</TableCell>
|
||||
<TableCell className="text-right tabular-nums">
|
||||
{s.packets_accepted}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Frame>
|
||||
</div>
|
||||
</PageShell>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { toast } from 'sonner'
|
||||
import { useState } from 'react'
|
||||
import { PageHeader, PageShell, EmptyState } from '@/components/reui-kit'
|
||||
import { Frame, FrameHeader, FrameTitle } from '@/components/reui/frame'
|
||||
import { listsQueryOptions } from '@/queries'
|
||||
import { apiFetch } from '@/lib/api'
|
||||
import { Button } from '@evofw/ui/components/button'
|
||||
import { Input } from '@evofw/ui/components/input'
|
||||
import { Label } from '@evofw/ui/components/label'
|
||||
import { Textarea } from '@evofw/ui/components/textarea'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@evofw/ui/components/select'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@evofw/ui/components/table'
|
||||
|
||||
export const Route = createFileRoute('/_auth/lists')({
|
||||
component: ListsPage,
|
||||
})
|
||||
|
||||
function ListsPage() {
|
||||
const qc = useQueryClient()
|
||||
const listsQ = useQuery(listsQueryOptions())
|
||||
const [name, setName] = useState('')
|
||||
const [type, setType] = useState<
|
||||
'static' | 'json_url' | 'domains' | 'evobgp_community'
|
||||
>('static')
|
||||
const [extra, setExtra] = useState('')
|
||||
|
||||
const create = useMutation({
|
||||
mutationFn: async () => {
|
||||
const config: Record<string, unknown> = {}
|
||||
let entries: string[] | undefined
|
||||
if (type === 'static') {
|
||||
entries = extra
|
||||
.split(/[\s,]+/)
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean)
|
||||
} else if (type === 'json_url') {
|
||||
config.url = extra.trim()
|
||||
} else if (type === 'domains') {
|
||||
config.domains = extra
|
||||
.split(/[\s,]+/)
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean)
|
||||
} else {
|
||||
config.community_id = extra.trim()
|
||||
}
|
||||
return apiFetch('/api/v1/lists', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ name, type, config, entries }),
|
||||
})
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success('Список создан')
|
||||
setName('')
|
||||
setExtra('')
|
||||
void qc.invalidateQueries({ queryKey: ['lists'] })
|
||||
},
|
||||
onError: (e: Error) => toast.error(e.message),
|
||||
})
|
||||
|
||||
const refresh = useMutation({
|
||||
mutationFn: (id: string) =>
|
||||
apiFetch(`/api/v1/lists/${id}/refresh`, { method: 'POST' }),
|
||||
onSuccess: () => {
|
||||
toast.success('Обновлено')
|
||||
void qc.invalidateQueries({ queryKey: ['lists'] })
|
||||
},
|
||||
})
|
||||
|
||||
const remove = useMutation({
|
||||
mutationFn: (id: string) =>
|
||||
apiFetch(`/api/v1/lists/${id}`, { method: 'DELETE' }),
|
||||
onSuccess: () => {
|
||||
void qc.invalidateQueries({ queryKey: ['lists'] })
|
||||
},
|
||||
})
|
||||
|
||||
const items = listsQ.data?.items ?? []
|
||||
|
||||
return (
|
||||
<PageShell>
|
||||
<PageHeader
|
||||
title="Списки IP"
|
||||
description="static · JSON URL · domains · EvoBGP community"
|
||||
/>
|
||||
|
||||
<Frame>
|
||||
<FrameHeader>
|
||||
<FrameTitle>Новый список</FrameTitle>
|
||||
</FrameHeader>
|
||||
<div className="grid max-w-xl gap-3">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label>Имя</Label>
|
||||
<Input value={name} onChange={(e) => setName(e.target.value)} />
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label>Тип</Label>
|
||||
<Select
|
||||
value={type}
|
||||
onValueChange={(v) =>
|
||||
setType(v as typeof type)
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="static">static</SelectItem>
|
||||
<SelectItem value="json_url">json_url</SelectItem>
|
||||
<SelectItem value="domains">domains</SelectItem>
|
||||
<SelectItem value="evobgp_community">evobgp_community</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label>
|
||||
{type === 'static'
|
||||
? 'CIDR (через пробел/запятую)'
|
||||
: type === 'json_url'
|
||||
? 'URL JSON'
|
||||
: type === 'domains'
|
||||
? 'Домены'
|
||||
: 'Community ID'}
|
||||
</Label>
|
||||
<Textarea
|
||||
value={extra}
|
||||
onChange={(e) => setExtra(e.target.value)}
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
disabled={!name || create.isPending}
|
||||
onClick={() => create.mutate()}
|
||||
>
|
||||
Создать
|
||||
</Button>
|
||||
</div>
|
||||
</Frame>
|
||||
|
||||
<Frame>
|
||||
<FrameHeader>
|
||||
<FrameTitle>Списки</FrameTitle>
|
||||
</FrameHeader>
|
||||
{items.length === 0 ? (
|
||||
<EmptyState title="Пусто" description="Создайте первый список." />
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Имя</TableHead>
|
||||
<TableHead>Тип</TableHead>
|
||||
<TableHead>Entries</TableHead>
|
||||
<TableHead>Refresh</TableHead>
|
||||
<TableHead className="text-right">Действия</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{items.map((l) => (
|
||||
<TableRow key={l.id}>
|
||||
<TableCell className="font-medium">{l.name}</TableCell>
|
||||
<TableCell>{l.type}</TableCell>
|
||||
<TableCell className="tabular-nums">
|
||||
{l.entry_count ?? 0}
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground text-xs">
|
||||
{l.last_error ?? l.refreshed_at ?? '—'}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex justify-end gap-1">
|
||||
{l.type !== 'static' ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => refresh.mutate(l.id)}
|
||||
>
|
||||
Refresh
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => remove.mutate(l.id)}
|
||||
>
|
||||
Удалить
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</Frame>
|
||||
</PageShell>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { toast } from 'sonner'
|
||||
import { useState } from 'react'
|
||||
import { PageHeader, PageShell } from '@/components/reui-kit'
|
||||
import { Frame, FrameHeader, FrameTitle } from '@/components/reui/frame'
|
||||
import { rulesQueryOptions, listsQueryOptions, agentsQueryOptions } from '@/queries'
|
||||
import { apiFetch } from '@/lib/api'
|
||||
import { Button } from '@evofw/ui/components/button'
|
||||
import { Input } from '@evofw/ui/components/input'
|
||||
import { Label } from '@evofw/ui/components/label'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@evofw/ui/components/select'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@evofw/ui/components/table'
|
||||
|
||||
export const Route = createFileRoute('/_auth/rules')({
|
||||
component: RulesPage,
|
||||
})
|
||||
|
||||
function RulesPage() {
|
||||
const qc = useQueryClient()
|
||||
const rulesQ = useQuery(rulesQueryOptions())
|
||||
const listsQ = useQuery(listsQueryOptions())
|
||||
const agentsQ = useQuery(agentsQueryOptions())
|
||||
const [priority, setPriority] = useState('100')
|
||||
const [action, setAction] = useState<'allow' | 'deny'>('deny')
|
||||
const [listId, setListId] = useState('')
|
||||
const [agentId, setAgentId] = useState('tenant')
|
||||
|
||||
const create = useMutation({
|
||||
mutationFn: () =>
|
||||
apiFetch('/api/v1/rules', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
priority: Number(priority),
|
||||
action,
|
||||
list_id: listId || null,
|
||||
agent_id: agentId === 'tenant' ? null : agentId,
|
||||
}),
|
||||
}),
|
||||
onSuccess: () => {
|
||||
toast.success('Правило создано')
|
||||
void qc.invalidateQueries({ queryKey: ['rules'] })
|
||||
},
|
||||
onError: (e: Error) => toast.error(e.message),
|
||||
})
|
||||
|
||||
const remove = useMutation({
|
||||
mutationFn: (id: string) =>
|
||||
apiFetch(`/api/v1/rules/${id}`, { method: 'DELETE' }),
|
||||
onSuccess: () => void qc.invalidateQueries({ queryKey: ['rules'] }),
|
||||
})
|
||||
|
||||
return (
|
||||
<PageShell>
|
||||
<PageHeader
|
||||
title="Правила"
|
||||
description="Упорядоченные allow/deny по списку или CIDR (tenant + per-agent)"
|
||||
/>
|
||||
|
||||
<Frame>
|
||||
<FrameHeader>
|
||||
<FrameTitle>Новое правило</FrameTitle>
|
||||
</FrameHeader>
|
||||
<div className="grid max-w-xl gap-3 sm:grid-cols-2">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label>Priority</Label>
|
||||
<Input
|
||||
value={priority}
|
||||
onChange={(e) => setPriority(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label>Action</Label>
|
||||
<Select
|
||||
value={action}
|
||||
onValueChange={(v) => setAction(v as 'allow' | 'deny')}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="deny">deny</SelectItem>
|
||||
<SelectItem value="allow">allow</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label>Список</Label>
|
||||
<Select value={listId} onValueChange={setListId}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="IP list" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(listsQ.data?.items ?? []).map((l) => (
|
||||
<SelectItem key={l.id} value={l.id}>
|
||||
{l.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label>Scope</Label>
|
||||
<Select value={agentId} onValueChange={setAgentId}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="tenant">Tenant default</SelectItem>
|
||||
{(agentsQ.data?.items ?? []).map((a) => (
|
||||
<SelectItem key={a.id} value={a.id}>
|
||||
{a.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<Button
|
||||
className="sm:col-span-2"
|
||||
disabled={!listId || create.isPending}
|
||||
onClick={() => create.mutate()}
|
||||
>
|
||||
Создать
|
||||
</Button>
|
||||
</div>
|
||||
</Frame>
|
||||
|
||||
<Frame>
|
||||
<FrameHeader>
|
||||
<FrameTitle>Все правила</FrameTitle>
|
||||
</FrameHeader>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Prio</TableHead>
|
||||
<TableHead>Action</TableHead>
|
||||
<TableHead>Agent</TableHead>
|
||||
<TableHead>List / CIDR</TableHead>
|
||||
<TableHead />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{(rulesQ.data?.items ?? []).map((r) => (
|
||||
<TableRow key={r.id}>
|
||||
<TableCell>{r.priority}</TableCell>
|
||||
<TableCell>{r.action}</TableCell>
|
||||
<TableCell className="text-xs">
|
||||
{r.agent_id ?? 'tenant'}
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-xs">
|
||||
{r.cidr ?? r.list_id ?? '—'}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => remove.mutate(r.id)}
|
||||
>
|
||||
Удалить
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Frame>
|
||||
</PageShell>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { toast } from 'sonner'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { PageHeader, PageShell } from '@/components/reui-kit'
|
||||
import { Frame, FrameHeader, FrameTitle, FrameDescription } from '@/components/reui/frame'
|
||||
import { settingsQueryOptions } from '@/queries'
|
||||
import { apiFetch } from '@/lib/api'
|
||||
import { Button } from '@evofw/ui/components/button'
|
||||
import { Input } from '@evofw/ui/components/input'
|
||||
import { Label } from '@evofw/ui/components/label'
|
||||
|
||||
export const Route = createFileRoute('/_auth/settings')({
|
||||
component: SettingsPage,
|
||||
})
|
||||
|
||||
function SettingsPage() {
|
||||
const qc = useQueryClient()
|
||||
const settingsQ = useQuery(settingsQueryOptions())
|
||||
const [form, setForm] = useState<Record<string, string>>({})
|
||||
|
||||
useEffect(() => {
|
||||
if (settingsQ.data) setForm(settingsQ.data)
|
||||
}, [settingsQ.data])
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: () =>
|
||||
apiFetch('/api/v1/settings', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(form),
|
||||
}),
|
||||
onSuccess: () => {
|
||||
toast.success('Сохранено')
|
||||
void qc.invalidateQueries({ queryKey: ['settings'] })
|
||||
void qc.invalidateQueries({ queryKey: ['install-context'] })
|
||||
},
|
||||
onError: (e: Error) => toast.error(e.message),
|
||||
})
|
||||
|
||||
const fields = [
|
||||
{
|
||||
key: 'enroll_seed',
|
||||
label: 'Enroll seed',
|
||||
hint: 'X-EvoFW-Seed для install.sh',
|
||||
},
|
||||
{
|
||||
key: 'evobgp_api_url',
|
||||
label: 'EvoBGP API URL',
|
||||
hint: 'Источник community prefixes',
|
||||
},
|
||||
{
|
||||
key: 'evobgp_api_token',
|
||||
label: 'EvoBGP API token',
|
||||
hint: 'Bearer для интеграции',
|
||||
},
|
||||
{
|
||||
key: 'agent_sync_interval_sec',
|
||||
label: 'Agent sync interval (sec)',
|
||||
hint: 'Рекомендуется 30–60',
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<PageShell>
|
||||
<PageHeader
|
||||
title="Настройки"
|
||||
description="Интеграции и enroll — settings-16"
|
||||
/>
|
||||
<Frame>
|
||||
<FrameHeader>
|
||||
<div>
|
||||
<FrameTitle>Control plane</FrameTitle>
|
||||
<FrameDescription>
|
||||
Auth-portal app id: fw · JWT через AUTH_*
|
||||
</FrameDescription>
|
||||
</div>
|
||||
</FrameHeader>
|
||||
<div className="flex max-w-xl flex-col gap-4">
|
||||
{fields.map((f) => (
|
||||
<div key={f.key} className="flex flex-col gap-2">
|
||||
<Label htmlFor={f.key}>{f.label}</Label>
|
||||
<Input
|
||||
id={f.key}
|
||||
type={f.key.includes('token') ? 'password' : 'text'}
|
||||
value={form[f.key] ?? ''}
|
||||
onChange={(e) =>
|
||||
setForm((prev) => ({ ...prev, [f.key]: e.target.value }))
|
||||
}
|
||||
/>
|
||||
<p className="text-muted-foreground text-xs">{f.hint}</p>
|
||||
</div>
|
||||
))}
|
||||
<Button onClick={() => save.mutate()} disabled={save.isPending}>
|
||||
Сохранить
|
||||
</Button>
|
||||
</div>
|
||||
</Frame>
|
||||
</PageShell>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { PageHeader, PageShell, KpiStatGrid } from '@/components/reui-kit'
|
||||
import { Frame, FrameHeader, FrameTitle } from '@/components/reui/frame'
|
||||
import { dashboardQueryOptions, recentStatsQueryOptions } from '@/queries'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@evofw/ui/components/table'
|
||||
import {
|
||||
ChartContainer,
|
||||
ChartTooltip,
|
||||
ChartTooltipContent,
|
||||
type ChartConfig,
|
||||
} from '@evofw/ui/components/chart'
|
||||
import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from 'recharts'
|
||||
|
||||
export const Route = createFileRoute('/_auth/stats')({
|
||||
component: StatsPage,
|
||||
})
|
||||
|
||||
const chartConfig = {
|
||||
dropped: { label: 'Dropped', color: 'var(--chart-1)' },
|
||||
accepted: { label: 'Accepted', color: 'var(--chart-2)' },
|
||||
} satisfies ChartConfig
|
||||
|
||||
function StatsPage() {
|
||||
const dash = useQuery(dashboardQueryOptions())
|
||||
const stats = useQuery(recentStatsQueryOptions())
|
||||
|
||||
const series = [...(stats.data?.items ?? [])]
|
||||
.reverse()
|
||||
.slice(-40)
|
||||
.map((s) => ({
|
||||
t: s.recorded_at.slice(11, 19),
|
||||
dropped: s.packets_dropped,
|
||||
accepted: s.packets_accepted,
|
||||
}))
|
||||
|
||||
return (
|
||||
<PageShell>
|
||||
<PageHeader
|
||||
title="Статистика"
|
||||
description="История apply-report counters — dashboard-1 / charts"
|
||||
/>
|
||||
<KpiStatGrid
|
||||
items={[
|
||||
{
|
||||
id: 'd',
|
||||
label: 'Dropped (sum)',
|
||||
value: dash.data?.packets_dropped ?? 0,
|
||||
},
|
||||
{
|
||||
id: 'a',
|
||||
label: 'Accepted (sum)',
|
||||
value: dash.data?.packets_accepted ?? 0,
|
||||
},
|
||||
{
|
||||
id: 'o',
|
||||
label: 'Online agents',
|
||||
value: dash.data?.agents_online ?? 0,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
<Frame>
|
||||
<FrameHeader>
|
||||
<FrameTitle>Тренд (последние samples)</FrameTitle>
|
||||
</FrameHeader>
|
||||
{series.length === 0 ? (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Нет данных — дождитесь apply-report от агентов.
|
||||
</p>
|
||||
) : (
|
||||
<ChartContainer config={chartConfig} className="aspect-[2/1] w-full">
|
||||
<AreaChart data={series}>
|
||||
<CartesianGrid vertical={false} />
|
||||
<XAxis dataKey="t" tickLine={false} axisLine={false} />
|
||||
<YAxis tickLine={false} axisLine={false} />
|
||||
<ChartTooltip content={<ChartTooltipContent />} />
|
||||
<Area
|
||||
dataKey="dropped"
|
||||
type="monotone"
|
||||
fill="var(--color-dropped)"
|
||||
stroke="var(--color-dropped)"
|
||||
fillOpacity={0.3}
|
||||
/>
|
||||
<Area
|
||||
dataKey="accepted"
|
||||
type="monotone"
|
||||
fill="var(--color-accepted)"
|
||||
stroke="var(--color-accepted)"
|
||||
fillOpacity={0.2}
|
||||
/>
|
||||
</AreaChart>
|
||||
</ChartContainer>
|
||||
)}
|
||||
</Frame>
|
||||
|
||||
<Frame>
|
||||
<FrameHeader>
|
||||
<FrameTitle>Сырые samples</FrameTitle>
|
||||
</FrameHeader>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Agent</TableHead>
|
||||
<TableHead>Time</TableHead>
|
||||
<TableHead className="text-right">Drop</TableHead>
|
||||
<TableHead className="text-right">Accept</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{(stats.data?.items ?? []).slice(0, 50).map((s, i) => (
|
||||
<TableRow key={`${s.agent_id}-${i}`}>
|
||||
<TableCell className="font-mono text-xs">{s.agent_id.slice(0, 8)}</TableCell>
|
||||
<TableCell className="text-xs">{s.recorded_at}</TableCell>
|
||||
<TableCell className="text-right tabular-nums">
|
||||
{s.packets_dropped}
|
||||
</TableCell>
|
||||
<TableCell className="text-right tabular-nums">
|
||||
{s.packets_accepted}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Frame>
|
||||
</PageShell>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { setToken } from '@/lib/auth'
|
||||
|
||||
export const Route = createFileRoute('/auth/callback')({
|
||||
component: AuthCallback,
|
||||
})
|
||||
|
||||
function AuthCallback() {
|
||||
const hash = typeof window !== 'undefined' ? window.location.hash : ''
|
||||
const params = new URLSearchParams(hash.replace(/^#/, ''))
|
||||
const token = params.get('access_token')
|
||||
if (token) {
|
||||
setToken(token)
|
||||
window.location.replace('/')
|
||||
} else {
|
||||
return (
|
||||
<div className="flex min-h-svh items-center justify-center p-6">
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Нет access_token в URL. Войдите через auth-portal.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return null
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"],
|
||||
"@evofw/ui/*": ["../../packages/ui/src/*"],
|
||||
"@evofw/shared": ["../../packages/shared/src/index.ts"]
|
||||
}
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import path from 'path'
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
import { TanStackRouterVite } from '@tanstack/router-plugin/vite'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
TanStackRouterVite({ routesDirectory: './src/routes', target: 'react' }),
|
||||
react(),
|
||||
tailwindcss(),
|
||||
],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, './src'),
|
||||
'@evofw/shared': path.resolve(
|
||||
__dirname,
|
||||
'../../packages/shared/src/index.ts',
|
||||
),
|
||||
'@evofw/ui/components': path.resolve(
|
||||
__dirname,
|
||||
'../../packages/ui/src/components',
|
||||
),
|
||||
'@evofw/ui/hooks': path.resolve(
|
||||
__dirname,
|
||||
'../../packages/ui/src/hooks',
|
||||
),
|
||||
'@evofw/ui/lib': path.resolve(__dirname, '../../packages/ui/src/lib'),
|
||||
},
|
||||
},
|
||||
server: {
|
||||
port: 5177,
|
||||
fs: { allow: [path.resolve(__dirname, '../..')] },
|
||||
proxy: {
|
||||
'/api': 'http://localhost:8080',
|
||||
'/health': 'http://localhost:8080',
|
||||
'/ready': 'http://localhost:8080',
|
||||
'/v1': 'http://localhost:8080',
|
||||
},
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user