Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5d1102b497 | ||
|
|
5edbd656ba | ||
|
|
4a4c11c6bf | ||
|
|
e51999c908 | ||
|
|
b7f7669685 | ||
|
|
947d1f0cc4 | ||
|
|
68f9d4b832 | ||
|
|
72045afcde | ||
|
|
e15768b25b | ||
|
|
7b3f002e5f | ||
|
|
fa2abc81f3 |
@@ -0,0 +1,215 @@
|
||||
import {
|
||||
ColumnDef,
|
||||
getCoreRowModel,
|
||||
getPaginationRowModel,
|
||||
getSortedRowModel,
|
||||
useReactTable,
|
||||
} from '@tanstack/react-table'
|
||||
import { useMemo } from 'react'
|
||||
|
||||
import { Button } from '@evobgp/ui/components/button'
|
||||
|
||||
import { ConfirmDialog } from '@/components/confirm-dialog'
|
||||
import { StatusBadge } from '@/components/status-badge'
|
||||
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
||||
import { DataGridPagination } from '@/components/reui/data-grid/data-grid-pagination'
|
||||
import { DataGrid, DataGridContainer } from '@/components/reui/data-grid/data-grid'
|
||||
import { DataGridTable } from '@/components/reui/data-grid/data-grid-table'
|
||||
import type { FirewallClient } from '@/types/api'
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
export interface FirewallClientsGridProps {
|
||||
clients: FirewallClient[]
|
||||
isLoading?: boolean
|
||||
onApprove: (id: string) => void
|
||||
onReject: (id: string) => void
|
||||
approvePending?: boolean
|
||||
rejectPending?: boolean
|
||||
emptyTitle?: string
|
||||
}
|
||||
|
||||
export function FirewallClientsGrid({
|
||||
clients,
|
||||
isLoading = false,
|
||||
onApprove,
|
||||
onReject,
|
||||
approvePending = false,
|
||||
rejectPending = false,
|
||||
emptyTitle = 'Нет клиентов',
|
||||
}: FirewallClientsGridProps) {
|
||||
const columns = useMemo<ColumnDef<FirewallClient>[]>(
|
||||
() => [
|
||||
{
|
||||
accessorKey: 'name',
|
||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Имя" />,
|
||||
cell: ({ row }) => (
|
||||
<div>
|
||||
<div className="font-medium">{row.original.name}</div>
|
||||
<div className="text-muted-foreground text-xs">
|
||||
{row.original.hostname || row.original.token_prefix}
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
meta: { headerTitle: 'Имя' },
|
||||
},
|
||||
{
|
||||
accessorKey: 'status',
|
||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Статус" />,
|
||||
cell: ({ row }) => <StatusBadge status={row.original.status} />,
|
||||
meta: { headerTitle: 'Статус' },
|
||||
},
|
||||
{
|
||||
id: 'last_seen_at',
|
||||
accessorFn: (row) => row.last_seen_at ?? '',
|
||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Last seen" />,
|
||||
cell: ({ row }) => (
|
||||
<span className="text-xs">{row.original.last_seen_at?.slice(0, 19) ?? '—'}</span>
|
||||
),
|
||||
sortingFn: (a, b) => {
|
||||
const av = a.original.last_seen_at ?? ''
|
||||
const bv = b.original.last_seen_at ?? ''
|
||||
return av.localeCompare(bv)
|
||||
},
|
||||
meta: { headerTitle: 'Last seen' },
|
||||
},
|
||||
{
|
||||
id: 'apply',
|
||||
enableSorting: false,
|
||||
header: 'Apply',
|
||||
cell: ({ row }) => {
|
||||
const c = row.original
|
||||
return (
|
||||
<span className="text-xs">
|
||||
{c.last_apply_status ?? '—'}
|
||||
{c.last_apply_prefix_count != null ? ` (${c.last_apply_prefix_count})` : ''}
|
||||
</span>
|
||||
)
|
||||
},
|
||||
meta: { headerTitle: 'Apply' },
|
||||
},
|
||||
{
|
||||
id: 'packets',
|
||||
enableSorting: false,
|
||||
header: 'Пакеты',
|
||||
cell: ({ row }) => {
|
||||
const dropped = formatPacketCount(row.original.last_apply_packets_dropped)
|
||||
const accepted = formatPacketCount(row.original.last_apply_packets_accepted)
|
||||
if (!dropped && !accepted) {
|
||||
return <span className="text-muted-foreground text-xs">—</span>
|
||||
}
|
||||
return (
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{dropped ? <span className="text-destructive">↓{dropped}</span> : null}
|
||||
{dropped && accepted ? ' · ' : null}
|
||||
{accepted ? <span className="text-success">↑{accepted}</span> : null}
|
||||
</span>
|
||||
)
|
||||
},
|
||||
meta: { headerTitle: 'Пакеты' },
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
enableSorting: false,
|
||||
header: () => null,
|
||||
cell: ({ row }) => {
|
||||
const c = row.original
|
||||
return (
|
||||
<div className="flex justify-end gap-2">
|
||||
{c.status === 'pending' ? (
|
||||
<>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
type="button"
|
||||
disabled={approvePending}
|
||||
onClick={() => onApprove(c.id)}
|
||||
>
|
||||
Одобрить
|
||||
</Button>
|
||||
<ConfirmDialog
|
||||
trigger={
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
type="button"
|
||||
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"
|
||||
type="button"
|
||||
className="text-destructive"
|
||||
disabled={rejectPending}
|
||||
>
|
||||
Удалить
|
||||
</Button>
|
||||
}
|
||||
title="Удалить клиент?"
|
||||
description={`${c.name} — запись будет удалена, blocklist и токен перестанут работать.`}
|
||||
confirmLabel="Удалить"
|
||||
destructive
|
||||
onConfirm={() => onReject(c.id)}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
},
|
||||
],
|
||||
[approvePending, onApprove, onReject, rejectPending],
|
||||
)
|
||||
|
||||
const table = useReactTable({
|
||||
data: clients,
|
||||
columns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
getPaginationRowModel: getPaginationRowModel(),
|
||||
getRowId: (row) => row.id,
|
||||
initialState: { pagination: { pageSize: 10 } },
|
||||
})
|
||||
|
||||
return (
|
||||
<DataGrid
|
||||
table={table}
|
||||
recordCount={clients.length}
|
||||
isLoading={isLoading}
|
||||
emptyMessage={emptyTitle}
|
||||
tableLayout={{ headerSticky: true, dense: true }}
|
||||
>
|
||||
<DataGridContainer>
|
||||
<DataGridTable />
|
||||
</DataGridContainer>
|
||||
<DataGridPagination
|
||||
sizes={[10, 25, 50]}
|
||||
sizesLabel="Показать"
|
||||
sizesDescription="на странице"
|
||||
info="{from}–{to} из {count}"
|
||||
rowsPerPageLabel="Строк на странице"
|
||||
previousPageLabel="Предыдущая страница"
|
||||
nextPageLabel="Следующая страница"
|
||||
/>
|
||||
</DataGrid>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
import {
|
||||
ColumnDef,
|
||||
getCoreRowModel,
|
||||
getPaginationRowModel,
|
||||
getSortedRowModel,
|
||||
useReactTable,
|
||||
} from '@tanstack/react-table'
|
||||
import { useMemo } from 'react'
|
||||
|
||||
import { Button } from '@evobgp/ui/components/button'
|
||||
|
||||
import { ConfirmDialog } from '@/components/confirm-dialog'
|
||||
import { StatusBadge } from '@/components/status-badge'
|
||||
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
||||
import { DataGridPagination } from '@/components/reui/data-grid/data-grid-pagination'
|
||||
import { DataGrid, DataGridContainer } from '@/components/reui/data-grid/data-grid'
|
||||
import { DataGridTable } from '@/components/reui/data-grid/data-grid-table'
|
||||
import { communityLabel } from '@/lib/modules/helpers'
|
||||
import type { BgpCommunity, FirewallRule } from '@/types/api'
|
||||
|
||||
export interface FirewallRulesGridProps {
|
||||
rules: FirewallRule[]
|
||||
communities: BgpCommunity[]
|
||||
isLoading?: boolean
|
||||
onDelete: (id: string) => void
|
||||
deletePending?: boolean
|
||||
emptyTitle?: string
|
||||
}
|
||||
|
||||
export function FirewallRulesGrid({
|
||||
rules,
|
||||
communities,
|
||||
isLoading = false,
|
||||
onDelete,
|
||||
deletePending = false,
|
||||
emptyTitle = 'Нет правил — blocklist пуст (default accept).',
|
||||
}: FirewallRulesGridProps) {
|
||||
const columns = useMemo<ColumnDef<FirewallRule>[]>(
|
||||
() => [
|
||||
{
|
||||
accessorKey: 'priority',
|
||||
header: ({ column }) => <DataGridColumnHeader column={column} title="#" />,
|
||||
cell: ({ row }) => row.original.priority,
|
||||
meta: { headerTitle: '#' },
|
||||
},
|
||||
{
|
||||
accessorKey: 'action',
|
||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Действие" />,
|
||||
cell: ({ row }) => (
|
||||
<StatusBadge status={row.original.action} label={row.original.action} />
|
||||
),
|
||||
meta: { headerTitle: 'Действие' },
|
||||
},
|
||||
{
|
||||
id: 'community',
|
||||
enableSorting: false,
|
||||
header: 'Community',
|
||||
cell: ({ row }) => (
|
||||
<span className="text-sm">
|
||||
{row.original.community_id
|
||||
? communityLabel(row.original.community_id, communities)
|
||||
: 'Все'}
|
||||
</span>
|
||||
),
|
||||
meta: { headerTitle: 'Community' },
|
||||
},
|
||||
{
|
||||
accessorKey: 'comment',
|
||||
enableSorting: false,
|
||||
header: 'Комментарий',
|
||||
cell: ({ row }) => row.original.comment || '—',
|
||||
meta: { headerTitle: 'Комментарий' },
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
enableSorting: false,
|
||||
header: () => null,
|
||||
cell: ({ row }) => {
|
||||
const r = row.original
|
||||
return (
|
||||
<ConfirmDialog
|
||||
trigger={
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
type="button"
|
||||
className="text-destructive"
|
||||
disabled={deletePending}
|
||||
>
|
||||
Удалить
|
||||
</Button>
|
||||
}
|
||||
title="Удалить правило?"
|
||||
description={
|
||||
r.comment
|
||||
? `Правило #${r.priority} (${r.action}): ${r.comment}`
|
||||
: `Правило #${r.priority} (${r.action}) будет удалено.`
|
||||
}
|
||||
confirmLabel="Удалить"
|
||||
destructive
|
||||
onConfirm={() => onDelete(r.id)}
|
||||
/>
|
||||
)
|
||||
},
|
||||
},
|
||||
],
|
||||
[communities, deletePending, onDelete],
|
||||
)
|
||||
|
||||
const table = useReactTable({
|
||||
data: rules,
|
||||
columns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
getPaginationRowModel: getPaginationRowModel(),
|
||||
getRowId: (row) => row.id,
|
||||
initialState: { pagination: { pageSize: 10 } },
|
||||
})
|
||||
|
||||
return (
|
||||
<DataGrid
|
||||
table={table}
|
||||
recordCount={rules.length}
|
||||
isLoading={isLoading}
|
||||
emptyMessage={emptyTitle}
|
||||
tableLayout={{ headerSticky: true, dense: true }}
|
||||
>
|
||||
<DataGridContainer>
|
||||
<DataGridTable />
|
||||
</DataGridContainer>
|
||||
<DataGridPagination
|
||||
sizes={[10, 25, 50]}
|
||||
sizesLabel="Показать"
|
||||
sizesDescription="на странице"
|
||||
info="{from}–{to} из {count}"
|
||||
rowsPerPageLabel="Строк на странице"
|
||||
previousPageLabel="Предыдущая страница"
|
||||
nextPageLabel="Следующая страница"
|
||||
/>
|
||||
</DataGrid>
|
||||
)
|
||||
}
|
||||
@@ -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,14 +1,32 @@
|
||||
import { queryOptions, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { apiJSON } from '@/lib/api-client'
|
||||
import type { FirewallClient, FirewallClientsResponse, FirewallRule, FirewallRulesResponse } from '@/types/api'
|
||||
import type {
|
||||
FirewallClient,
|
||||
FirewallClientsResponse,
|
||||
FirewallInstallContext,
|
||||
FirewallRule,
|
||||
FirewallRulesResponse,
|
||||
} from '@/types/api'
|
||||
|
||||
export const firewallKeys = {
|
||||
all: ['firewall'] as const,
|
||||
clients: () => [...firewallKeys.all, 'clients'] as const,
|
||||
installContext: () => [...firewallKeys.all, 'install-context'] as const,
|
||||
rules: (scope: string, clientId?: string) =>
|
||||
[...firewallKeys.all, 'rules', scope, clientId ?? ''] as const,
|
||||
}
|
||||
|
||||
export function firewallInstallContextQueryOptions() {
|
||||
return queryOptions<FirewallInstallContext>({
|
||||
queryKey: firewallKeys.installContext(),
|
||||
queryFn: () => apiJSON<FirewallInstallContext>('/v1/firewall/install-context'),
|
||||
staleTime: 60_000,
|
||||
retry: false,
|
||||
})
|
||||
}
|
||||
|
||||
export function firewallClientsQueryOptions() {
|
||||
return queryOptions<FirewallClientsResponse>({
|
||||
queryKey: firewallKeys.clients(),
|
||||
@@ -35,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 : 'Не удалось удалить'),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
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 { useEffect, useMemo, useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { Alert, AlertDescription, AlertTitle } from '@evobgp/ui/components/alert'
|
||||
@@ -10,47 +10,77 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@evob
|
||||
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 { FirewallClientsGrid } from '@/components/firewall/firewall-clients-grid'
|
||||
import { FirewallRulesGrid } from '@/components/firewall/firewall-rules-grid'
|
||||
import { PageHeader } from '@/components/page-header'
|
||||
import { StatusBadge } from '@/components/status-badge'
|
||||
import { CommunitySelect } from '@/components/modules/community-select'
|
||||
import { QueryState } from '@/components/query-state'
|
||||
import { TableSkeleton } from '@/components/skeletons'
|
||||
import { directoriesCommunitiesQueryOptions } from '@/queries/directories'
|
||||
import {
|
||||
firewallClientsQueryOptions,
|
||||
firewallInstallContextQueryOptions,
|
||||
firewallRulesQueryOptions,
|
||||
useApproveFirewallClient,
|
||||
useCreateFirewallRule,
|
||||
useDeleteFirewallClient,
|
||||
useDeleteFirewallRule,
|
||||
} from '@/queries/firewall'
|
||||
import type { FirewallClient } from '@/types/api'
|
||||
|
||||
function httpsOrigin(origin: string): string {
|
||||
try {
|
||||
const u = new URL(origin)
|
||||
u.protocol = 'https:'
|
||||
return u.origin
|
||||
} catch {
|
||||
return origin.replace(/^http:/i, 'https:')
|
||||
}
|
||||
}
|
||||
|
||||
export const Route = createFileRoute('/_auth/firewall')({
|
||||
component: FirewallPage,
|
||||
})
|
||||
|
||||
function FirewallPage() {
|
||||
const installCtxQ = useQuery(firewallInstallContextQueryOptions())
|
||||
const communitiesQ = useQuery(directoriesCommunitiesQueryOptions())
|
||||
const clientsQ = useQuery(firewallClientsQueryOptions())
|
||||
const rulesQ = useQuery(firewallRulesQueryOptions('tenant'))
|
||||
const approve = useApproveFirewallClient()
|
||||
const deleteClient = useDeleteFirewallClient()
|
||||
const createRule = useCreateFirewallRule()
|
||||
const deleteRule = useDeleteFirewallRule()
|
||||
|
||||
const installCtx = installCtxQ.data
|
||||
|
||||
const [clientName, setClientName] = useState('web-01')
|
||||
const [cpUrl, setCpUrl] = useState(() =>
|
||||
typeof window !== 'undefined' ? window.location.origin : 'https://api.example.com',
|
||||
typeof window !== 'undefined' ? httpsOrigin(window.location.origin) : 'https://api.example.com',
|
||||
)
|
||||
const [seed, setSeed] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
if (installCtx?.suggested_cp_url) {
|
||||
setCpUrl(httpsOrigin(installCtx.suggested_cp_url))
|
||||
}
|
||||
if (installCtx?.bundle_seed) {
|
||||
setSeed(installCtx.bundle_seed)
|
||||
}
|
||||
}, [installCtx?.bundle_seed, installCtx?.suggested_cp_url])
|
||||
const [ruleAction, setRuleAction] = useState<'block' | 'accept'>('block')
|
||||
const [ruleCommunityId, setRuleCommunityId] = useState<string | null>(null)
|
||||
const [ruleComment, setRuleComment] = useState('')
|
||||
|
||||
const clients = clientsQ.data?.items ?? []
|
||||
const pending = clients.filter((c) => c.status === 'pending')
|
||||
const communities = communitiesQ.data?.items ?? []
|
||||
|
||||
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(() => {
|
||||
@@ -64,7 +94,11 @@ function FirewallPage() {
|
||||
|
||||
async function copyInstall() {
|
||||
if (!seed.trim()) {
|
||||
toast.error('Укажите bundle seed')
|
||||
toast.error(
|
||||
installCtx?.bundle_seed_configured === false
|
||||
? 'На CP не задан EVOBGP_BUNDLE_SEED_HEX'
|
||||
: 'Bundle seed недоступен (нужна роль operator)',
|
||||
)
|
||||
return
|
||||
}
|
||||
try {
|
||||
@@ -100,8 +134,9 @@ function FirewallPage() {
|
||||
<Info className="text-info" />
|
||||
<AlertTitle>Политика</AlertTitle>
|
||||
<AlertDescription>
|
||||
Только явный <strong>block</strong> добавляет IP в blocklist. Правила <strong>accept</strong> сами по себе
|
||||
не создают block all. Default — accept.
|
||||
Правила сопоставляются с <strong>BGP community</strong> префиксов опубликованной revision.{' '}
|
||||
<strong>block</strong> добавляет префиксы community в kernel; <strong>accept</strong> — не блокирует.
|
||||
Community «Все» — правило для любого community. Default без совпадений — accept.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
@@ -128,10 +163,18 @@ function FirewallPage() {
|
||||
<Input
|
||||
id="fw-seed"
|
||||
type="password"
|
||||
readOnly
|
||||
placeholder="EVOBGP_BUNDLE_SEED_HEX"
|
||||
value={seed}
|
||||
onChange={(e) => setSeed(e.target.value)}
|
||||
className="font-mono text-xs"
|
||||
/>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{installCtxQ.isLoading
|
||||
? 'Загрузка из control plane…'
|
||||
: installCtx?.bundle_seed_configured
|
||||
? 'Из переменной EVOBGP_BUNDLE_SEED_HEX на CP (docker compose / .env)'
|
||||
: 'На CP не задан EVOBGP_BUNDLE_SEED_HEX — enroll невозможен'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<pre className="overflow-x-auto rounded-md border bg-muted/40 p-3 text-xs">{installCmd}</pre>
|
||||
@@ -144,17 +187,35 @@ 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)} />
|
||||
<QueryState
|
||||
data={clientsQ.data}
|
||||
isLoading={clientsQ.isLoading}
|
||||
isError={clientsQ.isError}
|
||||
error={clientsQ.error}
|
||||
onRetry={() => void clientsQ.refetch()}
|
||||
skeleton={<TableSkeleton rows={5} cols={6} />}
|
||||
>
|
||||
{() => (
|
||||
<FirewallClientsGrid
|
||||
clients={activeClients}
|
||||
isLoading={clientsQ.isFetching && !clientsQ.isLoading}
|
||||
onApprove={(id) => approve.mutate(id)}
|
||||
onReject={(id) => deleteClient.mutate(id)}
|
||||
approvePending={approve.isPending}
|
||||
rejectPending={deleteClient.isPending}
|
||||
/>
|
||||
)}
|
||||
</QueryState>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="rules" className="mt-4 space-y-4">
|
||||
<div className="flex flex-wrap items-end gap-2">
|
||||
<div className="flex flex-wrap items-end gap-3">
|
||||
<div className="space-y-1">
|
||||
<Label>Действие</Label>
|
||||
<select
|
||||
@@ -166,18 +227,33 @@ function FirewallPage() {
|
||||
<option value="accept">accept</option>
|
||||
</select>
|
||||
</div>
|
||||
<Input
|
||||
className="max-w-xs"
|
||||
placeholder="Комментарий"
|
||||
value={ruleComment}
|
||||
onChange={(e) => setRuleComment(e.target.value)}
|
||||
<CommunitySelect
|
||||
id="fw-rule-community"
|
||||
label="Community"
|
||||
value={ruleCommunityId}
|
||||
onValueChange={setRuleCommunityId}
|
||||
communities={communities}
|
||||
nullable
|
||||
placeholder="Все communities"
|
||||
/>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="fw-rule-comment">Комментарий</Label>
|
||||
<Input
|
||||
id="fw-rule-comment"
|
||||
className="max-w-xs"
|
||||
placeholder="Комментарий"
|
||||
value={ruleComment}
|
||||
onChange={(e) => setRuleComment(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
className="mb-0.5"
|
||||
onClick={() =>
|
||||
createRule.mutate({
|
||||
scope: 'tenant',
|
||||
action: ruleAction,
|
||||
community_id: ruleCommunityId,
|
||||
comment: ruleComment,
|
||||
})
|
||||
}
|
||||
@@ -185,109 +261,49 @@ function FirewallPage() {
|
||||
Добавить правило
|
||||
</Button>
|
||||
</div>
|
||||
<RulesTable rules={rules} onDelete={(id) => deleteRule.mutate(id)} />
|
||||
<QueryState
|
||||
data={rulesQ.data}
|
||||
isLoading={rulesQ.isLoading}
|
||||
isError={rulesQ.isError}
|
||||
error={rulesQ.error}
|
||||
onRetry={() => void rulesQ.refetch()}
|
||||
skeleton={<TableSkeleton rows={5} cols={5} />}
|
||||
>
|
||||
{() => (
|
||||
<FirewallRulesGrid
|
||||
rules={rules}
|
||||
communities={communities}
|
||||
isLoading={rulesQ.isFetching && !rulesQ.isLoading}
|
||||
onDelete={(id) => deleteRule.mutate(id)}
|
||||
deletePending={deleteRule.isPending}
|
||||
/>
|
||||
)}
|
||||
</QueryState>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="requests" className="mt-4">
|
||||
<ClientsTable
|
||||
clients={pending}
|
||||
onApprove={(id) => approve.mutate(id)}
|
||||
emptyTitle="Нет pending-запросов"
|
||||
/>
|
||||
<QueryState
|
||||
data={clientsQ.data}
|
||||
isLoading={clientsQ.isLoading}
|
||||
isError={clientsQ.isError}
|
||||
error={clientsQ.error}
|
||||
onRetry={() => void clientsQ.refetch()}
|
||||
skeleton={<TableSkeleton rows={3} cols={6} />}
|
||||
>
|
||||
{() => (
|
||||
<FirewallClientsGrid
|
||||
clients={pending}
|
||||
isLoading={clientsQ.isFetching && !clientsQ.isLoading}
|
||||
onApprove={(id) => approve.mutate(id)}
|
||||
onReject={(id) => deleteClient.mutate(id)}
|
||||
approvePending={approve.isPending}
|
||||
rejectPending={deleteClient.isPending}
|
||||
emptyTitle="Нет pending-запросов"
|
||||
/>
|
||||
)}
|
||||
</QueryState>
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -373,3 +375,10 @@ export type FirewallRule = {
|
||||
}
|
||||
|
||||
export type FirewallRulesResponse = { items: FirewallRule[] }
|
||||
|
||||
export type FirewallInstallContext = {
|
||||
bundle_seed: string
|
||||
bundle_seed_configured: boolean
|
||||
suggested_cp_url: string
|
||||
install_sh_url: string
|
||||
}
|
||||
|
||||
@@ -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/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"}
|
||||
{"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/firewall/firewall-clients-grid.tsx","./src/components/firewall/firewall-rules-grid.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"}
|
||||
@@ -159,10 +159,18 @@ services:
|
||||
condition: service_started
|
||||
labels:
|
||||
- traefik.enable=true
|
||||
# Публичные firewall-эндпоинты — без WEBUI_IP_WHITELIST (установка с произвольных серверов).
|
||||
- traefik.http.routers.evobgp-firewall-public.rule=Host(`${WEBUI_DOMAIN}`) && (Path(`/v1/firewall/install.sh`) || Path(`/v1/firewall/sync-script`) || PathPrefix(`/v1/firewall/enroll`))
|
||||
- traefik.http.routers.evobgp-firewall-public.entrypoints=websecure
|
||||
- traefik.http.routers.evobgp-firewall-public.tls=true
|
||||
- traefik.http.routers.evobgp-firewall-public.tls.certresolver=letsencrypt
|
||||
- traefik.http.routers.evobgp-firewall-public.priority=100
|
||||
- traefik.http.routers.evobgp-firewall-public.service=evobgp-web
|
||||
- traefik.http.routers.evobgp-web.rule=Host(`${WEBUI_DOMAIN}`)
|
||||
- traefik.http.routers.evobgp-web.entrypoints=websecure
|
||||
- traefik.http.routers.evobgp-web.tls=true
|
||||
- traefik.http.routers.evobgp-web.tls.certresolver=letsencrypt
|
||||
- traefik.http.routers.evobgp-web.priority=10
|
||||
- traefik.http.routers.evobgp-web.middlewares=webui-ipwhitelist@docker
|
||||
- traefik.http.middlewares.webui-ipwhitelist.ipallowlist.sourcerange=${WEBUI_IP_WHITELIST}
|
||||
- traefik.http.services.evobgp-web.loadbalancer.server.port=80
|
||||
|
||||
@@ -247,10 +247,18 @@ services:
|
||||
- evobgp-all
|
||||
labels:
|
||||
- traefik.enable=true
|
||||
# Публичные firewall-эндпоинты — без WEBUI_IP_WHITELIST (установка с произвольных серверов).
|
||||
- traefik.http.routers.evobgp-firewall-public.rule=Host(`${WEBUI_DOMAIN}`) && (Path(`/v1/firewall/install.sh`) || Path(`/v1/firewall/sync-script`) || PathPrefix(`/v1/firewall/enroll`))
|
||||
- traefik.http.routers.evobgp-firewall-public.entrypoints=websecure
|
||||
- traefik.http.routers.evobgp-firewall-public.tls=true
|
||||
- traefik.http.routers.evobgp-firewall-public.tls.certresolver=letsencrypt
|
||||
- traefik.http.routers.evobgp-firewall-public.priority=100
|
||||
- traefik.http.routers.evobgp-firewall-public.service=evobgp-web
|
||||
- traefik.http.routers.evobgp-web.rule=Host(`${WEBUI_DOMAIN}`)
|
||||
- traefik.http.routers.evobgp-web.entrypoints=websecure
|
||||
- traefik.http.routers.evobgp-web.tls=true
|
||||
- traefik.http.routers.evobgp-web.tls.certresolver=letsencrypt
|
||||
- traefik.http.routers.evobgp-web.priority=10
|
||||
- traefik.http.routers.evobgp-web.middlewares=webui-ipwhitelist@docker
|
||||
- traefik.http.middlewares.webui-ipwhitelist.ipallowlist.sourcerange=${WEBUI_IP_WHITELIST}
|
||||
- traefik.http.services.evobgp-web.loadbalancer.server.port=80
|
||||
|
||||
@@ -162,10 +162,18 @@ services:
|
||||
condition: service_started
|
||||
labels:
|
||||
- traefik.enable=true
|
||||
# Публичные firewall-эндпоинты — без WEBUI_IP_WHITELIST (установка с произвольных серверов).
|
||||
- traefik.http.routers.evobgp-firewall-public.rule=Host(`${WEBUI_DOMAIN}`) && (Path(`/v1/firewall/install.sh`) || Path(`/v1/firewall/sync-script`) || PathPrefix(`/v1/firewall/enroll`))
|
||||
- traefik.http.routers.evobgp-firewall-public.entrypoints=websecure
|
||||
- traefik.http.routers.evobgp-firewall-public.tls=true
|
||||
- traefik.http.routers.evobgp-firewall-public.tls.certresolver=letsencrypt
|
||||
- traefik.http.routers.evobgp-firewall-public.priority=100
|
||||
- traefik.http.routers.evobgp-firewall-public.service=evobgp-web
|
||||
- traefik.http.routers.evobgp-web.rule=Host(`${WEBUI_DOMAIN}`)
|
||||
- traefik.http.routers.evobgp-web.entrypoints=websecure
|
||||
- traefik.http.routers.evobgp-web.tls=true
|
||||
- traefik.http.routers.evobgp-web.tls.certresolver=letsencrypt
|
||||
- traefik.http.routers.evobgp-web.priority=10
|
||||
- traefik.http.routers.evobgp-web.middlewares=webui-ipwhitelist@docker
|
||||
- traefik.http.middlewares.webui-ipwhitelist.ipallowlist.sourcerange=${WEBUI_IP_WHITELIST}
|
||||
- traefik.http.services.evobgp-web.loadbalancer.server.port=80
|
||||
|
||||
@@ -17,7 +17,7 @@ server {
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Proto $http_x_forwarded_proto;
|
||||
}
|
||||
|
||||
location = /metrics {
|
||||
|
||||
@@ -58,6 +58,8 @@ RUN apt-get update \
|
||||
FROM runtime-base AS runtime
|
||||
ARG BIN=evobgp-api
|
||||
COPY --from=build-all /out/${BIN} /usr/local/bin/evobgp
|
||||
COPY scripts/firewall /opt/evobgp/scripts/firewall
|
||||
ENV EVOBGP_FIREWALL_SCRIPTS=/opt/evobgp/scripts/firewall
|
||||
EXPOSE 8080
|
||||
ENTRYPOINT ["/usr/local/bin/evobgp"]
|
||||
|
||||
|
||||
+20
-3
@@ -10,14 +10,21 @@
|
||||
|
||||
## Политика block/accept
|
||||
|
||||
- **`block`** — добавить префиксы community в kernel blocklist.
|
||||
- **`accept`** — не блокировать.
|
||||
- **`block`** — добавить префиксы выбранного BGP community в kernel blocklist.
|
||||
- **`accept`** — не блокировать префиксы этого community.
|
||||
- **Community** — правило применяется к префиксам с этим `community_id` в опубликованной revision; пустое значение («Все») — ко всем communities.
|
||||
- **Default** — accept (пустой blocklist без явных `block`).
|
||||
|
||||
Правила задаются на уровне tenant (по умолчанию) и per-server (overrides клиента). Client scope проверяется раньше tenant-default.
|
||||
Порядок: сначала per-server overrides клиента, затем tenant-default. Для каждого community берётся первое подходящее правило по приоритету.
|
||||
|
||||
Справочник communities: Web UI → Справочники, или модули с привязкой community к префиксам.
|
||||
|
||||
## Установка на сервер
|
||||
|
||||
Публичные 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> \
|
||||
@@ -28,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-домен.
|
||||
|
||||
@@ -1605,9 +1605,33 @@ 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
|
||||
|
||||
FirewallInstallContext:
|
||||
type: object
|
||||
description: Контекст для one-liner установки firewall-клиента (только operator).
|
||||
properties:
|
||||
bundle_seed:
|
||||
type: string
|
||||
description: Значение EVOBGP_BUNDLE_SEED_HEX на control plane.
|
||||
bundle_seed_configured:
|
||||
type: boolean
|
||||
suggested_cp_url:
|
||||
type: string
|
||||
format: uri
|
||||
install_sh_url:
|
||||
type: string
|
||||
format: uri
|
||||
|
||||
FirewallRule:
|
||||
type: object
|
||||
properties:
|
||||
@@ -4364,6 +4388,23 @@ paths:
|
||||
default:
|
||||
$ref: "#/components/responses/DefaultProblem"
|
||||
|
||||
/v1/firewall/install-context:
|
||||
get:
|
||||
tags: [Firewall]
|
||||
summary: Install context for firewall one-liner (operator)
|
||||
operationId: getFirewallInstallContext
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/FirewallInstallContext"
|
||||
"403":
|
||||
$ref: "#/components/responses/Forbidden"
|
||||
default:
|
||||
$ref: "#/components/responses/DefaultProblem"
|
||||
|
||||
/v1/firewall/enroll:
|
||||
post:
|
||||
tags: [Firewall]
|
||||
@@ -4439,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]
|
||||
@@ -4489,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
|
||||
|
||||
@@ -153,6 +153,7 @@ docker compose --env-file .env --env-file .env.web-sec --profile microvps-full u
|
||||
- `http://<WEBUI_DOMAIN>` должен редиректить на `https://<WEBUI_DOMAIN>`;
|
||||
- с IP из `WEBUI_IP_WHITELIST` UI доступен по HTTPS;
|
||||
- с неразрешенного IP Traefik вернет `403`.
|
||||
- исключение: `GET /v1/firewall/install.sh`, `GET /v1/firewall/sync-script`, `POST /v1/firewall/enroll` — публичные, без whitelist (см. [firewall.md](firewall.md)).
|
||||
|
||||
Health API: `http://<IP>:8080/v1/health`.
|
||||
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
// Package firewallscripts embeds bash installers served by GET /v1/firewall/install.sh.
|
||||
// Источник правды — scripts/firewall/; при изменении скопируйте файлы сюда или запустите:
|
||||
//
|
||||
// go generate ./internal/firewallscripts/...
|
||||
package firewallscripts
|
||||
|
||||
import "embed"
|
||||
|
||||
//go:embed install.sh evobgp-firewall.sh uninstall.sh
|
||||
var FS embed.FS
|
||||
@@ -0,0 +1,365 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
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"; }
|
||||
|
||||
if [[ ! -f "$CONF_FILE" ]]; then
|
||||
log "missing $CONF_FILE"
|
||||
exit 1
|
||||
fi
|
||||
# shellcheck disable=SC1090
|
||||
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_file() {
|
||||
local url="$1"
|
||||
local dest="$2"
|
||||
local 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"
|
||||
return 2
|
||||
fi
|
||||
if [[ "$code" != "200" ]]; then
|
||||
log "blocklist HTTP $code from $url"
|
||||
return 1
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
|
||||
try_fetch_blocklist() {
|
||||
local urls=()
|
||||
if [[ -n "${EVOBGP_FAILOVER_URLS:-}" ]]; then
|
||||
IFS=',' read -r -a urls <<<"$EVOBGP_FAILOVER_URLS"
|
||||
else
|
||||
urls=("${EVOBGP_CP_URL%/}")
|
||||
fi
|
||||
local u
|
||||
for u in "${urls[@]}"; do
|
||||
u="${u// /}"
|
||||
u="${u%/}"
|
||||
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
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
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 flush set "$table" "$name" v4
|
||||
|
||||
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 ((${#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; 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
|
||||
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() {
|
||||
case "$BACKEND" in
|
||||
nft) nft delete table inet evobgp_blocklist 2>/dev/null || true ;;
|
||||
ipset)
|
||||
ipset destroy evobgp_blocklist_v4 2>/dev/null || true
|
||||
iptables -D INPUT -m set --match-set evobgp_blocklist_v4 src -j DROP 2>/dev/null || true
|
||||
;;
|
||||
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}"
|
||||
}
|
||||
|
||||
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 ;;
|
||||
ipset) apply_ipset ;;
|
||||
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"
|
||||
send_client_reports
|
||||
@@ -0,0 +1,129 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
if [[ "${EUID:-$(id -u)}" -ne 0 ]]; then
|
||||
echo "evobgp-firewall install: run as root" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
for cmd in curl bash; do
|
||||
command -v "$cmd" >/dev/null 2>&1 || { echo "missing $cmd" >&2; exit 1; }
|
||||
done
|
||||
|
||||
if ! command -v jq >/dev/null 2>&1 && ! command -v python3 >/dev/null 2>&1; then
|
||||
if command -v apt-get >/dev/null 2>&1; then
|
||||
apt-get update -qq && apt-get install -y -qq jq
|
||||
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}"
|
||||
|
||||
CONF_DIR=/etc/evobgp
|
||||
CONF_FILE="${CONF_DIR}/firewall.conf"
|
||||
SYNC_SCRIPT=/usr/local/sbin/evobgp-firewall.sh
|
||||
|
||||
if [[ -f "$CONF_FILE" && "${EVOBGP_INSTALL_FORCE:-}" != "1" ]]; then
|
||||
echo "Already installed ($CONF_FILE). Set EVOBGP_INSTALL_FORCE=1 to reinstall." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
gen_token() {
|
||||
if command -v openssl >/dev/null 2>&1; then
|
||||
echo -n "evobgp_fw_$(openssl rand -base64 32 | tr '+/' '-_' | tr -d '=')"
|
||||
else
|
||||
echo -n "evobgp_fw_$(head -c 32 /dev/urandom | base64 | tr '+/' '-_' | tr -d '=\n')"
|
||||
fi
|
||||
}
|
||||
|
||||
CLIENT_TOKEN="$(gen_token)"
|
||||
HOSTNAME="$(hostname -f 2>/dev/null || hostname)"
|
||||
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")
|
||||
|
||||
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
|
||||
CLIENT_ID=$(echo "$RESP" | jq -r '.client_id')
|
||||
else
|
||||
CLIENT_ID=$(echo "$RESP" | sed -n 's/.*"client_id"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p')
|
||||
fi
|
||||
|
||||
mkdir -p "$CONF_DIR"
|
||||
chmod 700 "$CONF_DIR"
|
||||
cat >"$CONF_FILE" <<EOF
|
||||
EVOBGP_CP_URL=${CP_URL}
|
||||
CLIENT_ID=${CLIENT_ID}
|
||||
CLIENT_TOKEN=${CLIENT_TOKEN}
|
||||
CLIENT_NAME=${EVOBGP_CLIENT_NAME}
|
||||
KERNEL_BACKEND=auto
|
||||
EOF
|
||||
chmod 600 "$CONF_FILE"
|
||||
|
||||
curl -fsSL "${CP_URL}/v1/firewall/sync-script" -o "$SYNC_SCRIPT"
|
||||
chmod 755 "$SYNC_SCRIPT"
|
||||
|
||||
if command -v nft >/dev/null 2>&1; then
|
||||
BACKEND=nft
|
||||
elif command -v ipset >/dev/null 2>&1 && command -v iptables >/dev/null 2>&1; then
|
||||
BACKEND=ipset
|
||||
elif command -v iptables >/dev/null 2>&1; then
|
||||
BACKEND=iptables
|
||||
else
|
||||
echo "no supported firewall backend (nft/ipset/iptables)" >&2
|
||||
exit 1
|
||||
fi
|
||||
sed -i "s/^KERNEL_BACKEND=.*/KERNEL_BACKEND=${BACKEND}/" "$CONF_FILE" 2>/dev/null || \
|
||||
echo "KERNEL_BACKEND=${BACKEND}" >>"$CONF_FILE"
|
||||
|
||||
INTERVAL="${EVOBGP_SYNC_INTERVAL:-5min}"
|
||||
if command -v systemctl >/dev/null 2>&1; then
|
||||
cat >/etc/systemd/system/evobgp-firewall.service <<'UNIT'
|
||||
[Unit]
|
||||
Description=EvoBGP firewall blocklist sync
|
||||
After=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
ExecStart=/usr/local/sbin/evobgp-firewall.sh
|
||||
UNIT
|
||||
cat >/etc/systemd/system/evobgp-firewall.timer <<UNIT
|
||||
[Unit]
|
||||
Description=EvoBGP firewall sync timer
|
||||
|
||||
[Timer]
|
||||
OnBootSec=2min
|
||||
OnUnitActiveSec=${INTERVAL}
|
||||
Unit=evobgp-firewall.service
|
||||
|
||||
[Install]
|
||||
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
|
||||
|
||||
echo "Client ID: ${CLIENT_ID}"
|
||||
echo "Status: pending — approve in EvoBGP UI → Firewall → Запросы"
|
||||
@@ -0,0 +1,34 @@
|
||||
package firewallscripts
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestScriptsMatchRepoSource(t *testing.T) {
|
||||
root := filepath.Join("..", "..", "scripts", "firewall")
|
||||
for _, name := range []string{"install.sh", "evobgp-firewall.sh", "uninstall.sh"} {
|
||||
embedded, err := FS.ReadFile(name)
|
||||
if err != nil {
|
||||
t.Fatalf("embedded %s: %v", name, err)
|
||||
}
|
||||
source, err := os.ReadFile(filepath.Join(root, name))
|
||||
if err != nil {
|
||||
t.Skipf("source %s not found (cwd=%s): %v", name, mustWd(t), err)
|
||||
}
|
||||
if !bytes.Equal(embedded, source) {
|
||||
t.Fatalf("%s drift: copy scripts/firewall/%s to internal/firewallscripts/", name, name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func mustWd(t *testing.T) string {
|
||||
t.Helper()
|
||||
wd, err := os.Getwd()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return wd
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
systemctl disable --now evobgp-firewall.timer 2>/dev/null || true
|
||||
rm -f /etc/cron.d/evobgp-firewall
|
||||
rm -f /etc/systemd/system/evobgp-firewall.service /etc/systemd/system/evobgp-firewall.timer
|
||||
systemctl daemon-reload 2>/dev/null || true
|
||||
|
||||
nft delete table inet evobgp_blocklist 2>/dev/null || true
|
||||
ipset destroy evobgp_blocklist_v4 2>/dev/null || true
|
||||
iptables -D INPUT -m set --match-set evobgp_blocklist_v4 src -j DROP 2>/dev/null || true
|
||||
|
||||
rm -f /usr/local/sbin/evobgp-firewall.sh /usr/local/sbin/evobgp-firewall-uninstall.sh
|
||||
rm -rf /var/lib/evobgp-firewall
|
||||
if [[ "${EVOBGP_UNINSTALL_REMOVE_CONF:-}" == "1" ]]; then
|
||||
rm -f /etc/evobgp/firewall.conf
|
||||
fi
|
||||
|
||||
echo "evobgp-firewall uninstalled"
|
||||
@@ -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") {
|
||||
|
||||
@@ -16,10 +16,12 @@ import (
|
||||
|
||||
"evobgp/internal/authkey"
|
||||
"evobgp/internal/firewall"
|
||||
"evobgp/internal/firewallscripts"
|
||||
"evobgp/internal/store"
|
||||
)
|
||||
|
||||
func (s *Server) registerFirewallRoutes(m *http.ServeMux) {
|
||||
m.HandleFunc("GET /firewall/install-context", s.handleFirewallInstallContext)
|
||||
m.HandleFunc("GET /firewall/clients", s.handleListFirewallClients)
|
||||
m.HandleFunc("GET /firewall/clients/{id}", s.handleGetFirewallClient)
|
||||
m.HandleFunc("GET /firewall/clients/{id}/preview", s.handleFirewallClientPreview)
|
||||
@@ -39,6 +41,36 @@ func (s *Server) registerFirewallRoutes(m *http.ServeMux) {
|
||||
m.HandleFunc("POST /firewall/heartbeat", s.handleFirewallHeartbeat)
|
||||
}
|
||||
|
||||
func (s *Server) handleFirewallInstallContext(w http.ResponseWriter, r *http.Request) {
|
||||
a, ok := authFromContext(r.Context())
|
||||
if !ok || !s.requireAtLeast(w, a, "operator") {
|
||||
return
|
||||
}
|
||||
seed := strings.TrimSpace(s.bundleSeedHex)
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"bundle_seed": seed,
|
||||
"bundle_seed_configured": seed != "",
|
||||
"suggested_cp_url": publicHTTPSBaseURL(r),
|
||||
"install_sh_url": publicHTTPSBaseURL(r) + "/v1/firewall/install.sh",
|
||||
})
|
||||
}
|
||||
|
||||
// publicHTTPSBaseURL is the external HTTPS origin for firewall install/enroll links.
|
||||
func publicHTTPSBaseURL(r *http.Request) string {
|
||||
host := strings.TrimSpace(r.Host)
|
||||
if xf := strings.TrimSpace(r.Header.Get("X-Forwarded-Host")); xf != "" {
|
||||
host = strings.TrimSpace(strings.Split(xf, ",")[0])
|
||||
}
|
||||
if host == "" {
|
||||
return ""
|
||||
}
|
||||
return "https://" + host
|
||||
}
|
||||
|
||||
func requestBaseURL(r *http.Request) string {
|
||||
return publicHTTPSBaseURL(r)
|
||||
}
|
||||
|
||||
func (s *Server) handleFirewallEnrollPublic(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
writeProblem(w, http.StatusMethodNotAllowed, "Method Not Allowed", "POST required")
|
||||
@@ -91,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{
|
||||
@@ -125,8 +157,7 @@ func (s *Server) handleFirewallSyncScript(w http.ResponseWriter, r *http.Request
|
||||
}
|
||||
|
||||
func (s *Server) serveFirewallScript(w http.ResponseWriter, name string) {
|
||||
path := filepath.Join("scripts", "firewall", name)
|
||||
b, err := os.ReadFile(path)
|
||||
b, err := readFirewallScript(name)
|
||||
if err != nil {
|
||||
writeProblem(w, http.StatusNotFound, "Not Found", "script not found")
|
||||
return
|
||||
@@ -136,6 +167,24 @@ func (s *Server) serveFirewallScript(w http.ResponseWriter, name string) {
|
||||
_, _ = w.Write(b)
|
||||
}
|
||||
|
||||
func readFirewallScript(name string) ([]byte, error) {
|
||||
if b, err := firewallscripts.FS.ReadFile(name); err == nil {
|
||||
return b, nil
|
||||
}
|
||||
candidates := []string{}
|
||||
if dir := strings.TrimSpace(os.Getenv("EVOBGP_FIREWALL_SCRIPTS")); dir != "" {
|
||||
candidates = append(candidates, filepath.Join(dir, name))
|
||||
}
|
||||
candidates = append(candidates, filepath.Join("scripts", "firewall", name))
|
||||
for _, p := range candidates {
|
||||
b, err := os.ReadFile(p)
|
||||
if err == nil {
|
||||
return b, nil
|
||||
}
|
||||
}
|
||||
return nil, os.ErrNotExist
|
||||
}
|
||||
|
||||
func (s *Server) handleListFirewallClients(w http.ResponseWriter, r *http.Request) {
|
||||
a, ok := authFromContext(r.Context())
|
||||
if !ok || !s.requireAtLeast(w, a, "viewer") {
|
||||
@@ -401,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")
|
||||
@@ -417,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})
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
@@ -119,6 +141,160 @@ func TestFirewallEnrollBadSeed(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestFirewallInstallScriptPublic(t *testing.T) {
|
||||
srv, err := New(Options{SeedDemo: true, BundleSeedHex: testBundleSeed})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer srv.Close()
|
||||
ts := httptest.NewServer(srv.Handler())
|
||||
defer ts.Close()
|
||||
|
||||
for _, path := range []string{"/v1/firewall/install.sh", "/v1/firewall/sync-script"} {
|
||||
req, _ := http.NewRequest(http.MethodGet, ts.URL+path, nil)
|
||||
resp, err := ts.Client().Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
func() {
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
t.Fatalf("%s status=%d body=%s", path, resp.StatusCode, b)
|
||||
}
|
||||
ct := resp.Header.Get("Content-Type")
|
||||
if !strings.Contains(ct, "shellscript") {
|
||||
t.Fatalf("%s content-type=%q", path, ct)
|
||||
}
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
if !strings.HasPrefix(string(b), "#!/") {
|
||||
t.Fatalf("%s missing shebang", path)
|
||||
}
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
||||
func TestFirewallInstallContext(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,vwkey|"+tenant+"|viewer")
|
||||
|
||||
ts := httptest.NewServer(srv.Handler())
|
||||
defer ts.Close()
|
||||
client := ts.Client()
|
||||
|
||||
reqOp, _ := http.NewRequest(http.MethodGet, ts.URL+"/v1/firewall/install-context", nil)
|
||||
reqOp.Header.Set("Authorization", "Bearer opkey")
|
||||
respOp, err := client.Do(reqOp)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() { _ = respOp.Body.Close() }()
|
||||
if respOp.StatusCode != http.StatusOK {
|
||||
b, _ := io.ReadAll(respOp.Body)
|
||||
t.Fatalf("operator install-context status=%d body=%s", respOp.StatusCode, b)
|
||||
}
|
||||
var ctx map[string]any
|
||||
if err := json.NewDecoder(respOp.Body).Decode(&ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if seed, _ := ctx["bundle_seed"].(string); seed != testBundleSeed {
|
||||
t.Fatalf("bundle_seed=%q want %q", seed, testBundleSeed)
|
||||
}
|
||||
if configured, _ := ctx["bundle_seed_configured"].(bool); !configured {
|
||||
t.Fatal("bundle_seed_configured want true")
|
||||
}
|
||||
if url, _ := ctx["suggested_cp_url"].(string); !strings.HasPrefix(url, "https://") {
|
||||
t.Fatalf("suggested_cp_url=%q want https", url)
|
||||
}
|
||||
if url, _ := ctx["install_sh_url"].(string); !strings.HasPrefix(url, "https://") || !strings.HasSuffix(url, "/v1/firewall/install.sh") {
|
||||
t.Fatalf("install_sh_url=%q", url)
|
||||
}
|
||||
|
||||
reqVw, _ := http.NewRequest(http.MethodGet, ts.URL+"/v1/firewall/install-context", nil)
|
||||
reqVw.Header.Set("Authorization", "Bearer vwkey")
|
||||
respVw, err := client.Do(reqVw)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() { _ = respVw.Body.Close() }()
|
||||
if respVw.StatusCode != http.StatusForbidden {
|
||||
t.Fatalf("viewer install-context want 403 got %d", respVw.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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;
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user