feat: реализовать EvoFirewall V1 control plane
Build and Push EvoFirewall Docker Image / build-and-push (push) Failing after 25s
Build and Push EvoFirewall Docker Image / create-release (push) Skipped

API, UI, Linux/MikroTik agents, IP lists, политики, stats, CI и интеграция с auth-portal/EvoBGP.

Co-authored-by: Cursor <[email protected]>
This commit is contained in:
Denozordec
2026-07-20 19:50:54 +07:00
co-authored by Cursor
parent d71b45d86f
commit ebadf70e2b
107 changed files with 15196 additions and 99 deletions
+28 -5
View File
@@ -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
+125
View File
@@ -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")
+96
View File
@@ -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
}
+55
View File
@@ -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',
}
}
+217
View File
@@ -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 }
+8
View File
@@ -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' })
+38
View File
@@ -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' })
+38
View File
@@ -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' })
+136
View File
@@ -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 }
})
}
+413
View File
@@ -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 }
})
}
+14
View File
@@ -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' })
}
})
}
+40
View File
@@ -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)
}
+174
View File
@@ -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)
}
}
+85
View File
@@ -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,
}
}
+14
View File
@@ -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"]
}