Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e51999c908 | ||
|
|
b7f7669685 | ||
|
|
947d1f0cc4 |
@@ -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 }) {
|
||||
|
||||
@@ -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 useRevokeFirewallClient() {
|
||||
const qc = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: (id: string) =>
|
||||
apiJSON<{ status: string }>(`/v1/firewall/clients/${id}/revoke`, { method: 'POST' }),
|
||||
onSuccess: () => {
|
||||
toast.success('Клиент отключён')
|
||||
void qc.invalidateQueries({ queryKey: firewallKeys.clients() })
|
||||
},
|
||||
onError: (e) => toast.error(e instanceof Error ? e.message : 'Не удалось отклонить'),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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'
|
||||
@@ -31,6 +32,7 @@ import {
|
||||
useApproveFirewallClient,
|
||||
useCreateFirewallRule,
|
||||
useDeleteFirewallRule,
|
||||
useRevokeFirewallClient,
|
||||
} 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 revoke = useRevokeFirewallClient()
|
||||
const createRule = useCreateFirewallRule()
|
||||
const deleteRule = useDeleteFirewallRule()
|
||||
|
||||
@@ -193,7 +196,13 @@ function FirewallPage() {
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="clients" className="mt-4">
|
||||
<ClientsTable clients={clients} onApprove={(id) => approve.mutate(id)} />
|
||||
<ClientsTable
|
||||
clients={clients}
|
||||
onApprove={(id) => approve.mutate(id)}
|
||||
onReject={(id) => revoke.mutate(id)}
|
||||
approvePending={approve.isPending}
|
||||
rejectPending={revoke.isPending}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="rules" className="mt-4 space-y-4">
|
||||
@@ -254,6 +263,9 @@ function FirewallPage() {
|
||||
<ClientsTable
|
||||
clients={pending}
|
||||
onApprove={(id) => approve.mutate(id)}
|
||||
onReject={(id) => revoke.mutate(id)}
|
||||
approvePending={approve.isPending}
|
||||
rejectPending={revoke.isPending}
|
||||
emptyTitle="Нет pending-запросов"
|
||||
/>
|
||||
</TabsContent>
|
||||
@@ -265,10 +277,16 @@ function FirewallPage() {
|
||||
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) {
|
||||
@@ -301,11 +319,56 @@ function ClientsTable({
|
||||
{c.last_apply_prefix_count != null ? ` (${c.last_apply_prefix_count})` : ''}
|
||||
</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>
|
||||
))}
|
||||
|
||||
@@ -35,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-домен.
|
||||
|
||||
@@ -4472,6 +4472,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]
|
||||
|
||||
@@ -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,22 +67,87 @@ 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
|
||||
if [[ -f "$HASH_FILE" && "$(tr -d '\r\n' <"$HASH_FILE")" == "$HASH" && -n "$HASH" ]]; then
|
||||
log "unchanged hash $HASH — skip kernel apply"
|
||||
exit 0
|
||||
fi
|
||||
@@ -88,48 +155,66 @@ 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 chain "$table" "$name" input '{ type filter hook input priority 0; policy accept; }'
|
||||
nft add rule "$table" "$name" input ip saddr @v4 drop
|
||||
}
|
||||
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
|
||||
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
|
||||
APPLIED_V4=$n
|
||||
}
|
||||
|
||||
clear_block() {
|
||||
@@ -141,10 +226,13 @@ 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
|
||||
}
|
||||
|
||||
if [[ "$TOTAL" == "0" || ${#PREFIXES[@]} -eq 0 ]]; then
|
||||
APPLIED_V4=0
|
||||
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,12 +240,12 @@ 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}")
|
||||
REPORT=$(printf '{"status":"ok","prefix_count":%s,"ip_count":%s,"source":"cp"}' "${TOTAL:-0}" "${APPLIED_V4:-0}")
|
||||
curl -fsS -X POST "${EVOBGP_CP_URL%/}/v1/firewall/apply-report" \
|
||||
-H "Authorization: Bearer ${CLIENT_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
|
||||
@@ -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}"
|
||||
@@ -110,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
|
||||
|
||||
@@ -205,6 +205,74 @@ func TestFirewallInstallContext(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestFirewallRevokePendingClient(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")
|
||||
}
|
||||
|
||||
reqRevoke, _ := http.NewRequest(http.MethodPost, ts.URL+"/v1/firewall/clients/"+clientID+"/revoke", nil)
|
||||
reqRevoke.Header.Set("Authorization", "Bearer opkey")
|
||||
respRevoke, err := client.Do(reqRevoke)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() { _ = respRevoke.Body.Close() }()
|
||||
if respRevoke.StatusCode != http.StatusOK {
|
||||
b, _ := io.ReadAll(respRevoke.Body)
|
||||
t.Fatalf("revoke status=%d body=%s", respRevoke.StatusCode, b)
|
||||
}
|
||||
|
||||
got, err := srv.Store().GetFirewallClient(tenant, clientID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.Status != "revoked" {
|
||||
t.Fatalf("status=%q want revoked", got.Status)
|
||||
}
|
||||
|
||||
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.StatusForbidden {
|
||||
t.Fatalf("revoked blocklist want 403 got %d", respBlock.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFirewallTokenHashMatchesAuthkey(t *testing.T) {
|
||||
tok := "evobgp_fw_sample"
|
||||
h := authkey.HashToken(tok)
|
||||
|
||||
@@ -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,22 +67,87 @@ 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
|
||||
if [[ -f "$HASH_FILE" && "$(tr -d '\r\n' <"$HASH_FILE")" == "$HASH" && -n "$HASH" ]]; then
|
||||
log "unchanged hash $HASH — skip kernel apply"
|
||||
exit 0
|
||||
fi
|
||||
@@ -88,48 +155,66 @@ 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 chain "$table" "$name" input '{ type filter hook input priority 0; policy accept; }'
|
||||
nft add rule "$table" "$name" input ip saddr @v4 drop
|
||||
}
|
||||
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
|
||||
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
|
||||
APPLIED_V4=$n
|
||||
}
|
||||
|
||||
clear_block() {
|
||||
@@ -141,10 +226,13 @@ 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
|
||||
}
|
||||
|
||||
if [[ "$TOTAL" == "0" || ${#PREFIXES[@]} -eq 0 ]]; then
|
||||
APPLIED_V4=0
|
||||
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,12 +240,12 @@ 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}")
|
||||
REPORT=$(printf '{"status":"ok","prefix_count":%s,"ip_count":%s,"source":"cp"}' "${TOTAL:-0}" "${APPLIED_V4:-0}")
|
||||
curl -fsS -X POST "${EVOBGP_CP_URL%/}/v1/firewall/apply-report" \
|
||||
-H "Authorization: Bearer ${CLIENT_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
|
||||
@@ -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}"
|
||||
@@ -110,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
|
||||
|
||||
Reference in New Issue
Block a user