feat(firewall): implement firewall blocklist feature with client management and policy rules
CI / changes (push) Successful in 12s
CI / commitlint (push) Has been skipped
CI / openapi (push) Successful in 25s
CI / web (push) Successful in 46s
CI / go (push) Successful in 1m15s
CI / bird2 (push) Successful in 18s
CI / release (push) Successful in 3m59s

Introduced a comprehensive firewall blocklist feature, allowing for the management of firewall clients and their associated rules. This includes endpoints for enrolling clients, listing clients and rules, and reporting apply statuses. Enhanced the API to support firewall operations, including the ability to handle block/accept policies. Updated the documentation to reflect these changes and added necessary components in the web UI for better user interaction.

Additionally, modified the agent server to support firewall failover and integrated firewall functionality into the existing architecture.
This commit is contained in:
Denozordec
2026-07-08 16:37:27 +07:00
parent 276194a9d0
commit 7a3eae98b1
36 changed files with 4581 additions and 174 deletions
@@ -9,6 +9,7 @@ import {
BookText,
KeyRound,
ServerCog,
Shield,
} from 'lucide-react'
import {
@@ -69,6 +70,7 @@ const NAV_GROUPS: NavGroup[] = [
label: 'Операции',
items: [
{ to: '/operations', label: 'Операции', icon: Cog },
{ to: '/firewall', label: 'Firewall', icon: Shield },
{ to: '/schedule', label: 'Задачи', icon: ListChecks },
{ to: '/monitoring', label: 'Мониторинг', icon: Activity },
],
+67
View File
@@ -0,0 +1,67 @@
import { queryOptions, useMutation, useQueryClient } from '@tanstack/react-query'
import { apiJSON } from '@/lib/api-client'
import type { FirewallClient, FirewallClientsResponse, FirewallRule, FirewallRulesResponse } from '@/types/api'
export const firewallKeys = {
all: ['firewall'] as const,
clients: () => [...firewallKeys.all, 'clients'] as const,
rules: (scope: string, clientId?: string) =>
[...firewallKeys.all, 'rules', scope, clientId ?? ''] as const,
}
export function firewallClientsQueryOptions() {
return queryOptions<FirewallClientsResponse>({
queryKey: firewallKeys.clients(),
queryFn: () => apiJSON<FirewallClientsResponse>('/v1/firewall/clients'),
staleTime: 15_000,
})
}
export function firewallRulesQueryOptions(scope: 'tenant' | 'client', clientId?: string) {
const qs =
scope === 'client' && clientId
? `?scope=client&client_id=${encodeURIComponent(clientId)}`
: '?scope=tenant'
return queryOptions<FirewallRulesResponse>({
queryKey: firewallKeys.rules(scope, clientId),
queryFn: () => apiJSON<FirewallRulesResponse>(`/v1/firewall/rules${qs}`),
staleTime: 15_000,
})
}
export function useApproveFirewallClient() {
const qc = useQueryClient()
return useMutation({
mutationFn: (id: string) =>
apiJSON<FirewallClient>(`/v1/firewall/clients/${id}/approve`, { method: 'POST' }),
onSuccess: () => {
void qc.invalidateQueries({ queryKey: firewallKeys.clients() })
},
})
}
export function useCreateFirewallRule() {
const qc = useQueryClient()
return useMutation({
mutationFn: (body: Record<string, unknown>) =>
apiJSON<FirewallRule>('/v1/firewall/rules', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
}),
onSuccess: () => {
void qc.invalidateQueries({ queryKey: firewallKeys.all })
},
})
}
export function useDeleteFirewallRule() {
const qc = useQueryClient()
return useMutation({
mutationFn: (id: string) =>
apiJSON<void>(`/v1/firewall/rules/${id}`, { method: 'DELETE' }),
onSuccess: () => {
void qc.invalidateQueries({ queryKey: firewallKeys.all })
},
})
}
+293
View File
@@ -0,0 +1,293 @@
import { createFileRoute } from '@tanstack/react-router'
import { useQuery } from '@tanstack/react-query'
import { Copy, Info, RefreshCw, Shield } from 'lucide-react'
import { useMemo, useState } from 'react'
import { toast } from 'sonner'
import { Alert, AlertDescription, AlertTitle } from '@evobgp/ui/components/alert'
import { Button } from '@evobgp/ui/components/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@evobgp/ui/components/card'
import { Input } from '@evobgp/ui/components/input'
import { Label } from '@evobgp/ui/components/label'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@evobgp/ui/components/tabs'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@evobgp/ui/components/table'
import { PageHeader } from '@/components/page-header'
import { StatusBadge } from '@/components/status-badge'
import {
firewallClientsQueryOptions,
firewallRulesQueryOptions,
useApproveFirewallClient,
useCreateFirewallRule,
useDeleteFirewallRule,
} from '@/queries/firewall'
import type { FirewallClient } from '@/types/api'
export const Route = createFileRoute('/_auth/firewall')({
component: FirewallPage,
})
function FirewallPage() {
const clientsQ = useQuery(firewallClientsQueryOptions())
const rulesQ = useQuery(firewallRulesQueryOptions('tenant'))
const approve = useApproveFirewallClient()
const createRule = useCreateFirewallRule()
const deleteRule = useDeleteFirewallRule()
const [clientName, setClientName] = useState('web-01')
const [cpUrl, setCpUrl] = useState(() =>
typeof window !== 'undefined' ? window.location.origin : 'https://api.example.com',
)
const [seed, setSeed] = useState('')
const [ruleAction, setRuleAction] = useState<'block' | 'accept'>('block')
const [ruleComment, setRuleComment] = useState('')
const clients = clientsQ.data?.items ?? []
const pending = clients.filter((c) => c.status === 'pending')
const rules = rulesQ.data?.items ?? []
const installCmd = useMemo(() => {
const s = seed.trim() || '<bundle_seed_hex>'
return `curl -fsSL ${cpUrl.replace(/\/$/, '')}/v1/firewall/install.sh | \\
EVOBGP_CP_URL=${cpUrl.replace(/\/$/, '')} \\
EVOBGP_SEED=${s} \\
EVOBGP_CLIENT_NAME="${clientName}" \\
bash`
}, [clientName, cpUrl, seed])
async function copyInstall() {
if (!seed.trim()) {
toast.error('Укажите bundle seed')
return
}
try {
await navigator.clipboard.writeText(installCmd)
toast.success('Команда скопирована')
} catch {
toast.error('Не удалось скопировать')
}
}
return (
<div className="flex flex-col gap-6">
<PageHeader
title="Firewall blocklist"
description="Linux-серверы: синхронизация CIDR по policy block/accept"
actions={
<Button
variant="outline"
size="sm"
onClick={() => {
void clientsQ.refetch()
void rulesQ.refetch()
}}
disabled={clientsQ.isFetching}
>
<RefreshCw className={clientsQ.isFetching ? 'animate-spin' : ''} />
Обновить
</Button>
}
/>
<Alert className="border-info/30 bg-info/5">
<Info className="text-info" />
<AlertTitle>Политика</AlertTitle>
<AlertDescription>
Только явный <strong>block</strong> добавляет IP в blocklist. Правила <strong>accept</strong> сами по себе
не создают block all. Default accept.
</AlertDescription>
</Alert>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Shield className="size-5" />
Установка на сервер
</CardTitle>
<CardDescription>One-liner для root на целевом Linux (bash, curl). После enroll approve в «Запросы».</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<div className="grid gap-4 sm:grid-cols-3">
<div className="space-y-2">
<Label htmlFor="fw-name">Имя сервера</Label>
<Input id="fw-name" value={clientName} onChange={(e) => setClientName(e.target.value)} />
</div>
<div className="space-y-2">
<Label htmlFor="fw-url">URL API</Label>
<Input id="fw-url" value={cpUrl} onChange={(e) => setCpUrl(e.target.value)} />
</div>
<div className="space-y-2">
<Label htmlFor="fw-seed">Bundle seed</Label>
<Input
id="fw-seed"
type="password"
placeholder="EVOBGP_BUNDLE_SEED_HEX"
value={seed}
onChange={(e) => setSeed(e.target.value)}
/>
</div>
</div>
<pre className="overflow-x-auto rounded-md border bg-muted/40 p-3 text-xs">{installCmd}</pre>
<Button variant="outline" size="sm" className="w-fit" onClick={copyInstall}>
<Copy />
Копировать команду
</Button>
</CardContent>
</Card>
<Tabs defaultValue="clients">
<TabsList>
<TabsTrigger value="clients">Клиенты ({clients.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)} />
</TabsContent>
<TabsContent value="rules" className="mt-4 space-y-4">
<div className="flex flex-wrap items-end gap-2">
<div className="space-y-1">
<Label>Действие</Label>
<select
className="border-input bg-background h-9 rounded-md border px-2 text-sm"
value={ruleAction}
onChange={(e) => setRuleAction(e.target.value as 'block' | 'accept')}
>
<option value="block">block</option>
<option value="accept">accept</option>
</select>
</div>
<Input
className="max-w-xs"
placeholder="Комментарий"
value={ruleComment}
onChange={(e) => setRuleComment(e.target.value)}
/>
<Button
size="sm"
onClick={() =>
createRule.mutate({
scope: 'tenant',
action: ruleAction,
comment: ruleComment,
})
}
>
Добавить правило
</Button>
</div>
<RulesTable rules={rules} onDelete={(id) => deleteRule.mutate(id)} />
</TabsContent>
<TabsContent value="requests" className="mt-4">
<ClientsTable
clients={pending}
onApprove={(id) => approve.mutate(id)}
emptyTitle="Нет pending-запросов"
/>
</TabsContent>
</Tabs>
</div>
)
}
function ClientsTable({
clients,
onApprove,
emptyTitle = 'Нет клиентов',
}: {
clients: FirewallClient[]
onApprove: (id: string) => void
emptyTitle?: string
}) {
if (clients.length === 0) {
return <p className="text-muted-foreground text-sm">{emptyTitle}</p>
}
return (
<Table>
<TableHeader>
<TableRow>
<TableHead>Имя</TableHead>
<TableHead>Статус</TableHead>
<TableHead>Last seen</TableHead>
<TableHead>Apply</TableHead>
<TableHead />
</TableRow>
</TableHeader>
<TableBody>
{clients.map((c) => (
<TableRow key={c.id}>
<TableCell>
<div className="font-medium">{c.name}</div>
<div className="text-muted-foreground text-xs">{c.hostname || c.token_prefix}</div>
</TableCell>
<TableCell>
<StatusBadge status={c.status} />
</TableCell>
<TableCell className="text-xs">{c.last_seen_at?.slice(0, 19) ?? '—'}</TableCell>
<TableCell className="text-xs">
{c.last_apply_status ?? '—'}
{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}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)
}
function RulesTable({
rules,
onDelete,
}: {
rules: { id: string; priority: number; action: string; comment?: string }[]
onDelete: (id: string) => void
}) {
if (rules.length === 0) {
return <p className="text-muted-foreground text-sm">Нет правил blocklist пуст (default accept).</p>
}
return (
<Table>
<TableHeader>
<TableRow>
<TableHead>#</TableHead>
<TableHead>Действие</TableHead>
<TableHead>Комментарий</TableHead>
<TableHead />
</TableRow>
</TableHeader>
<TableBody>
{rules.map((r) => (
<TableRow key={r.id}>
<TableCell>{r.priority}</TableCell>
<TableCell>
<StatusBadge status={r.action} label={r.action} />
</TableCell>
<TableCell>{r.comment || '—'}</TableCell>
<TableCell>
<Button size="sm" variant="ghost" onClick={() => onDelete(r.id)}>
Удалить
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)
}
+30
View File
@@ -343,3 +343,33 @@ export type ApiKeyCreated = ApiKey & { token: string }
export type AsyncJobAccepted = {
job_id: string
}
// ---- Firewall blocklist ----
export type FirewallClient = {
id: string
name: string
hostname?: string
token_prefix: string
status: 'pending' | 'approved' | 'revoked'
last_seen_at?: string | null
last_seen_at_source?: string
last_apply_at?: string | null
last_apply_status?: string
last_apply_prefix_count?: number
last_apply_source?: string
client_version?: string
created_at: string
}
export type FirewallClientsResponse = { items: FirewallClient[] }
export type FirewallRule = {
id: string
client_id?: string | null
priority: number
action: 'block' | 'accept'
community_id?: string | null
comment?: string
}
export type FirewallRulesResponse = { items: FirewallRule[] }
+1 -1
View File
@@ -1 +1 @@
{"root":["./src/main.tsx","./src/routetree.gen.ts","./src/components/confirm-dialog.tsx","./src/components/empty-state.tsx","./src/components/loading-button.tsx","./src/components/mode-toggle.tsx","./src/components/page-header.tsx","./src/components/page-shell.tsx","./src/components/query-state.tsx","./src/components/section-cards.tsx","./src/components/skeletons.tsx","./src/components/status-badge.tsx","./src/components/theme-provider.tsx","./src/components/truncated-text.tsx","./src/components/access/access-api-keys-card.tsx","./src/components/access/api-key-create-dialog.tsx","./src/components/access/api-key-token-dialog.tsx","./src/components/layout/app-shell.tsx","./src/components/modules/community-select.tsx","./src/components/modules/module-as-entry-dialog.tsx","./src/components/modules/module-cdn-source-dialog.tsx","./src/components/modules/module-domain-entry-dialog.tsx","./src/components/modules/module-entries-section.tsx","./src/components/modules/module-ip-range-entry-dialog.tsx","./src/components/modules/module-kpi-cards.tsx","./src/components/reui/autocomplete.tsx","./src/components/reui/badge.tsx","./src/components/reui/date-selector.tsx","./src/components/reui/filters.tsx","./src/components/reui/number-field.tsx","./src/components/reui/data-grid/data-grid-column-filter.tsx","./src/components/reui/data-grid/data-grid-column-header.tsx","./src/components/reui/data-grid/data-grid-column-visibility.tsx","./src/components/reui/data-grid/data-grid-pagination.tsx","./src/components/reui/data-grid/data-grid-scroll-area.tsx","./src/components/reui/data-grid/data-grid-table-dnd-rows.tsx","./src/components/reui/data-grid/data-grid-table-dnd.tsx","./src/components/reui/data-grid/data-grid-table-virtual.tsx","./src/components/reui/data-grid/data-grid-table.tsx","./src/components/reui/data-grid/data-grid.tsx","./src/lib/api-client.ts","./src/lib/queryclient.ts","./src/lib/router.ts","./src/lib/ui-labels.ts","./src/lib/access/api-key-labels.ts","./src/lib/modules/display.ts","./src/lib/modules/helpers.ts","./src/queries/api-keys.ts","./src/queries/auth.ts","./src/queries/directories.ts","./src/queries/modules.ts","./src/queries/monitoring.ts","./src/queries/network.ts","./src/queries/operations.ts","./src/queries/overview.ts","./src/queries/settings.ts","./src/routes/__root.tsx","./src/routes/_auth.tsx","./src/routes/index.tsx","./src/routes/_auth/access.tsx","./src/routes/_auth/dashboard.tsx","./src/routes/_auth/directories.tsx","./src/routes/_auth/monitoring.tsx","./src/routes/_auth/network.tsx","./src/routes/_auth/operations.tsx","./src/routes/_auth/schedule.tsx","./src/routes/_auth/settings.tsx","./src/routes/_auth/tenant-settings.tsx","./src/routes/_auth/modules/$moduleid.tsx","./src/routes/_auth/modules/index.tsx","./src/routes/_auth/modules/new.tsx","./src/types/api.ts","./vite.config.ts"],"version":"5.9.3"}
{"root":["./src/main.tsx","./src/routetree.gen.ts","./src/components/confirm-dialog.tsx","./src/components/empty-state.tsx","./src/components/loading-button.tsx","./src/components/mode-toggle.tsx","./src/components/page-header.tsx","./src/components/page-shell.tsx","./src/components/query-state.tsx","./src/components/section-cards.tsx","./src/components/skeletons.tsx","./src/components/status-badge.tsx","./src/components/theme-provider.tsx","./src/components/truncated-text.tsx","./src/components/access/access-api-keys-card.tsx","./src/components/access/api-key-create-dialog.tsx","./src/components/access/api-key-token-dialog.tsx","./src/components/layout/app-shell.tsx","./src/components/modules/community-select.tsx","./src/components/modules/module-as-entry-dialog.tsx","./src/components/modules/module-cdn-source-dialog.tsx","./src/components/modules/module-domain-entry-dialog.tsx","./src/components/modules/module-entries-section.tsx","./src/components/modules/module-ip-range-entry-dialog.tsx","./src/components/modules/module-kpi-cards.tsx","./src/components/reui/autocomplete.tsx","./src/components/reui/badge.tsx","./src/components/reui/date-selector.tsx","./src/components/reui/filters.tsx","./src/components/reui/number-field.tsx","./src/components/reui/data-grid/data-grid-column-filter.tsx","./src/components/reui/data-grid/data-grid-column-header.tsx","./src/components/reui/data-grid/data-grid-column-visibility.tsx","./src/components/reui/data-grid/data-grid-pagination.tsx","./src/components/reui/data-grid/data-grid-scroll-area.tsx","./src/components/reui/data-grid/data-grid-table-dnd-rows.tsx","./src/components/reui/data-grid/data-grid-table-dnd.tsx","./src/components/reui/data-grid/data-grid-table-virtual.tsx","./src/components/reui/data-grid/data-grid-table.tsx","./src/components/reui/data-grid/data-grid.tsx","./src/lib/api-client.ts","./src/lib/queryclient.ts","./src/lib/router.ts","./src/lib/ui-labels.ts","./src/lib/access/api-key-labels.ts","./src/lib/modules/display.ts","./src/lib/modules/helpers.ts","./src/queries/api-keys.ts","./src/queries/auth.ts","./src/queries/directories.ts","./src/queries/firewall.ts","./src/queries/modules.ts","./src/queries/monitoring.ts","./src/queries/network.ts","./src/queries/operations.ts","./src/queries/overview.ts","./src/queries/settings.ts","./src/routes/__root.tsx","./src/routes/_auth.tsx","./src/routes/index.tsx","./src/routes/_auth/access.tsx","./src/routes/_auth/dashboard.tsx","./src/routes/_auth/directories.tsx","./src/routes/_auth/firewall.tsx","./src/routes/_auth/monitoring.tsx","./src/routes/_auth/network.tsx","./src/routes/_auth/operations.tsx","./src/routes/_auth/schedule.tsx","./src/routes/_auth/settings.tsx","./src/routes/_auth/tenant-settings.tsx","./src/routes/_auth/modules/$moduleid.tsx","./src/routes/_auth/modules/index.tsx","./src/routes/_auth/modules/new.tsx","./src/types/api.ts","./vite.config.ts"],"version":"5.9.3"}