Compare commits

..
7 Commits
Author SHA1 Message Date
Denozordec 5edbd656ba feat(firewall): add packet statistics tracking for firewall clients
CI / changes (push) Successful in 11s
CI / commitlint (push) Has been skipped
CI / openapi (push) Successful in 29s
CI / web (push) Successful in 1m6s
CI / go (push) Successful in 1m23s
CI / bird2 (push) Successful in 17s
CI / release (push) Successful in 4m42s
Enhanced the firewall client functionality by introducing packet statistics tracking, including the cumulative count of packets dropped and accepted. Updated the API to support these new fields and modified the database schema accordingly. Improved the firewall scripts to collect and report packet statistics, ensuring better visibility into client performance. Adjusted the UI components to display packet counts in the clients table, enhancing user experience and monitoring capabilities.
2026-07-09 00:50:38 +07:00
Denozordec 4a4c11c6bf feat(firewall): rename revoke function to delete and update related UI components
Refactored the revoke functionality for firewall clients to be more accurately represented as a delete operation. Updated the corresponding API call to use the DELETE method and modified the UI components to reflect this change, including confirmation dialogs and success messages. Adjusted tests to ensure the new delete functionality works as intended.
2026-07-09 00:27:50 +07:00
Denozordec e51999c908 feat(firewall): add revoke functionality for firewall clients and enhance status badge
CI / changes (push) Successful in 13s
CI / commitlint (push) Has been skipped
CI / openapi (push) Successful in 30s
CI / web (push) Successful in 56s
CI / go (push) Successful in 1m11s
CI / bird2 (push) Successful in 27s
CI / release (push) Successful in 4m19s
Implemented the ability to revoke approved firewall clients and reject pending requests through new API endpoints. Updated the StatusBadge component to include additional status variants for 'approved', 'revoked', 'pending', and 'block'. Enhanced the FirewallPage UI to support client revocation and rejection actions, integrating confirmation dialogs for user interactions. Updated tests to ensure proper functionality of the new revoke feature.
2026-07-08 23:27:29 +07:00
Denozordec b7f7669685 feat(firewall): improve blocklist parsing and nft element addition
CI / changes (push) Successful in 9s
CI / commitlint (push) Has been skipped
CI / openapi (push) Successful in 25s
CI / web (push) Successful in 42s
CI / go (push) Successful in 1m1s
CI / bird2 (push) Successful in 15s
CI / release (push) Successful in 4m17s
Enhanced the blocklist parsing function to log when the blocklist file is empty. Introduced new helper functions `nft_join_elements` and `nft_add_v4_chunk` to streamline the addition of elements to the nftables, allowing for batch processing and improved error handling. Adjusted the chunk size for element addition to optimize performance. Updated logging to provide better visibility into the blocklist processing and applied prefixes.
2026-07-08 22:02:59 +07:00
Denozordec 947d1f0cc4 feat(firewall): enhance blocklist handling and installation script
CI / changes (push) Successful in 11s
CI / commitlint (push) Has been skipped
CI / openapi (push) Successful in 29s
CI / web (push) Successful in 58s
CI / go (push) Successful in 1m20s
CI / bird2 (push) Successful in 18s
CI / release (push) Successful in 4m37s
Updated the firewall scripts to improve blocklist handling by introducing a new method for fetching and parsing blocklist data using either `jq` or `python3`. Enhanced the installation script to ensure the presence of required dependencies and provided user guidance for post-approval actions. Additionally, improved logging for applied prefixes and total counts, ensuring better visibility into the firewall's operational status.
2026-07-08 21:45:32 +07:00
Denozordec 68f9d4b832 refactor(firewall): simplify SQL queries for firewall client retrieval
CI / changes (push) Successful in 13s
CI / commitlint (push) Has been skipped
CI / openapi (push) Has been skipped
CI / web (push) Has been skipped
CI / go (push) Successful in 1m6s
CI / bird2 (push) Successful in 18s
CI / release (push) Successful in 3m57s
Refactored the SQL queries in the Postgres repository for listing and retrieving firewall clients by introducing a constant for the selected columns. This change improves code readability and maintainability by reducing duplication in the query definitions. No functional changes were made to the data retrieval process.
2026-07-08 21:16:57 +07:00
Denozordec 72045afcde feat(firewall): improve error handling and documentation for firewall enrollment
CI / changes (push) Successful in 11s
CI / commitlint (push) Has been skipped
CI / openapi (push) Successful in 25s
CI / web (push) Successful in 43s
CI / go (push) Successful in 1m2s
CI / bird2 (push) Successful in 16s
CI / release (push) Successful in 3m41s
Enhanced the firewall enrollment process by implementing better error handling for HTTP responses, specifically addressing database schema issues. Updated the documentation to include migration requirements for PostgreSQL and clarified the steps to take if enrollment fails due to an outdated schema. This ensures users are better informed about necessary actions during deployment.
2026-07-08 21:00:27 +07:00
22 changed files with 981 additions and 175 deletions
+5
View File
@@ -22,6 +22,11 @@ const STATUS_VARIANT: Record<string, BadgeVariant> = {
stale: 'warning',
warning: 'warning',
mismatch: 'warning',
pending: 'warning',
approved: 'success',
revoked: 'destructive',
block: 'destructive',
accept: 'success',
}
export function StatusBadge({ status, label }: { status: string; label?: string }) {
+17
View File
@@ -1,4 +1,6 @@
import { queryOptions, useMutation, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner'
import { apiJSON } from '@/lib/api-client'
import type {
FirewallClient,
@@ -51,8 +53,23 @@ export function useApproveFirewallClient() {
mutationFn: (id: string) =>
apiJSON<FirewallClient>(`/v1/firewall/clients/${id}/approve`, { method: 'POST' }),
onSuccess: () => {
toast.success('Клиент одобрен')
void qc.invalidateQueries({ queryKey: firewallKeys.clients() })
},
onError: (e) => toast.error(e instanceof Error ? e.message : 'Не удалось одобрить'),
})
}
export function useDeleteFirewallClient() {
const qc = useQueryClient()
return useMutation({
mutationFn: (id: string) =>
apiJSON<void>(`/v1/firewall/clients/${id}`, { method: 'DELETE' }),
onSuccess: () => {
toast.success('Клиент удалён')
void qc.invalidateQueries({ queryKey: firewallKeys.clients() })
},
onError: (e) => toast.error(e instanceof Error ? e.message : 'Не удалось удалить'),
})
}
+102 -9
View File
@@ -19,6 +19,7 @@ import {
TableRow,
} from '@evobgp/ui/components/table'
import { ConfirmDialog } from '@/components/confirm-dialog'
import { PageHeader } from '@/components/page-header'
import { CommunitySelect } from '@/components/modules/community-select'
import { StatusBadge } from '@/components/status-badge'
@@ -30,6 +31,7 @@ import {
firewallRulesQueryOptions,
useApproveFirewallClient,
useCreateFirewallRule,
useDeleteFirewallClient,
useDeleteFirewallRule,
} from '@/queries/firewall'
import type { BgpCommunity, FirewallClient } from '@/types/api'
@@ -54,6 +56,7 @@ function FirewallPage() {
const clientsQ = useQuery(firewallClientsQueryOptions())
const rulesQ = useQuery(firewallRulesQueryOptions('tenant'))
const approve = useApproveFirewallClient()
const deleteClient = useDeleteFirewallClient()
const createRule = useCreateFirewallRule()
const deleteRule = useDeleteFirewallRule()
@@ -79,8 +82,13 @@ function FirewallPage() {
const communities = communitiesQ.data?.items ?? []
const clients = clientsQ.data?.items ?? []
const pending = clients.filter((c) => c.status === 'pending')
const { activeClients, pending } = useMemo(() => {
const all = clientsQ.data?.items ?? []
return {
activeClients: all.filter((c) => c.status !== 'revoked'),
pending: all.filter((c) => c.status === 'pending'),
}
}, [clientsQ.data?.items])
const rules = rulesQ.data?.items ?? []
const installCmd = useMemo(() => {
@@ -187,13 +195,19 @@ function FirewallPage() {
<Tabs defaultValue="clients">
<TabsList>
<TabsTrigger value="clients">Клиенты ({clients.length})</TabsTrigger>
<TabsTrigger value="clients">Клиенты ({activeClients.length})</TabsTrigger>
<TabsTrigger value="rules">Правила ({rules.length})</TabsTrigger>
<TabsTrigger value="requests">Запросы ({pending.length})</TabsTrigger>
</TabsList>
<TabsContent value="clients" className="mt-4">
<ClientsTable clients={clients} onApprove={(id) => approve.mutate(id)} />
<ClientsTable
clients={activeClients}
onApprove={(id) => approve.mutate(id)}
onReject={(id) => deleteClient.mutate(id)}
approvePending={approve.isPending}
rejectPending={deleteClient.isPending}
/>
</TabsContent>
<TabsContent value="rules" className="mt-4 space-y-4">
@@ -254,6 +268,9 @@ function FirewallPage() {
<ClientsTable
clients={pending}
onApprove={(id) => approve.mutate(id)}
onReject={(id) => deleteClient.mutate(id)}
approvePending={approve.isPending}
rejectPending={deleteClient.isPending}
emptyTitle="Нет pending-запросов"
/>
</TabsContent>
@@ -262,13 +279,26 @@ function FirewallPage() {
)
}
function formatPacketCount(value?: number | null): string | null {
if (value == null || value <= 0) return null
if (value >= 1_000_000) return `${(value / 1_000_000).toFixed(1)}M`
if (value >= 1_000) return `${(value / 1_000).toFixed(1)}k`
return String(value)
}
function ClientsTable({
clients,
onApprove,
onReject,
approvePending = false,
rejectPending = false,
emptyTitle = 'Нет клиентов',
}: {
clients: FirewallClient[]
onApprove: (id: string) => void
onReject: (id: string) => void
approvePending?: boolean
rejectPending?: boolean
emptyTitle?: string
}) {
if (clients.length === 0) {
@@ -282,6 +312,7 @@ function ClientsTable({
<TableHead>Статус</TableHead>
<TableHead>Last seen</TableHead>
<TableHead>Apply</TableHead>
<TableHead>Пакеты</TableHead>
<TableHead />
</TableRow>
</TableHeader>
@@ -300,12 +331,74 @@ function ClientsTable({
{c.last_apply_status ?? '—'}
{c.last_apply_prefix_count != null ? ` (${c.last_apply_prefix_count})` : ''}
</TableCell>
<TableCell className="text-muted-foreground text-xs">
{formatPacketCount(c.last_apply_packets_dropped) || formatPacketCount(c.last_apply_packets_accepted) ? (
<>
{formatPacketCount(c.last_apply_packets_dropped) ? (
<span className="text-destructive">{formatPacketCount(c.last_apply_packets_dropped)}</span>
) : null}
{formatPacketCount(c.last_apply_packets_dropped) && formatPacketCount(c.last_apply_packets_accepted)
? ' · '
: null}
{formatPacketCount(c.last_apply_packets_accepted) ? (
<span className="text-success">{formatPacketCount(c.last_apply_packets_accepted)}</span>
) : null}
</>
) : (
'—'
)}
</TableCell>
<TableCell>
{c.status === 'pending' ? (
<Button size="sm" variant="outline" onClick={() => onApprove(c.id)}>
Approve
</Button>
) : null}
<div className="flex justify-end gap-2">
{c.status === 'pending' ? (
<>
<Button
size="sm"
variant="outline"
disabled={approvePending}
onClick={() => onApprove(c.id)}
>
Одобрить
</Button>
<ConfirmDialog
trigger={
<Button
size="sm"
variant="outline"
className="text-destructive"
disabled={rejectPending}
>
Отклонить
</Button>
}
title="Отклонить запрос?"
description={`${c.name}${c.hostname ? ` (${c.hostname})` : ''} — запись будет удалена, токен перестанет работать.`}
confirmLabel="Отклонить"
destructive
onConfirm={() => onReject(c.id)}
/>
</>
) : null}
{c.status === 'approved' ? (
<ConfirmDialog
trigger={
<Button
size="sm"
variant="ghost"
className="text-destructive"
disabled={rejectPending}
>
Удалить
</Button>
}
title="Удалить клиент?"
description={`${c.name} — запись будет удалена, blocklist и токен перестанут работать.`}
confirmLabel="Удалить"
destructive
onConfirm={() => onReject(c.id)}
/>
) : null}
</div>
</TableCell>
</TableRow>
))}
+2
View File
@@ -356,6 +356,8 @@ export type FirewallClient = {
last_apply_at?: string | null
last_apply_status?: string
last_apply_prefix_count?: number
last_apply_packets_dropped?: number
last_apply_packets_accepted?: number
last_apply_source?: string
client_version?: string
created_at: string
+12
View File
@@ -23,6 +23,8 @@
Публичные URL (без API-ключа, вне `WEBUI_IP_WHITELIST` Traefik): `GET /v1/firewall/install.sh`, `GET /v1/firewall/sync-script`, `POST /v1/firewall/enroll`. Всегда **HTTPS**.
Требуется миграция **`000027_firewall`** в PostgreSQL (применяется при старте API с актуальным бинарём). Если enroll отвечает `503` / `database schema outdated` — перезапустите `evobgp-api` / `evobgp-all` после деплоя новой версии.
```bash
curl -fsSL https://<api>/v1/firewall/install.sh | \
EVOBGP_CP_URL=https://<api> \
@@ -33,6 +35,16 @@ curl -fsSL https://<api>/v1/firewall/install.sh | \
Файлы: `/etc/evobgp/firewall.conf`, `/usr/local/sbin/evobgp-firewall.sh`, systemd timer `evobgp-firewall.timer`.
После **approve** в UI выполните на сервере (или дождитесь timer):
```bash
sudo rm -f /var/lib/evobgp-firewall/last_hash
sudo /usr/local/sbin/evobgp-firewall.sh
sudo nft list table inet evobgp_blocklist
```
Для парсинга JSON нужен `jq` или `python3` (install.sh ставит `jq` на Debian/Ubuntu при отсутствии).
## Failover через speaker
При `EVOBGP_FIREWALL_FAILOVER_ENABLED=1` на speaker-agent CP реплицирует состояние через `POST /v1/agent/firewall-replicate`. Клиенты используют тот же DNS-домен.
+58
View File
@@ -1605,6 +1605,14 @@ components:
type: string
last_apply_prefix_count:
type: integer
last_apply_packets_dropped:
type: integer
format: int64
description: Cumulative packets dropped by blocklist rule (from client kernel counter).
last_apply_packets_accepted:
type: integer
format: int64
description: Cumulative packets accepted past blocklist chain (nft counter accept rule).
client_version:
type: string
@@ -4472,6 +4480,31 @@ paths:
default:
$ref: "#/components/responses/DefaultProblem"
/v1/firewall/clients/{id}/revoke:
post:
tags: [Firewall]
summary: Reject pending or revoke approved client
operationId: revokeFirewallClient
parameters:
- name: id
in: path
required: true
schema:
$ref: "#/components/schemas/ResourceId"
responses:
"200":
description: Revoked
content:
application/json:
schema:
type: object
properties:
status:
type: string
enum: [revoked]
default:
$ref: "#/components/responses/DefaultProblem"
/v1/firewall/rules:
get:
tags: [Firewall]
@@ -4522,6 +4555,31 @@ paths:
tags: [Firewall]
summary: Report last apply status
operationId: firewallApplyReport
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
status:
type: string
error:
type: string
prefix_count:
type: integer
ip_count:
type: integer
packets_dropped:
type: integer
format: int64
packets_accepted:
type: integer
format: int64
kernel_method:
type: string
source:
type: string
responses:
"200":
description: OK
+251 -55
View File
@@ -5,6 +5,7 @@ CONF_FILE=/etc/evobgp/firewall.conf
LOG_FILE=/var/log/evobgp-firewall.log
STATE_DIR=/var/lib/evobgp-firewall
HASH_FILE="${STATE_DIR}/last_hash"
PREFIX_FILE="${STATE_DIR}/last_prefixes.txt"
log() { echo "$(date -u +%Y-%m-%dT%H:%M:%SZ) $*" | tee -a "$LOG_FILE"; }
@@ -17,36 +18,32 @@ source "$CONF_FILE"
: "${EVOBGP_CP_URL:?}"
: "${CLIENT_TOKEN:?}"
CLIENT_TOKEN="${CLIENT_TOKEN//$'\r'/}"
CLIENT_TOKEN="${CLIENT_TOKEN//$'\n'/}"
mkdir -p "$STATE_DIR"
BACKEND="${KERNEL_BACKEND:-auto}"
curl_get_blocklist() {
curl_get_blocklist_file() {
local url="$1"
local host
host=$(echo "$url" | sed -E 's#https?://([^/]+)/?.*#\1#')
local tmp
tmp=$(mktemp)
local dest="$2"
local code
code=$(curl -sS -o "$tmp" -w "%{http_code}" \
code=$(curl -sS -o "$dest" -w "%{http_code}" \
-H "Authorization: Bearer ${CLIENT_TOKEN}" \
-H "Accept: application/json" \
"${url}/v1/firewall/blocklist") || return 1
if [[ "$code" == "403" ]]; then
log "pending approval"
rm -f "$tmp"
exit 0
return 2
fi
if [[ "$code" != "200" ]]; then
log "blocklist HTTP $code from $url"
rm -f "$tmp"
return 1
fi
cat "$tmp"
rm -f "$tmp"
return 0
}
try_urls() {
try_fetch_blocklist() {
local urls=()
if [[ -n "${EVOBGP_FAILOVER_URLS:-}" ]]; then
IFS=',' read -r -a urls <<<"$EVOBGP_FAILOVER_URLS"
@@ -57,7 +54,12 @@ try_urls() {
for u in "${urls[@]}"; do
u="${u// /}"
u="${u%/}"
if OUT=$(curl_get_blocklist "$u"); then
local rc=0
curl_get_blocklist_file "$u" "$PREFIX_FILE" || rc=$?
if [[ "$rc" == 2 ]]; then
exit 0
fi
if [[ "$rc" == 0 ]]; then
CP_HIT="$u"
return 0
fi
@@ -65,71 +67,270 @@ try_urls() {
return 1
}
if ! OUT=$(try_urls); then
parse_blocklist_file() {
local f="$1"
if [[ ! -s "$f" ]]; then
log "blocklist file empty: $f"
return 1
fi
if command -v jq >/dev/null 2>&1; then
HASH=$(jq -r '.hash // empty' "$f")
TOTAL=$(jq -r '.total // 0' "$f")
mapfile -t PREFIXES < <(jq -r '.prefixes[]? // empty' "$f")
return 0
fi
if command -v python3 >/dev/null 2>&1; then
local parsed
parsed=$(python3 - "$f" <<'PY'
import json, sys
with open(sys.argv[1], encoding="utf-8") as fh:
data = json.load(fh)
print(data.get("hash") or "")
print(data.get("total") or 0)
for p in data.get("prefixes") or []:
if p:
print(p)
PY
)
HASH=$(echo "$parsed" | sed -n '1p')
TOTAL=$(echo "$parsed" | sed -n '2p')
mapfile -t PREFIXES < <(echo "$parsed" | sed -n '3,$p')
return 0
fi
HASH=$(grep -o '"hash"[[:space:]]*:[[:space:]]*"[^"]*"' "$f" | head -1 | sed 's/.*"\(sha256:[^"]*\)".*/\1/')
TOTAL=$(grep -o '"total"[[:space:]]*:[[:space:]]*[0-9]*' "$f" | head -1 | grep -o '[0-9]*$' || true)
mapfile -t PREFIXES < <(grep -oE '"[0-9]+(\.[0-9]+){3}/[0-9]+"' "$f" | tr -d '"' || true)
return 0
}
nft_join_elements() {
local out="" p
for p in "$@"; do
if [[ -n "$out" ]]; then
out+=", "
fi
out+="$p"
done
printf '%s' "$out"
}
nft_add_v4_chunk() {
local table=$1 name=$2
shift 2
local joined
joined=$(nft_join_elements "$@")
if nft add element "$table" "$name" v4 "{ ${joined} }" 2>>"$LOG_FILE"; then
return 0
fi
log "nft batch add failed (chunk=$#), retrying one-by-one"
local p ok=0
for p in "$@"; do
if nft add element "$table" "$name" v4 "{ $p }" 2>>"$LOG_FILE"; then
ok=$((ok + 1))
fi
done
[[ "$ok" -gt 0 ]]
}
if ! try_fetch_blocklist; then
log "all endpoints failed"
exit 1
fi
if command -v jq >/dev/null 2>&1; then
HASH=$(echo "$OUT" | jq -r '.hash // empty')
TOTAL=$(echo "$OUT" | jq -r '.total // 0')
mapfile -t PREFIXES < <(echo "$OUT" | jq -r '.prefixes[]?')
else
HASH=$(echo "$OUT" | grep -o '"hash"[[:space:]]*:[[:space:]]*"[^"]*"' | head -1 | sed 's/.*"\(sha256:[^"]*\)".*/\1/')
TOTAL=$(echo "$OUT" | grep -o '"total"[[:space:]]*:[[:space:]]*[0-9]*' | head -1 | grep -o '[0-9]*$')
mapfile -t PREFIXES < <(echo "$OUT" | grep -o '"[0-9a-fA-F:.]*/[0-9]*"' | tr -d '"')
HASH=""
TOTAL=0
PREFIXES=()
parse_blocklist_file "$PREFIX_FILE"
log "blocklist bytes=$(wc -c <"$PREFIX_FILE" | tr -d ' ') parsed=${#PREFIXES[@]} api_total=${TOTAL:-0}"
if [[ -z "${TOTAL// }" ]]; then
TOTAL=${#PREFIXES[@]}
fi
if [[ -f "$HASH_FILE" && "$(cat "$HASH_FILE")" == "$HASH" ]]; then
log "unchanged hash $HASH — skip kernel apply"
PACKETS_DROPPED=0
PACKETS_ACCEPTED=0
KERNEL_METHOD=""
APPLIED_V4=0
count_ipv4_prefixes() {
local n=0 p
for p in "${PREFIXES[@]}"; do
[[ "$p" == *:* ]] && continue
n=$((n + 1))
done
APPLIED_V4=$n
}
nft_rule_packets() {
local line=$1
if [[ "$line" =~ counter[[:space:]]+packets[[:space:]]+([0-9]+) ]]; then
echo "${BASH_REMATCH[1]}"
else
echo 0
fi
}
ensure_nft_counters() {
local table=inet name=evobgp_blocklist
nft list chain "$table" "$name" input >/dev/null 2>&1 || return 0
local drop_line
drop_line=$(nft -a list chain "$table" "$name" input 2>/dev/null | grep 'ip saddr @v4' | grep drop | head -1 || true)
if [[ -n "$drop_line" && "$drop_line" != *counter* ]]; then
local handle
handle=$(echo "$drop_line" | sed -n 's/.*# handle \([0-9]\+\).*/\1/p')
if [[ -n "$handle" ]]; then
nft delete rule "$table" "$name" input handle "$handle" 2>>"$LOG_FILE" || true
drop_line=""
fi
fi
if [[ -z "$drop_line" ]]; then
nft add rule "$table" "$name" input ip saddr @v4 counter drop
fi
if ! nft list chain "$table" "$name" input 2>/dev/null | grep -qE '[[:space:]]counter[[:space:]]+accept'; then
nft add rule "$table" "$name" input counter accept
fi
}
collect_nft_packet_stats() {
PACKETS_DROPPED=0
PACKETS_ACCEPTED=0
local line pkts
while IFS= read -r line; do
if [[ "$line" == *"ip saddr @v4"* && "$line" == *drop* ]]; then
pkts=$(nft_rule_packets "$line")
[[ -n "$pkts" ]] && PACKETS_DROPPED=$pkts
elif [[ "$line" == *counter* && "$line" == *accept* && "$line" != *@v4* ]]; then
pkts=$(nft_rule_packets "$line")
[[ -n "$pkts" ]] && PACKETS_ACCEPTED=$pkts
fi
done < <(nft list chain inet evobgp_blocklist input 2>/dev/null || true)
}
collect_ipset_packet_stats() {
PACKETS_DROPPED=0
PACKETS_ACCEPTED=0
local pkts
pkts=$(iptables -L INPUT -v -n -x 2>/dev/null | awk '/match-set evobgp_blocklist_v4/ {print $1; exit}')
[[ "$pkts" =~ ^[0-9]+$ ]] && PACKETS_DROPPED=$pkts
}
collect_packet_stats() {
case "${KERNEL_METHOD:-$BACKEND}" in
nft)
ensure_nft_counters
collect_nft_packet_stats
;;
ipset)
collect_ipset_packet_stats
;;
iptables)
PACKETS_DROPPED=$(iptables -L INPUT -v -n -x 2>/dev/null | awk '/DROP/ {s+=$1} END {print s+0}')
PACKETS_ACCEPTED=0
;;
*)
if command -v nft >/dev/null 2>&1 && nft list chain inet evobgp_blocklist input >/dev/null 2>&1; then
KERNEL_METHOD=nft
ensure_nft_counters
collect_nft_packet_stats
elif iptables -L INPUT -v -n -x 2>/dev/null | grep -q 'evobgp_blocklist_v4'; then
KERNEL_METHOD=ipset
collect_ipset_packet_stats
fi
;;
esac
}
send_client_reports() {
collect_packet_stats
local km="${KERNEL_METHOD:-$BACKEND}"
local report
report=$(printf '{"status":"ok","prefix_count":%s,"ip_count":%s,"packets_dropped":%s,"packets_accepted":%s,"source":"cp","kernel_method":"%s"}' \
"${TOTAL:-0}" "${APPLIED_V4:-0}" "${PACKETS_DROPPED:-0}" "${PACKETS_ACCEPTED:-0}" "$km")
curl -fsS -X POST "${EVOBGP_CP_URL%/}/v1/firewall/apply-report" \
-H "Authorization: Bearer ${CLIENT_TOKEN}" \
-H "Content-Type: application/json" \
-d "$report" >/dev/null 2>&1 || true
curl -fsS -X POST "${EVOBGP_CP_URL%/}/v1/firewall/heartbeat" \
-H "Authorization: Bearer ${CLIENT_TOKEN}" \
-H "Content-Type: application/json" \
-d '{"source":"cp"}' >/dev/null 2>&1 || true
}
if [[ -f "$HASH_FILE" && "$(tr -d '\r\n' <"$HASH_FILE")" == "$HASH" && -n "$HASH" ]]; then
count_ipv4_prefixes
log "unchanged hash $HASH — skip kernel apply (ipv4=${APPLIED_V4})"
send_client_reports
exit 0
fi
apply_nft() {
local table=inet
local name=evobgp_blocklist
local v4=()
local p
for p in "${PREFIXES[@]}"; do
[[ "$p" == *:* ]] && continue
v4+=("$p")
done
nft list table "$table" "$name" >/dev/null 2>&1 || nft add table "$table" "$name"
nft list set "$table" "$name" v4 >/dev/null 2>&1 || nft add set "$table" "$name" v4 '{ type ipv4_addr; flags interval; }'
nft list set "$table" "$name" v4 >/dev/null 2>&1 || \
nft add set "$table" "$name" v4 '{ type ipv4_addr; flags interval; }'
nft flush set "$table" "$name" v4
if ((${#PREFIXES[@]})); then
local v4=()
local p
for p in "${PREFIXES[@]}"; do
[[ "$p" == *:* ]] && continue
v4+=("$p")
if ((${#v4[@]})); then
local batch=()
local chunk=64
for p in "${v4[@]}"; do
batch+=("$p")
if ((${#batch[@]} >= chunk)); then
nft_add_v4_chunk "$table" "$name" "${batch[@]}" || log "nft chunk add partial failure"
batch=()
fi
done
if ((${#v4[@]})); then
nft add element "$table" "$name" v4 "{ $(IFS=,; echo "${v4[*]}") }"
if ((${#batch[@]})); then
nft_add_v4_chunk "$table" "$name" "${batch[@]}" || log "nft tail chunk add partial failure"
fi
fi
nft list chain "$table" "$name" input >/dev/null 2>&1 || {
nft add chain "$table" "$name" input '{ type filter hook input priority 0; }'
nft add rule "$table" "$name" input ip saddr @v4 drop
nft add chain "$table" "$name" input '{ type filter hook input priority 0; policy accept; }'
nft add rule "$table" "$name" input ip saddr @v4 counter drop
nft add rule "$table" "$name" input counter accept
}
ensure_nft_counters
KERNEL_METHOD=nft
APPLIED_V4=${#v4[@]}
}
apply_ipset() {
local set=evobgp_blocklist_v4
local n=0
ipset list "$set" >/dev/null 2>&1 || ipset create "$set" hash:net family inet hashsize 4096 maxelem 1048576
ipset flush "$set"
local p
for p in "${PREFIXES[@]}"; do
[[ "$p" == *:* ]] && continue
ipset add "$set" "$p" -exist
n=$((n + 1))
done
iptables -C INPUT -m set --match-set "$set" src -j DROP 2>/dev/null || \
iptables -I INPUT -m set --match-set "$set" src -j DROP
KERNEL_METHOD=ipset
APPLIED_V4=$n
}
apply_iptables_only() {
iptables -D INPUT -m comment --comment evobgp-block -j DROP 2>/dev/null || true
if ((${#PREFIXES[@]})); then
local p
for p in "${PREFIXES[@]}"; do
[[ "$p" == *:* ]] && continue
iptables -C INPUT -s "$p" -j DROP 2>/dev/null || iptables -A INPUT -s "$p" -j DROP
done
fi
local n=0
local p
for p in "${PREFIXES[@]}"; do
[[ "$p" == *:* ]] && continue
iptables -C INPUT -s "$p" -j DROP 2>/dev/null || iptables -A INPUT -s "$p" -j DROP
n=$((n + 1))
done
KERNEL_METHOD=iptables
APPLIED_V4=$n
}
clear_block() {
@@ -141,10 +342,15 @@ clear_block() {
;;
iptables) iptables -S INPUT | grep -i evobgp | sed 's/^-A /-D /' | while read -r line; do iptables $line 2>/dev/null || true; done ;;
esac
APPLIED_V4=0
KERNEL_METHOD="${BACKEND:-auto}"
}
if [[ "$TOTAL" == "0" || ${#PREFIXES[@]} -eq 0 ]]; then
APPLIED_V4=0
KERNEL_METHOD=""
if [[ "${TOTAL:-0}" == "0" || ${#PREFIXES[@]} -eq 0 ]]; then
clear_block
log "cleared blocklist (api total=${TOTAL:-0}) backend=$BACKEND"
else
case "$BACKEND" in
nft|auto) if command -v nft >/dev/null 2>&1; then apply_nft; else apply_ipset; fi ;;
@@ -152,18 +358,8 @@ else
iptables) apply_iptables_only ;;
*) apply_ipset ;;
esac
log "applied api_total=${TOTAL} ipv4_in_kernel=${APPLIED_V4} from ${CP_HIT:-$EVOBGP_CP_URL} backend=$BACKEND hash=${HASH:-empty}"
fi
echo "$HASH" >"$HASH_FILE"
log "applied $TOTAL prefixes from ${CP_HIT:-$EVOBGP_CP_URL} backend=$BACKEND"
REPORT=$(printf '{"status":"ok","prefix_count":%s,"ip_count":0,"source":"cp"}' "${TOTAL:-0}")
curl -fsS -X POST "${EVOBGP_CP_URL%/}/v1/firewall/apply-report" \
-H "Authorization: Bearer ${CLIENT_TOKEN}" \
-H "Content-Type: application/json" \
-d "$REPORT" >/dev/null 2>&1 || true
curl -fsS -X POST "${EVOBGP_CP_URL%/}/v1/firewall/heartbeat" \
-H "Authorization: Bearer ${CLIENT_TOKEN}" \
-H "Content-Type: application/json" \
-d '{"source":"cp"}' >/dev/null 2>&1 || true
send_client_reports
+20 -1
View File
@@ -10,6 +10,16 @@ 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
fi
fi
if ! command -v jq >/dev/null 2>&1 && ! command -v python3 >/dev/null 2>&1; then
echo "evobgp-firewall install: install jq or python3 for blocklist JSON parsing" >&2
exit 1
fi
: "${EVOBGP_CP_URL:?EVOBGP_CP_URL required}"
: "${EVOBGP_SEED:?EVOBGP_SEED required}"
: "${EVOBGP_CLIENT_NAME:?EVOBGP_CLIENT_NAME required}"
@@ -38,10 +48,18 @@ CP_URL="${EVOBGP_CP_URL%/}"
ENROLL_BODY=$(printf '{"name":"%s","hostname":"%s","client_token":"%s","client_version":"install.sh/1"}' \
"$EVOBGP_CLIENT_NAME" "$HOSTNAME" "$CLIENT_TOKEN")
RESP=$(curl -fsS -X POST "${CP_URL}/v1/firewall/enroll" \
ENROLL_TMP=$(mktemp)
trap 'rm -f "$ENROLL_TMP"' EXIT
ENROLL_CODE=$(curl -sS -o "$ENROLL_TMP" -w "%{http_code}" -X POST "${CP_URL}/v1/firewall/enroll" \
-H "Content-Type: application/json" \
-H "X-EvoBGP-Seed: ${EVOBGP_SEED}" \
-d "$ENROLL_BODY")
if [[ "$ENROLL_CODE" != "201" ]]; then
echo "evobgp-firewall enroll failed: HTTP ${ENROLL_CODE} from ${CP_URL}/v1/firewall/enroll" >&2
cat "$ENROLL_TMP" >&2
exit 1
fi
RESP=$(cat "$ENROLL_TMP")
CLIENT_ID=""
if command -v jq >/dev/null 2>&1; then
@@ -102,6 +120,7 @@ WantedBy=timers.target
UNIT
systemctl daemon-reload
systemctl enable --now evobgp-firewall.timer
echo "Tip: after UI approve, run: rm -f /var/lib/evobgp-firewall/last_hash && ${SYNC_SCRIPT}"
else
echo "*/5 * * * * root ${SYNC_SCRIPT}" >/etc/cron.d/evobgp-firewall
fi
+22
View File
@@ -16,6 +16,8 @@ import (
"evobgp/internal/pipeline"
"evobgp/internal/runtimelogs"
"evobgp/internal/store"
"github.com/jackc/pgx/v5/pgconn"
)
func (s *Server) registerCRUDRoutes(m *http.ServeMux) {
@@ -188,9 +190,29 @@ func writeStoreErr(w http.ResponseWriter, err error) {
writeProblem(w, http.StatusUnprocessableEntity, "Unprocessable Entity", invalidInputDetail)
return
}
if writePostgresStoreErr(w, err) {
return
}
writeInternalError(w, "store", err)
}
func writePostgresStoreErr(w http.ResponseWriter, err error) bool {
var pgErr *pgconn.PgError
if !errors.As(err, &pgErr) {
return false
}
switch pgErr.Code {
case "42P01":
writeProblem(w, http.StatusServiceUnavailable, "Service Unavailable",
"database schema outdated; restart API after deploy or apply migration 000027_firewall")
return true
case "23505":
writeProblem(w, http.StatusConflict, "Conflict", "resource already exists")
return true
}
return false
}
func (s *Server) handleListCDNSources(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "viewer") {
+14 -9
View File
@@ -123,7 +123,7 @@ func (s *Server) handleFirewallEnrollPublic(w http.ResponseWriter, r *http.Reque
writeProblem(w, http.StatusConflict, "Conflict", "client token already enrolled")
return
}
writeInternalError(w, "internal", err)
writeStoreErr(w, err)
return
}
writeJSON(w, http.StatusCreated, map[string]any{
@@ -450,13 +450,15 @@ func (s *Server) handleFirewallApplyReport(w http.ResponseWriter, r *http.Reques
return
}
var body struct {
Status string `json:"status"`
Error string `json:"error"`
PrefixCount int `json:"prefix_count"`
IPCount int `json:"ip_count"`
Version string `json:"version"`
KernelMethod string `json:"kernel_method"`
Source string `json:"source"`
Status string `json:"status"`
Error string `json:"error"`
PrefixCount int `json:"prefix_count"`
IPCount int `json:"ip_count"`
PacketsDropped int64 `json:"packets_dropped"`
PacketsAccepted int64 `json:"packets_accepted"`
Version string `json:"version"`
KernelMethod string `json:"kernel_method"`
Source string `json:"source"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeProblem(w, http.StatusBadRequest, "Bad Request", "invalid JSON body")
@@ -466,7 +468,10 @@ func (s *Server) handleFirewallApplyReport(w http.ResponseWriter, r *http.Reques
if src == "" {
src = "cp"
}
_ = s.store.TouchFirewallClientLastApply(a.APIKeyID, src, body.Status, body.Error, body.PrefixCount, body.IPCount)
_ = s.store.TouchFirewallClientLastApply(
a.APIKeyID, src, body.Status, body.Error,
body.PrefixCount, body.IPCount, body.PacketsDropped, body.PacketsAccepted,
)
writeJSON(w, http.StatusOK, map[string]bool{"ok": true})
}
+90
View File
@@ -2,6 +2,7 @@ package httpapi
import (
"encoding/json"
"errors"
"io"
"net/http"
"net/http/httptest"
@@ -94,6 +95,27 @@ func TestFirewallEnrollAndBlocklist(t *testing.T) {
if total, _ := bl["total"].(float64); total != 0 {
t.Fatalf("accept-only want empty blocklist, total=%v", total)
}
reportBody := `{"status":"ok","prefix_count":0,"ip_count":0,"packets_dropped":42,"packets_accepted":1000,"source":"cp","kernel_method":"nft"}`
reqReport, _ := http.NewRequest(http.MethodPost, ts.URL+"/v1/firewall/apply-report", strings.NewReader(reportBody))
reqReport.Header.Set("Authorization", "Bearer "+tok)
reqReport.Header.Set("Content-Type", "application/json")
respReport, err := client.Do(reqReport)
if err != nil {
t.Fatal(err)
}
defer func() { _ = respReport.Body.Close() }()
if respReport.StatusCode != http.StatusOK {
b, _ := io.ReadAll(respReport.Body)
t.Fatalf("apply-report status=%d body=%s", respReport.StatusCode, b)
}
gotClient, err := srv.Store().GetFirewallClient(tenant, clientID)
if err != nil {
t.Fatal(err)
}
if gotClient.LastApplyPacketsDropped != 42 || gotClient.LastApplyPacketsAccepted != 1000 {
t.Fatalf("packet stats dropped=%d accepted=%d", gotClient.LastApplyPacketsDropped, gotClient.LastApplyPacketsAccepted)
}
}
func TestFirewallEnrollBadSeed(t *testing.T) {
@@ -205,6 +227,74 @@ func TestFirewallInstallContext(t *testing.T) {
}
}
func TestFirewallDeletePendingClient(t *testing.T) {
srv, err := New(Options{SeedDemo: true, BundleSeedHex: testBundleSeed})
if err != nil {
t.Fatal(err)
}
defer srv.Close()
tenant, _, _, _, _ := srv.Store().DemoIDs()
mustSetTestAPIKeys(t, srv, "opkey|"+tenant+"|operator")
ts := httptest.NewServer(srv.Handler())
defer ts.Close()
client := ts.Client()
tok := "evobgp_fw_revoketest123456789012345678901"
enrollBody := `{"name":"reject-me","hostname":"test.local","client_token":"` + tok + `","client_version":"test/1"}`
reqEnroll, _ := http.NewRequest(http.MethodPost, ts.URL+"/v1/firewall/enroll", strings.NewReader(enrollBody))
reqEnroll.Header.Set("Content-Type", "application/json")
reqEnroll.Header.Set("X-EvoBGP-Seed", testBundleSeed)
respEnroll, err := client.Do(reqEnroll)
if err != nil {
t.Fatal(err)
}
defer func() { _ = respEnroll.Body.Close() }()
if respEnroll.StatusCode != http.StatusCreated {
b, _ := io.ReadAll(respEnroll.Body)
t.Fatalf("enroll status=%d body=%s", respEnroll.StatusCode, b)
}
var enroll map[string]any
if err := json.NewDecoder(respEnroll.Body).Decode(&enroll); err != nil {
t.Fatal(err)
}
clientID, _ := enroll["client_id"].(string)
if clientID == "" {
t.Fatal("missing client_id")
}
reqDelete, _ := http.NewRequest(http.MethodDelete, ts.URL+"/v1/firewall/clients/"+clientID, nil)
reqDelete.Header.Set("Authorization", "Bearer opkey")
respDelete, err := client.Do(reqDelete)
if err != nil {
t.Fatal(err)
}
defer func() { _ = respDelete.Body.Close() }()
if respDelete.StatusCode != http.StatusNoContent {
b, _ := io.ReadAll(respDelete.Body)
t.Fatalf("delete status=%d body=%s", respDelete.StatusCode, b)
}
_, err = srv.Store().GetFirewallClient(tenant, clientID)
if err == nil {
t.Fatal("client should be deleted")
}
if !errors.Is(err, store.ErrNotFound) {
t.Fatalf("delete err=%v", err)
}
reqBlock, _ := http.NewRequest(http.MethodGet, ts.URL+"/v1/firewall/blocklist", nil)
reqBlock.Header.Set("Authorization", "Bearer "+tok)
respBlock, err := client.Do(reqBlock)
if err != nil {
t.Fatal(err)
}
defer func() { _ = respBlock.Body.Close() }()
if respBlock.StatusCode != http.StatusUnauthorized {
t.Fatalf("deleted blocklist want 401 got %d", respBlock.StatusCode)
}
}
func TestFirewallTokenHashMatchesAuthkey(t *testing.T) {
tok := "evobgp_fw_sample"
h := authkey.HashToken(tok)
+29 -23
View File
@@ -13,14 +13,19 @@ import (
"github.com/jackc/pgx/v5"
)
const firewallClientSelectCols = `
id, name, COALESCE(hostname, ''), token_prefix, status,
last_seen_at, COALESCE(last_seen_at_source, ''), COALESCE(last_seen_ip, ''),
last_apply_at, COALESCE(last_apply_status, ''), COALESCE(last_apply_error, ''),
COALESCE(last_apply_prefix_count, 0), COALESCE(last_apply_ip_count, 0),
COALESCE(last_apply_packets_dropped, 0), COALESCE(last_apply_packets_accepted, 0),
COALESCE(last_apply_source, ''),
COALESCE(client_version, ''), created_at, approved_at, approved_by_api_key_id, revoked_at`
func (p *Postgres) ListFirewallClients(tenantID string) ([]*store.FirewallClient, error) {
ctx := context.Background()
rows, err := p.pool.Query(ctx, `
SELECT id, name, hostname, token_prefix, status,
last_seen_at, last_seen_at_source, last_seen_ip,
last_apply_at, last_apply_status, last_apply_error,
last_apply_prefix_count, last_apply_ip_count, last_apply_source,
client_version, created_at, approved_at, approved_by_api_key_id, revoked_at
SELECT `+firewallClientSelectCols+`
FROM firewall_client WHERE tenant_id=$1 ORDER BY created_at DESC`, tenantID)
if err != nil {
return nil, err
@@ -40,11 +45,7 @@ func (p *Postgres) ListFirewallClients(tenantID string) ([]*store.FirewallClient
func (p *Postgres) GetFirewallClient(tenantID, id string) (*store.FirewallClient, error) {
ctx := context.Background()
row := p.pool.QueryRow(ctx, `
SELECT id, name, hostname, token_prefix, status,
last_seen_at, last_seen_at_source, last_seen_ip,
last_apply_at, last_apply_status, last_apply_error,
last_apply_prefix_count, last_apply_ip_count, last_apply_source,
client_version, created_at, approved_at, approved_by_api_key_id, revoked_at
SELECT `+firewallClientSelectCols+`
FROM firewall_client WHERE id=$1 AND tenant_id=$2`, id, tenantID)
c, err := scanFirewallClientRow(row.Scan, tenantID)
if err != nil {
@@ -147,11 +148,7 @@ func (p *Postgres) LookupFirewallClientByTokenHash(hash []byte) (*store.Firewall
}
ctx := context.Background()
row := p.pool.QueryRow(ctx, `
SELECT tenant_id, id, name, hostname, token_prefix, status,
last_seen_at, last_seen_at_source, last_seen_ip,
last_apply_at, last_apply_status, last_apply_error,
last_apply_prefix_count, last_apply_ip_count, last_apply_source,
client_version, created_at, approved_at, approved_by_api_key_id, revoked_at
SELECT tenant_id, `+firewallClientSelectCols+`
FROM firewall_client WHERE token_hash=$1`, hash)
c, err := scanFirewallClientLookupRow(row.Scan)
if err != nil {
@@ -172,12 +169,13 @@ func (p *Postgres) TouchFirewallClientLastSeen(id, source, clientIP, clientVersi
return err
}
func (p *Postgres) TouchFirewallClientLastApply(id, source, status, errMsg string, prefixCount, ipCount int) error {
func (p *Postgres) TouchFirewallClientLastApply(id, source, status, errMsg string, prefixCount, ipCount int, packetsDropped, packetsAccepted int64) error {
ctx := context.Background()
_, err := p.pool.Exec(ctx, `
UPDATE firewall_client SET last_apply_at=now(), last_apply_source=$2, last_apply_status=$3,
last_apply_error=$4, last_apply_prefix_count=$5, last_apply_ip_count=$6
WHERE id=$1`, id, strings.TrimSpace(source), strings.TrimSpace(status), strings.TrimSpace(errMsg), prefixCount, ipCount)
last_apply_error=$4, last_apply_prefix_count=$5, last_apply_ip_count=$6,
last_apply_packets_dropped=$7, last_apply_packets_accepted=$8
WHERE id=$1`, id, strings.TrimSpace(source), strings.TrimSpace(status), strings.TrimSpace(errMsg), prefixCount, ipCount, packetsDropped, packetsAccepted)
return err
}
@@ -440,16 +438,17 @@ func scanFirewallClientRow(scan scanFn, tenantID string) (*store.FirewallClient,
var approvedBy *string
var lastSeen, lastApply, approved, revoked *time.Time
var prefixCount, ipCount *int
var packetsDropped, packetsAccepted *int64
if err := scan(
&c.ID, &c.Name, &c.Hostname, &c.TokenPrefix, &c.Status,
&lastSeen, &c.LastSeenAtSource, &c.LastSeenIP,
&lastApply, &c.LastApplyStatus, &c.LastApplyError,
&prefixCount, &ipCount, &c.LastApplySource,
&prefixCount, &ipCount, &packetsDropped, &packetsAccepted, &c.LastApplySource,
&c.ClientVersion, &c.CreatedAt, &approved, &approvedBy, &revoked,
); err != nil {
return nil, err
}
return finishFirewallClientScan(&c, lastSeen, lastApply, approved, revoked, approvedBy, prefixCount, ipCount), nil
return finishFirewallClientScan(&c, lastSeen, lastApply, approved, revoked, approvedBy, prefixCount, ipCount, packetsDropped, packetsAccepted), nil
}
func scanFirewallClientLookupRow(scan scanFn) (*store.FirewallClient, error) {
@@ -457,19 +456,20 @@ func scanFirewallClientLookupRow(scan scanFn) (*store.FirewallClient, error) {
var approvedBy *string
var lastSeen, lastApply, approved, revoked *time.Time
var prefixCount, ipCount *int
var packetsDropped, packetsAccepted *int64
if err := scan(
&c.TenantID, &c.ID, &c.Name, &c.Hostname, &c.TokenPrefix, &c.Status,
&lastSeen, &c.LastSeenAtSource, &c.LastSeenIP,
&lastApply, &c.LastApplyStatus, &c.LastApplyError,
&prefixCount, &ipCount, &c.LastApplySource,
&prefixCount, &ipCount, &packetsDropped, &packetsAccepted, &c.LastApplySource,
&c.ClientVersion, &c.CreatedAt, &approved, &approvedBy, &revoked,
); err != nil {
return nil, err
}
return finishFirewallClientScan(&c, lastSeen, lastApply, approved, revoked, approvedBy, prefixCount, ipCount), nil
return finishFirewallClientScan(&c, lastSeen, lastApply, approved, revoked, approvedBy, prefixCount, ipCount, packetsDropped, packetsAccepted), nil
}
func finishFirewallClientScan(c *store.FirewallClient, lastSeen, lastApply, approved, revoked *time.Time, approvedBy *string, prefixCount, ipCount *int) *store.FirewallClient {
func finishFirewallClientScan(c *store.FirewallClient, lastSeen, lastApply, approved, revoked *time.Time, approvedBy *string, prefixCount, ipCount *int, packetsDropped, packetsAccepted *int64) *store.FirewallClient {
c.LastSeenAt = lastSeen
c.LastApplyAt = lastApply
c.ApprovedAt = approved
@@ -483,6 +483,12 @@ func finishFirewallClientScan(c *store.FirewallClient, lastSeen, lastApply, appr
if ipCount != nil {
c.LastApplyIPCount = *ipCount
}
if packetsDropped != nil {
c.LastApplyPacketsDropped = *packetsDropped
}
if packetsAccepted != nil {
c.LastApplyPacketsAccepted = *packetsAccepted
}
return c
}
@@ -0,0 +1,52 @@
package repository
import (
"context"
"os"
"testing"
"evobgp/internal/authkey"
"evobgp/internal/db"
"evobgp/internal/store"
)
func TestPostgresFirewallClientCreateAndGetIntegration(t *testing.T) {
dsn := os.Getenv("EVOBGP_TEST_DATABASE_URL")
if dsn == "" {
t.Skip("EVOBGP_TEST_DATABASE_URL not set")
}
ctx := context.Background()
pool, err := db.OpenPostgresPool(ctx, dsn)
if err != nil {
t.Fatal(err)
}
defer pool.Close()
pg, err := NewPostgres(ctx, pool, true)
if err != nil {
t.Fatal(err)
}
tenant, _, _, _, _ := pg.DemoIDs()
if tenant == "" {
t.Fatal("demo tenant required")
}
tok := "evobgp_fw_pgtest_" + t.Name()
hash := authkey.HashToken(tok)
client, err := pg.CreateFirewallClient(tenant, &store.FirewallClientCreate{
Name: "pg-firewall-test",
Hostname: "test.local",
TokenPrefix: tok[:12],
TokenHash: hash,
ClientVersion: "test/1",
})
if err != nil {
t.Fatalf("create: %v", err)
}
got, err := pg.GetFirewallClient(tenant, client.ID)
if err != nil {
t.Fatalf("get: %v", err)
}
if got.Name != "pg-firewall-test" || got.Status != "pending" {
t.Fatalf("got %+v", got)
}
_ = pg.DeleteFirewallClient(tenant, client.ID)
}
+1 -1
View File
@@ -142,7 +142,7 @@ type Backend interface {
DeleteFirewallClient(tenantID, id string) error
LookupFirewallClientByTokenHash(hash []byte) (*FirewallClient, error)
TouchFirewallClientLastSeen(id, source, clientIP, clientVersion string) error
TouchFirewallClientLastApply(id, source, status, errMsg string, prefixCount, ipCount int) error
TouchFirewallClientLastApply(id, source, status, errMsg string, prefixCount, ipCount int, packetsDropped, packetsAccepted int64) error
ListActiveFirewallClientHashes() ([]FirewallClientAuthRow, error)
ListApprovedFirewallClientsForReplication(tenantID string) ([]FirewallClientReplicationRow, error)
+22 -20
View File
@@ -7,26 +7,28 @@ import (
// FirewallClient is a Linux blocklist sync client enrolled via seed.
type FirewallClient struct {
ID string `json:"id"`
TenantID string `json:"tenant_id,omitempty"`
Name string `json:"name"`
Hostname string `json:"hostname,omitempty"`
TokenPrefix string `json:"token_prefix"`
Status string `json:"status"`
LastSeenAt *time.Time `json:"last_seen_at,omitempty"`
LastSeenAtSource string `json:"last_seen_at_source,omitempty"`
LastSeenIP string `json:"last_seen_ip,omitempty"`
LastApplyAt *time.Time `json:"last_apply_at,omitempty"`
LastApplyStatus string `json:"last_apply_status,omitempty"`
LastApplyError string `json:"last_apply_error,omitempty"`
LastApplyPrefixCount int `json:"last_apply_prefix_count,omitempty"`
LastApplyIPCount int `json:"last_apply_ip_count,omitempty"`
LastApplySource string `json:"last_apply_source,omitempty"`
ClientVersion string `json:"client_version,omitempty"`
CreatedAt time.Time `json:"created_at"`
ApprovedAt *time.Time `json:"approved_at,omitempty"`
ApprovedByAPIKeyID string `json:"approved_by_api_key_id,omitempty"`
RevokedAt *time.Time `json:"revoked_at,omitempty"`
ID string `json:"id"`
TenantID string `json:"tenant_id,omitempty"`
Name string `json:"name"`
Hostname string `json:"hostname,omitempty"`
TokenPrefix string `json:"token_prefix"`
Status string `json:"status"`
LastSeenAt *time.Time `json:"last_seen_at,omitempty"`
LastSeenAtSource string `json:"last_seen_at_source,omitempty"`
LastSeenIP string `json:"last_seen_ip,omitempty"`
LastApplyAt *time.Time `json:"last_apply_at,omitempty"`
LastApplyStatus string `json:"last_apply_status,omitempty"`
LastApplyError string `json:"last_apply_error,omitempty"`
LastApplyPrefixCount int `json:"last_apply_prefix_count,omitempty"`
LastApplyIPCount int `json:"last_apply_ip_count,omitempty"`
LastApplyPacketsDropped int64 `json:"last_apply_packets_dropped,omitempty"`
LastApplyPacketsAccepted int64 `json:"last_apply_packets_accepted,omitempty"`
LastApplySource string `json:"last_apply_source,omitempty"`
ClientVersion string `json:"client_version,omitempty"`
CreatedAt time.Time `json:"created_at"`
ApprovedAt *time.Time `json:"approved_at,omitempty"`
ApprovedByAPIKeyID string `json:"approved_by_api_key_id,omitempty"`
RevokedAt *time.Time `json:"revoked_at,omitempty"`
}
// FirewallClientCreate is input for enroll (token hash supplied by caller).
+3 -1
View File
@@ -171,7 +171,7 @@ func (m *Memory) TouchFirewallClientLastSeen(id, source, clientIP, clientVersion
return nil
}
func (m *Memory) TouchFirewallClientLastApply(id, source, status, errMsg string, prefixCount, ipCount int) error {
func (m *Memory) TouchFirewallClientLastApply(id, source, status, errMsg string, prefixCount, ipCount int, packetsDropped, packetsAccepted int64) error {
m.mu.Lock()
defer m.mu.Unlock()
rec, ok := m.firewallClients[id]
@@ -185,6 +185,8 @@ func (m *Memory) TouchFirewallClientLastApply(id, source, status, errMsg string,
rec.LastApplyError = strings.TrimSpace(errMsg)
rec.LastApplyPrefixCount = prefixCount
rec.LastApplyIPCount = ipCount
rec.LastApplyPacketsDropped = packetsDropped
rec.LastApplyPacketsAccepted = packetsAccepted
return nil
}
@@ -0,0 +1,3 @@
ALTER TABLE firewall_client
DROP COLUMN IF EXISTS last_apply_packets_dropped,
DROP COLUMN IF EXISTS last_apply_packets_accepted;
@@ -0,0 +1,3 @@
ALTER TABLE firewall_client
ADD COLUMN last_apply_packets_dropped BIGINT NOT NULL DEFAULT 0,
ADD COLUMN last_apply_packets_accepted BIGINT NOT NULL DEFAULT 0;
@@ -0,0 +1,2 @@
ALTER TABLE firewall_client DROP COLUMN last_apply_packets_dropped;
ALTER TABLE firewall_client DROP COLUMN last_apply_packets_accepted;
@@ -0,0 +1,2 @@
ALTER TABLE firewall_client ADD COLUMN last_apply_packets_dropped INTEGER NOT NULL DEFAULT 0;
ALTER TABLE firewall_client ADD COLUMN last_apply_packets_accepted INTEGER NOT NULL DEFAULT 0;
+251 -55
View File
@@ -5,6 +5,7 @@ CONF_FILE=/etc/evobgp/firewall.conf
LOG_FILE=/var/log/evobgp-firewall.log
STATE_DIR=/var/lib/evobgp-firewall
HASH_FILE="${STATE_DIR}/last_hash"
PREFIX_FILE="${STATE_DIR}/last_prefixes.txt"
log() { echo "$(date -u +%Y-%m-%dT%H:%M:%SZ) $*" | tee -a "$LOG_FILE"; }
@@ -17,36 +18,32 @@ source "$CONF_FILE"
: "${EVOBGP_CP_URL:?}"
: "${CLIENT_TOKEN:?}"
CLIENT_TOKEN="${CLIENT_TOKEN//$'\r'/}"
CLIENT_TOKEN="${CLIENT_TOKEN//$'\n'/}"
mkdir -p "$STATE_DIR"
BACKEND="${KERNEL_BACKEND:-auto}"
curl_get_blocklist() {
curl_get_blocklist_file() {
local url="$1"
local host
host=$(echo "$url" | sed -E 's#https?://([^/]+)/?.*#\1#')
local tmp
tmp=$(mktemp)
local dest="$2"
local code
code=$(curl -sS -o "$tmp" -w "%{http_code}" \
code=$(curl -sS -o "$dest" -w "%{http_code}" \
-H "Authorization: Bearer ${CLIENT_TOKEN}" \
-H "Accept: application/json" \
"${url}/v1/firewall/blocklist") || return 1
if [[ "$code" == "403" ]]; then
log "pending approval"
rm -f "$tmp"
exit 0
return 2
fi
if [[ "$code" != "200" ]]; then
log "blocklist HTTP $code from $url"
rm -f "$tmp"
return 1
fi
cat "$tmp"
rm -f "$tmp"
return 0
}
try_urls() {
try_fetch_blocklist() {
local urls=()
if [[ -n "${EVOBGP_FAILOVER_URLS:-}" ]]; then
IFS=',' read -r -a urls <<<"$EVOBGP_FAILOVER_URLS"
@@ -57,7 +54,12 @@ try_urls() {
for u in "${urls[@]}"; do
u="${u// /}"
u="${u%/}"
if OUT=$(curl_get_blocklist "$u"); then
local rc=0
curl_get_blocklist_file "$u" "$PREFIX_FILE" || rc=$?
if [[ "$rc" == 2 ]]; then
exit 0
fi
if [[ "$rc" == 0 ]]; then
CP_HIT="$u"
return 0
fi
@@ -65,71 +67,270 @@ try_urls() {
return 1
}
if ! OUT=$(try_urls); then
parse_blocklist_file() {
local f="$1"
if [[ ! -s "$f" ]]; then
log "blocklist file empty: $f"
return 1
fi
if command -v jq >/dev/null 2>&1; then
HASH=$(jq -r '.hash // empty' "$f")
TOTAL=$(jq -r '.total // 0' "$f")
mapfile -t PREFIXES < <(jq -r '.prefixes[]? // empty' "$f")
return 0
fi
if command -v python3 >/dev/null 2>&1; then
local parsed
parsed=$(python3 - "$f" <<'PY'
import json, sys
with open(sys.argv[1], encoding="utf-8") as fh:
data = json.load(fh)
print(data.get("hash") or "")
print(data.get("total") or 0)
for p in data.get("prefixes") or []:
if p:
print(p)
PY
)
HASH=$(echo "$parsed" | sed -n '1p')
TOTAL=$(echo "$parsed" | sed -n '2p')
mapfile -t PREFIXES < <(echo "$parsed" | sed -n '3,$p')
return 0
fi
HASH=$(grep -o '"hash"[[:space:]]*:[[:space:]]*"[^"]*"' "$f" | head -1 | sed 's/.*"\(sha256:[^"]*\)".*/\1/')
TOTAL=$(grep -o '"total"[[:space:]]*:[[:space:]]*[0-9]*' "$f" | head -1 | grep -o '[0-9]*$' || true)
mapfile -t PREFIXES < <(grep -oE '"[0-9]+(\.[0-9]+){3}/[0-9]+"' "$f" | tr -d '"' || true)
return 0
}
nft_join_elements() {
local out="" p
for p in "$@"; do
if [[ -n "$out" ]]; then
out+=", "
fi
out+="$p"
done
printf '%s' "$out"
}
nft_add_v4_chunk() {
local table=$1 name=$2
shift 2
local joined
joined=$(nft_join_elements "$@")
if nft add element "$table" "$name" v4 "{ ${joined} }" 2>>"$LOG_FILE"; then
return 0
fi
log "nft batch add failed (chunk=$#), retrying one-by-one"
local p ok=0
for p in "$@"; do
if nft add element "$table" "$name" v4 "{ $p }" 2>>"$LOG_FILE"; then
ok=$((ok + 1))
fi
done
[[ "$ok" -gt 0 ]]
}
if ! try_fetch_blocklist; then
log "all endpoints failed"
exit 1
fi
if command -v jq >/dev/null 2>&1; then
HASH=$(echo "$OUT" | jq -r '.hash // empty')
TOTAL=$(echo "$OUT" | jq -r '.total // 0')
mapfile -t PREFIXES < <(echo "$OUT" | jq -r '.prefixes[]?')
else
HASH=$(echo "$OUT" | grep -o '"hash"[[:space:]]*:[[:space:]]*"[^"]*"' | head -1 | sed 's/.*"\(sha256:[^"]*\)".*/\1/')
TOTAL=$(echo "$OUT" | grep -o '"total"[[:space:]]*:[[:space:]]*[0-9]*' | head -1 | grep -o '[0-9]*$')
mapfile -t PREFIXES < <(echo "$OUT" | grep -o '"[0-9a-fA-F:.]*/[0-9]*"' | tr -d '"')
HASH=""
TOTAL=0
PREFIXES=()
parse_blocklist_file "$PREFIX_FILE"
log "blocklist bytes=$(wc -c <"$PREFIX_FILE" | tr -d ' ') parsed=${#PREFIXES[@]} api_total=${TOTAL:-0}"
if [[ -z "${TOTAL// }" ]]; then
TOTAL=${#PREFIXES[@]}
fi
if [[ -f "$HASH_FILE" && "$(cat "$HASH_FILE")" == "$HASH" ]]; then
log "unchanged hash $HASH — skip kernel apply"
PACKETS_DROPPED=0
PACKETS_ACCEPTED=0
KERNEL_METHOD=""
APPLIED_V4=0
count_ipv4_prefixes() {
local n=0 p
for p in "${PREFIXES[@]}"; do
[[ "$p" == *:* ]] && continue
n=$((n + 1))
done
APPLIED_V4=$n
}
nft_rule_packets() {
local line=$1
if [[ "$line" =~ counter[[:space:]]+packets[[:space:]]+([0-9]+) ]]; then
echo "${BASH_REMATCH[1]}"
else
echo 0
fi
}
ensure_nft_counters() {
local table=inet name=evobgp_blocklist
nft list chain "$table" "$name" input >/dev/null 2>&1 || return 0
local drop_line
drop_line=$(nft -a list chain "$table" "$name" input 2>/dev/null | grep 'ip saddr @v4' | grep drop | head -1 || true)
if [[ -n "$drop_line" && "$drop_line" != *counter* ]]; then
local handle
handle=$(echo "$drop_line" | sed -n 's/.*# handle \([0-9]\+\).*/\1/p')
if [[ -n "$handle" ]]; then
nft delete rule "$table" "$name" input handle "$handle" 2>>"$LOG_FILE" || true
drop_line=""
fi
fi
if [[ -z "$drop_line" ]]; then
nft add rule "$table" "$name" input ip saddr @v4 counter drop
fi
if ! nft list chain "$table" "$name" input 2>/dev/null | grep -qE '[[:space:]]counter[[:space:]]+accept'; then
nft add rule "$table" "$name" input counter accept
fi
}
collect_nft_packet_stats() {
PACKETS_DROPPED=0
PACKETS_ACCEPTED=0
local line pkts
while IFS= read -r line; do
if [[ "$line" == *"ip saddr @v4"* && "$line" == *drop* ]]; then
pkts=$(nft_rule_packets "$line")
[[ -n "$pkts" ]] && PACKETS_DROPPED=$pkts
elif [[ "$line" == *counter* && "$line" == *accept* && "$line" != *@v4* ]]; then
pkts=$(nft_rule_packets "$line")
[[ -n "$pkts" ]] && PACKETS_ACCEPTED=$pkts
fi
done < <(nft list chain inet evobgp_blocklist input 2>/dev/null || true)
}
collect_ipset_packet_stats() {
PACKETS_DROPPED=0
PACKETS_ACCEPTED=0
local pkts
pkts=$(iptables -L INPUT -v -n -x 2>/dev/null | awk '/match-set evobgp_blocklist_v4/ {print $1; exit}')
[[ "$pkts" =~ ^[0-9]+$ ]] && PACKETS_DROPPED=$pkts
}
collect_packet_stats() {
case "${KERNEL_METHOD:-$BACKEND}" in
nft)
ensure_nft_counters
collect_nft_packet_stats
;;
ipset)
collect_ipset_packet_stats
;;
iptables)
PACKETS_DROPPED=$(iptables -L INPUT -v -n -x 2>/dev/null | awk '/DROP/ {s+=$1} END {print s+0}')
PACKETS_ACCEPTED=0
;;
*)
if command -v nft >/dev/null 2>&1 && nft list chain inet evobgp_blocklist input >/dev/null 2>&1; then
KERNEL_METHOD=nft
ensure_nft_counters
collect_nft_packet_stats
elif iptables -L INPUT -v -n -x 2>/dev/null | grep -q 'evobgp_blocklist_v4'; then
KERNEL_METHOD=ipset
collect_ipset_packet_stats
fi
;;
esac
}
send_client_reports() {
collect_packet_stats
local km="${KERNEL_METHOD:-$BACKEND}"
local report
report=$(printf '{"status":"ok","prefix_count":%s,"ip_count":%s,"packets_dropped":%s,"packets_accepted":%s,"source":"cp","kernel_method":"%s"}' \
"${TOTAL:-0}" "${APPLIED_V4:-0}" "${PACKETS_DROPPED:-0}" "${PACKETS_ACCEPTED:-0}" "$km")
curl -fsS -X POST "${EVOBGP_CP_URL%/}/v1/firewall/apply-report" \
-H "Authorization: Bearer ${CLIENT_TOKEN}" \
-H "Content-Type: application/json" \
-d "$report" >/dev/null 2>&1 || true
curl -fsS -X POST "${EVOBGP_CP_URL%/}/v1/firewall/heartbeat" \
-H "Authorization: Bearer ${CLIENT_TOKEN}" \
-H "Content-Type: application/json" \
-d '{"source":"cp"}' >/dev/null 2>&1 || true
}
if [[ -f "$HASH_FILE" && "$(tr -d '\r\n' <"$HASH_FILE")" == "$HASH" && -n "$HASH" ]]; then
count_ipv4_prefixes
log "unchanged hash $HASH — skip kernel apply (ipv4=${APPLIED_V4})"
send_client_reports
exit 0
fi
apply_nft() {
local table=inet
local name=evobgp_blocklist
local v4=()
local p
for p in "${PREFIXES[@]}"; do
[[ "$p" == *:* ]] && continue
v4+=("$p")
done
nft list table "$table" "$name" >/dev/null 2>&1 || nft add table "$table" "$name"
nft list set "$table" "$name" v4 >/dev/null 2>&1 || nft add set "$table" "$name" v4 '{ type ipv4_addr; flags interval; }'
nft list set "$table" "$name" v4 >/dev/null 2>&1 || \
nft add set "$table" "$name" v4 '{ type ipv4_addr; flags interval; }'
nft flush set "$table" "$name" v4
if ((${#PREFIXES[@]})); then
local v4=()
local p
for p in "${PREFIXES[@]}"; do
[[ "$p" == *:* ]] && continue
v4+=("$p")
if ((${#v4[@]})); then
local batch=()
local chunk=64
for p in "${v4[@]}"; do
batch+=("$p")
if ((${#batch[@]} >= chunk)); then
nft_add_v4_chunk "$table" "$name" "${batch[@]}" || log "nft chunk add partial failure"
batch=()
fi
done
if ((${#v4[@]})); then
nft add element "$table" "$name" v4 "{ $(IFS=,; echo "${v4[*]}") }"
if ((${#batch[@]})); then
nft_add_v4_chunk "$table" "$name" "${batch[@]}" || log "nft tail chunk add partial failure"
fi
fi
nft list chain "$table" "$name" input >/dev/null 2>&1 || {
nft add chain "$table" "$name" input '{ type filter hook input priority 0; }'
nft add rule "$table" "$name" input ip saddr @v4 drop
nft add chain "$table" "$name" input '{ type filter hook input priority 0; policy accept; }'
nft add rule "$table" "$name" input ip saddr @v4 counter drop
nft add rule "$table" "$name" input counter accept
}
ensure_nft_counters
KERNEL_METHOD=nft
APPLIED_V4=${#v4[@]}
}
apply_ipset() {
local set=evobgp_blocklist_v4
local n=0
ipset list "$set" >/dev/null 2>&1 || ipset create "$set" hash:net family inet hashsize 4096 maxelem 1048576
ipset flush "$set"
local p
for p in "${PREFIXES[@]}"; do
[[ "$p" == *:* ]] && continue
ipset add "$set" "$p" -exist
n=$((n + 1))
done
iptables -C INPUT -m set --match-set "$set" src -j DROP 2>/dev/null || \
iptables -I INPUT -m set --match-set "$set" src -j DROP
KERNEL_METHOD=ipset
APPLIED_V4=$n
}
apply_iptables_only() {
iptables -D INPUT -m comment --comment evobgp-block -j DROP 2>/dev/null || true
if ((${#PREFIXES[@]})); then
local p
for p in "${PREFIXES[@]}"; do
[[ "$p" == *:* ]] && continue
iptables -C INPUT -s "$p" -j DROP 2>/dev/null || iptables -A INPUT -s "$p" -j DROP
done
fi
local n=0
local p
for p in "${PREFIXES[@]}"; do
[[ "$p" == *:* ]] && continue
iptables -C INPUT -s "$p" -j DROP 2>/dev/null || iptables -A INPUT -s "$p" -j DROP
n=$((n + 1))
done
KERNEL_METHOD=iptables
APPLIED_V4=$n
}
clear_block() {
@@ -141,10 +342,15 @@ clear_block() {
;;
iptables) iptables -S INPUT | grep -i evobgp | sed 's/^-A /-D /' | while read -r line; do iptables $line 2>/dev/null || true; done ;;
esac
APPLIED_V4=0
KERNEL_METHOD="${BACKEND:-auto}"
}
if [[ "$TOTAL" == "0" || ${#PREFIXES[@]} -eq 0 ]]; then
APPLIED_V4=0
KERNEL_METHOD=""
if [[ "${TOTAL:-0}" == "0" || ${#PREFIXES[@]} -eq 0 ]]; then
clear_block
log "cleared blocklist (api total=${TOTAL:-0}) backend=$BACKEND"
else
case "$BACKEND" in
nft|auto) if command -v nft >/dev/null 2>&1; then apply_nft; else apply_ipset; fi ;;
@@ -152,18 +358,8 @@ else
iptables) apply_iptables_only ;;
*) apply_ipset ;;
esac
log "applied api_total=${TOTAL} ipv4_in_kernel=${APPLIED_V4} from ${CP_HIT:-$EVOBGP_CP_URL} backend=$BACKEND hash=${HASH:-empty}"
fi
echo "$HASH" >"$HASH_FILE"
log "applied $TOTAL prefixes from ${CP_HIT:-$EVOBGP_CP_URL} backend=$BACKEND"
REPORT=$(printf '{"status":"ok","prefix_count":%s,"ip_count":0,"source":"cp"}' "${TOTAL:-0}")
curl -fsS -X POST "${EVOBGP_CP_URL%/}/v1/firewall/apply-report" \
-H "Authorization: Bearer ${CLIENT_TOKEN}" \
-H "Content-Type: application/json" \
-d "$REPORT" >/dev/null 2>&1 || true
curl -fsS -X POST "${EVOBGP_CP_URL%/}/v1/firewall/heartbeat" \
-H "Authorization: Bearer ${CLIENT_TOKEN}" \
-H "Content-Type: application/json" \
-d '{"source":"cp"}' >/dev/null 2>&1 || true
send_client_reports
+20 -1
View File
@@ -10,6 +10,16 @@ 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
fi
fi
if ! command -v jq >/dev/null 2>&1 && ! command -v python3 >/dev/null 2>&1; then
echo "evobgp-firewall install: install jq or python3 for blocklist JSON parsing" >&2
exit 1
fi
: "${EVOBGP_CP_URL:?EVOBGP_CP_URL required}"
: "${EVOBGP_SEED:?EVOBGP_SEED required}"
: "${EVOBGP_CLIENT_NAME:?EVOBGP_CLIENT_NAME required}"
@@ -38,10 +48,18 @@ CP_URL="${EVOBGP_CP_URL%/}"
ENROLL_BODY=$(printf '{"name":"%s","hostname":"%s","client_token":"%s","client_version":"install.sh/1"}' \
"$EVOBGP_CLIENT_NAME" "$HOSTNAME" "$CLIENT_TOKEN")
RESP=$(curl -fsS -X POST "${CP_URL}/v1/firewall/enroll" \
ENROLL_TMP=$(mktemp)
trap 'rm -f "$ENROLL_TMP"' EXIT
ENROLL_CODE=$(curl -sS -o "$ENROLL_TMP" -w "%{http_code}" -X POST "${CP_URL}/v1/firewall/enroll" \
-H "Content-Type: application/json" \
-H "X-EvoBGP-Seed: ${EVOBGP_SEED}" \
-d "$ENROLL_BODY")
if [[ "$ENROLL_CODE" != "201" ]]; then
echo "evobgp-firewall enroll failed: HTTP ${ENROLL_CODE} from ${CP_URL}/v1/firewall/enroll" >&2
cat "$ENROLL_TMP" >&2
exit 1
fi
RESP=$(cat "$ENROLL_TMP")
CLIENT_ID=""
if command -v jq >/dev/null 2>&1; then
@@ -102,6 +120,7 @@ WantedBy=timers.target
UNIT
systemctl daemon-reload
systemctl enable --now evobgp-firewall.timer
echo "Tip: after UI approve, run: rm -f /var/lib/evobgp-firewall/last_hash && ${SYNC_SCRIPT}"
else
echo "*/5 * * * * root ${SYNC_SCRIPT}" >/etc/cron.d/evobgp-firewall
fi