Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c1132cbe19 |
@@ -1,195 +0,0 @@
|
|||||||
import { ColumnDef } from '@tanstack/react-table'
|
|
||||||
import { useMemo } from 'react'
|
|
||||||
|
|
||||||
import { Button } from '@evobgp/ui/components/button'
|
|
||||||
|
|
||||||
import { DataGridMutedCell, DataGridPrimaryCell } from '@/components/data-grid-cell'
|
|
||||||
import { DataGridSection } from '@/components/data-grid-shell'
|
|
||||||
import { ConfirmDialog } from '@/components/confirm-dialog'
|
|
||||||
import { StatusBadge } from '@/components/status-badge'
|
|
||||||
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
|
||||||
import { useClientDataGrid } from '@/hooks/use-client-data-grid'
|
|
||||||
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 }) => (
|
|
||||||
<DataGridPrimaryCell
|
|
||||||
title={row.original.name}
|
|
||||||
subtitle={row.original.hostname || row.original.token_prefix}
|
|
||||||
accent="primary"
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
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="Последняя активность" />,
|
|
||||||
cell: ({ row }) => (
|
|
||||||
<DataGridMutedCell>{row.original.last_seen_at?.slice(0, 19) ?? '—'}</DataGridMutedCell>
|
|
||||||
),
|
|
||||||
sortingFn: (a, b) => {
|
|
||||||
const av = a.original.last_seen_at ?? ''
|
|
||||||
const bv = b.original.last_seen_at ?? ''
|
|
||||||
return av.localeCompare(bv)
|
|
||||||
},
|
|
||||||
meta: { headerTitle: 'Последняя активность' },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'apply',
|
|
||||||
enableSorting: false,
|
|
||||||
header: 'Применение',
|
|
||||||
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: 'Применение' },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
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, globalFilter, setGlobalFilter, filteredCount } = useClientDataGrid({
|
|
||||||
data: clients,
|
|
||||||
columns,
|
|
||||||
getSearchText: (row) =>
|
|
||||||
`${row.name} ${row.hostname ?? ''} ${row.token_prefix} ${row.status ?? ''}`,
|
|
||||||
getRowId: (row) => row.id,
|
|
||||||
})
|
|
||||||
|
|
||||||
return (
|
|
||||||
<DataGridSection
|
|
||||||
table={table}
|
|
||||||
recordCount={filteredCount}
|
|
||||||
isLoading={isLoading}
|
|
||||||
emptyMessage={emptyTitle}
|
|
||||||
searchValue={globalFilter}
|
|
||||||
onSearchChange={setGlobalFilter}
|
|
||||||
searchPlaceholder="Поиск клиентов…"
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,101 +0,0 @@
|
|||||||
import { useEffect, useState } from 'react'
|
|
||||||
|
|
||||||
import { Button } from '@evobgp/ui/components/button'
|
|
||||||
import { Input } from '@evobgp/ui/components/input'
|
|
||||||
import { Label } from '@evobgp/ui/components/label'
|
|
||||||
|
|
||||||
import { FormDrawer } from '@/components/form-drawer'
|
|
||||||
import { LoadingButton } from '@/components/loading-button'
|
|
||||||
import { CommunitySelect } from '@/components/modules/community-select'
|
|
||||||
import { SelectField } from '@/components/select-field'
|
|
||||||
import { useCreateFirewallRule } from '@/queries/firewall'
|
|
||||||
import type { BgpCommunity } from '@/types/api'
|
|
||||||
|
|
||||||
const FIREWALL_ACTION_ITEMS = [
|
|
||||||
{ value: 'block', label: 'block' },
|
|
||||||
{ value: 'accept', label: 'accept' },
|
|
||||||
] as const
|
|
||||||
|
|
||||||
interface FirewallRuleCreateDialogProps {
|
|
||||||
open: boolean
|
|
||||||
onOpenChange: (open: boolean) => void
|
|
||||||
communities: BgpCommunity[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export function FirewallRuleCreateDialog({
|
|
||||||
open,
|
|
||||||
onOpenChange,
|
|
||||||
communities,
|
|
||||||
}: FirewallRuleCreateDialogProps) {
|
|
||||||
const createMutation = useCreateFirewallRule()
|
|
||||||
const [action, setAction] = useState<'block' | 'accept'>('block')
|
|
||||||
const [communityId, setCommunityId] = useState<string | null>(null)
|
|
||||||
const [comment, setComment] = useState('')
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!open) return
|
|
||||||
setAction('block')
|
|
||||||
setCommunityId(null)
|
|
||||||
setComment('')
|
|
||||||
}, [open])
|
|
||||||
|
|
||||||
async function save() {
|
|
||||||
try {
|
|
||||||
await createMutation.mutateAsync({
|
|
||||||
scope: 'tenant',
|
|
||||||
action,
|
|
||||||
community_id: communityId,
|
|
||||||
comment: comment.trim(),
|
|
||||||
})
|
|
||||||
onOpenChange(false)
|
|
||||||
} catch {
|
|
||||||
// toast handled in mutation
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<FormDrawer
|
|
||||||
open={open}
|
|
||||||
onOpenChange={onOpenChange}
|
|
||||||
title="Новое правило"
|
|
||||||
className="sm:max-w-md"
|
|
||||||
footer={
|
|
||||||
<>
|
|
||||||
<Button variant="outline" type="button" onClick={() => onOpenChange(false)}>
|
|
||||||
Отмена
|
|
||||||
</Button>
|
|
||||||
<LoadingButton type="button" onClick={save} loading={createMutation.isPending}>
|
|
||||||
Добавить
|
|
||||||
</LoadingButton>
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<SelectField
|
|
||||||
id="fw-rule-action"
|
|
||||||
label="Действие"
|
|
||||||
items={[...FIREWALL_ACTION_ITEMS]}
|
|
||||||
value={action}
|
|
||||||
placeholder="Выберите действие"
|
|
||||||
onValueChange={(v) => v && setAction(v as 'block' | 'accept')}
|
|
||||||
/>
|
|
||||||
<CommunitySelect
|
|
||||||
id="fw-rule-community"
|
|
||||||
label="Community"
|
|
||||||
value={communityId}
|
|
||||||
onValueChange={setCommunityId}
|
|
||||||
communities={communities}
|
|
||||||
nullable
|
|
||||||
placeholder="Все communities"
|
|
||||||
/>
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
<Label htmlFor="fw-rule-comment">Комментарий</Label>
|
|
||||||
<Input
|
|
||||||
id="fw-rule-comment"
|
|
||||||
placeholder="Комментарий"
|
|
||||||
value={comment}
|
|
||||||
onChange={(e) => setComment(e.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</FormDrawer>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,122 +0,0 @@
|
|||||||
import { ColumnDef } from '@tanstack/react-table'
|
|
||||||
import { useMemo } from 'react'
|
|
||||||
|
|
||||||
import { Button } from '@evobgp/ui/components/button'
|
|
||||||
|
|
||||||
import { DataGridSection } from '@/components/data-grid-shell'
|
|
||||||
import { ConfirmDialog } from '@/components/confirm-dialog'
|
|
||||||
import { StatusBadge } from '@/components/status-badge'
|
|
||||||
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
|
||||||
import { useClientDataGrid } from '@/hooks/use-client-data-grid'
|
|
||||||
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, globalFilter, setGlobalFilter, filteredCount } = useClientDataGrid({
|
|
||||||
data: rules,
|
|
||||||
columns,
|
|
||||||
getSearchText: (row) =>
|
|
||||||
`${row.priority} ${row.action} ${row.comment ?? ''} ${communityLabel(row.community_id, communities)}`,
|
|
||||||
getRowId: (row) => row.id,
|
|
||||||
})
|
|
||||||
|
|
||||||
return (
|
|
||||||
<DataGridSection
|
|
||||||
table={table}
|
|
||||||
recordCount={filteredCount}
|
|
||||||
isLoading={isLoading}
|
|
||||||
emptyMessage={emptyTitle}
|
|
||||||
searchValue={globalFilter}
|
|
||||||
onSearchChange={setGlobalFilter}
|
|
||||||
searchPlaceholder="Поиск правил…"
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -9,7 +9,6 @@ import {
|
|||||||
BookText,
|
BookText,
|
||||||
KeyRound,
|
KeyRound,
|
||||||
ServerCog,
|
ServerCog,
|
||||||
Shield,
|
|
||||||
Search,
|
Search,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
|
|
||||||
@@ -90,7 +89,6 @@ const NAV_GROUPS: NavGroup[] = [
|
|||||||
label: 'Операции',
|
label: 'Операции',
|
||||||
items: [
|
items: [
|
||||||
{ to: '/operations', label: 'Операции', icon: Cog, description: 'Ревизии и apply', search: { tab: 'revisions' } },
|
{ to: '/operations', label: 'Операции', icon: Cog, description: 'Ревизии и apply', search: { tab: 'revisions' } },
|
||||||
{ to: '/firewall', label: 'Файрвол', icon: Shield, description: 'Клиенты и правила' },
|
|
||||||
{ to: '/schedule', label: 'Задачи', icon: ListChecks, description: 'Расписание refresh' },
|
{ to: '/schedule', label: 'Задачи', icon: ListChecks, description: 'Расписание refresh' },
|
||||||
{ to: '/monitoring', label: 'Мониторинг', icon: Activity, description: 'Health и BIRD', search: { tab: 'system' } },
|
{ to: '/monitoring', label: 'Мониторинг', icon: Activity, description: 'Health и BIRD', search: { tab: 'system' } },
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -269,7 +269,6 @@ export function permissionForPath(pathname: string): string | null {
|
|||||||
if (pathname.startsWith('/network')) return 'bgp:network:read'
|
if (pathname.startsWith('/network')) return 'bgp:network:read'
|
||||||
if (pathname.startsWith('/directories')) return 'bgp:directories:read'
|
if (pathname.startsWith('/directories')) return 'bgp:directories:read'
|
||||||
if (pathname.startsWith('/operations')) return 'bgp:operations:read'
|
if (pathname.startsWith('/operations')) return 'bgp:operations:read'
|
||||||
if (pathname.startsWith('/firewall')) return 'bgp:firewall:read'
|
|
||||||
if (pathname.startsWith('/schedule')) return 'bgp:schedule:read'
|
if (pathname.startsWith('/schedule')) return 'bgp:schedule:read'
|
||||||
if (pathname.startsWith('/monitoring')) return 'bgp:monitoring:read'
|
if (pathname.startsWith('/monitoring')) return 'bgp:monitoring:read'
|
||||||
if (pathname.startsWith('/access')) return 'bgp:access:admin'
|
if (pathname.startsWith('/access')) return 'bgp:access:admin'
|
||||||
@@ -289,7 +288,6 @@ export function firstAllowedPath(): string {
|
|||||||
'/network',
|
'/network',
|
||||||
'/directories',
|
'/directories',
|
||||||
'/operations',
|
'/operations',
|
||||||
'/firewall',
|
|
||||||
'/schedule',
|
'/schedule',
|
||||||
'/monitoring',
|
'/monitoring',
|
||||||
'/access',
|
'/access',
|
||||||
|
|||||||
@@ -1,102 +0,0 @@
|
|||||||
import { queryOptions, useMutation, useQueryClient } from '@tanstack/react-query'
|
|
||||||
import { toast } from 'sonner'
|
|
||||||
|
|
||||||
import { apiJSON } from '@/lib/api-client'
|
|
||||||
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(),
|
|
||||||
queryFn: () => apiJSON<FirewallClientsResponse>('/v1/firewall/clients'),
|
|
||||||
staleTime: 15_000,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export function firewallRulesQueryOptions(scope: 'tenant' | 'client', clientId?: string) {
|
|
||||||
const qs =
|
|
||||||
scope === 'client' && clientId
|
|
||||||
? `?scope=client&client_id=${encodeURIComponent(clientId)}`
|
|
||||||
: '?scope=tenant'
|
|
||||||
return queryOptions<FirewallRulesResponse>({
|
|
||||||
queryKey: firewallKeys.rules(scope, clientId),
|
|
||||||
queryFn: () => apiJSON<FirewallRulesResponse>(`/v1/firewall/rules${qs}`),
|
|
||||||
staleTime: 15_000,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useApproveFirewallClient() {
|
|
||||||
const qc = useQueryClient()
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: (id: string) =>
|
|
||||||
apiJSON<FirewallClient>(`/v1/firewall/clients/${id}/approve`, { method: 'POST' }),
|
|
||||||
onSuccess: () => {
|
|
||||||
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 : 'Не удалось удалить'),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useCreateFirewallRule() {
|
|
||||||
const qc = useQueryClient()
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: (body: Record<string, unknown>) =>
|
|
||||||
apiJSON<FirewallRule>('/v1/firewall/rules', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify(body),
|
|
||||||
}),
|
|
||||||
onSuccess: () => {
|
|
||||||
toast.success('Правило добавлено')
|
|
||||||
void qc.invalidateQueries({ queryKey: firewallKeys.all })
|
|
||||||
},
|
|
||||||
onError: (e) => toast.error(e instanceof Error ? e.message : 'Не удалось добавить правило'),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useDeleteFirewallRule() {
|
|
||||||
const qc = useQueryClient()
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: (id: string) =>
|
|
||||||
apiJSON<void>(`/v1/firewall/rules/${id}`, { method: 'DELETE' }),
|
|
||||||
onSuccess: () => {
|
|
||||||
void qc.invalidateQueries({ queryKey: firewallKeys.all })
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -1,276 +0,0 @@
|
|||||||
import { createFileRoute } from '@tanstack/react-router'
|
|
||||||
import { useQuery } from '@tanstack/react-query'
|
|
||||||
import { Copy, Plus, RefreshCw, Shield } from 'lucide-react'
|
|
||||||
import { useEffect, useMemo, useState } from 'react'
|
|
||||||
import { toast } from 'sonner'
|
|
||||||
|
|
||||||
import { Button } from '@evobgp/ui/components/button'
|
|
||||||
import { PanelCard } from '@/components/panel-card'
|
|
||||||
import { Input } from '@evobgp/ui/components/input'
|
|
||||||
import { Label } from '@evobgp/ui/components/label'
|
|
||||||
import { BadgeTabs, TabsContent } from '@/components/badge-tabs'
|
|
||||||
import { DataGridCard } from '@/components/data-grid-shell'
|
|
||||||
import { FirewallClientsGrid } from '@/components/firewall/firewall-clients-grid'
|
|
||||||
import { FirewallRuleCreateDialog } from '@/components/firewall/firewall-rule-create-dialog'
|
|
||||||
import { FirewallRulesGrid } from '@/components/firewall/firewall-rules-grid'
|
|
||||||
import { PageHeader } from '@/components/page-header'
|
|
||||||
import { QueryState } from '@/components/query-state'
|
|
||||||
import { TableSkeleton } from '@/components/skeletons'
|
|
||||||
import { directoriesCommunitiesQueryOptions } from '@/queries/directories'
|
|
||||||
import {
|
|
||||||
firewallClientsQueryOptions,
|
|
||||||
firewallInstallContextQueryOptions,
|
|
||||||
firewallRulesQueryOptions,
|
|
||||||
useApproveFirewallClient,
|
|
||||||
useDeleteFirewallClient,
|
|
||||||
useDeleteFirewallRule,
|
|
||||||
} from '@/queries/firewall'
|
|
||||||
|
|
||||||
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 deleteRule = useDeleteFirewallRule()
|
|
||||||
|
|
||||||
const installCtx = installCtxQ.data
|
|
||||||
|
|
||||||
const [clientName, setClientName] = useState('web-01')
|
|
||||||
const [cpUrl, setCpUrl] = useState(() =>
|
|
||||||
typeof window !== 'undefined' ? httpsOrigin(window.location.origin) : 'https://api.example.com',
|
|
||||||
)
|
|
||||||
const [seed, setSeed] = useState('')
|
|
||||||
const [createRuleOpen, setCreateRuleOpen] = useState(false)
|
|
||||||
|
|
||||||
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 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(() => {
|
|
||||||
const s = seed.trim() || '<bundle_seed_hex>'
|
|
||||||
return `curl -fsSL ${cpUrl.replace(/\/$/, '')}/v1/firewall/install.sh | \\
|
|
||||||
EVOBGP_CP_URL=${cpUrl.replace(/\/$/, '')} \\
|
|
||||||
EVOBGP_SEED=${s} \\
|
|
||||||
EVOBGP_CLIENT_NAME="${clientName}" \\
|
|
||||||
bash`
|
|
||||||
}, [clientName, cpUrl, seed])
|
|
||||||
|
|
||||||
async function copyInstall() {
|
|
||||||
if (!seed.trim()) {
|
|
||||||
toast.error(
|
|
||||||
installCtx?.bundle_seed_configured === false
|
|
||||||
? 'На CP не задан EVOBGP_BUNDLE_SEED_HEX'
|
|
||||||
: 'Seed бандла недоступен (нужна роль оператора)',
|
|
||||||
)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
await navigator.clipboard.writeText(installCmd)
|
|
||||||
toast.success('Команда скопирована')
|
|
||||||
} catch {
|
|
||||||
toast.error('Не удалось скопировать')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col gap-6">
|
|
||||||
<PageHeader
|
|
||||||
title="Файрвол: blocklist"
|
|
||||||
description="Linux-серверы: синхронизация CIDR по policy block/accept"
|
|
||||||
actions={
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => {
|
|
||||||
void clientsQ.refetch()
|
|
||||||
void rulesQ.refetch()
|
|
||||||
}}
|
|
||||||
disabled={clientsQ.isFetching}
|
|
||||||
>
|
|
||||||
<RefreshCw className={clientsQ.isFetching ? 'animate-spin' : ''} />
|
|
||||||
Обновить
|
|
||||||
</Button>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<PanelCard
|
|
||||||
title={
|
|
||||||
<span className="flex items-center gap-2">
|
|
||||||
<Shield className="size-4" />
|
|
||||||
Установка на сервер
|
|
||||||
</span>
|
|
||||||
}
|
|
||||||
description="Команда для root на целевом Linux (bash, curl). После регистрации — одобрите клиента во вкладке «Запросы»."
|
|
||||||
contentClassName="flex flex-col gap-4 py-4"
|
|
||||||
>
|
|
||||||
<div className="grid gap-4 sm:grid-cols-3">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="fw-name">Имя сервера</Label>
|
|
||||||
<Input id="fw-name" value={clientName} onChange={(e) => setClientName(e.target.value)} />
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="fw-url">URL API</Label>
|
|
||||||
<Input id="fw-url" value={cpUrl} onChange={(e) => setCpUrl(e.target.value)} />
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="fw-seed">Seed бандла</Label>
|
|
||||||
<Input
|
|
||||||
id="fw-seed"
|
|
||||||
type="password"
|
|
||||||
readOnly
|
|
||||||
placeholder="EVOBGP_BUNDLE_SEED_HEX"
|
|
||||||
value={seed}
|
|
||||||
className="font-mono text-xs"
|
|
||||||
/>
|
|
||||||
<p className="text-muted-foreground text-xs">
|
|
||||||
{installCtxQ.isLoading
|
|
||||||
? 'Загрузка с плоскости управления…'
|
|
||||||
: installCtx?.bundle_seed_configured
|
|
||||||
? 'Из переменной EVOBGP_BUNDLE_SEED_HEX на CP (docker compose / .env)'
|
|
||||||
: 'На CP не задан EVOBGP_BUNDLE_SEED_HEX — регистрация невозможна'}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<pre className="overflow-x-auto rounded-md border bg-muted/40 p-3 text-xs">{installCmd}</pre>
|
|
||||||
<Button variant="outline" size="sm" className="w-fit" onClick={copyInstall}>
|
|
||||||
<Copy />
|
|
||||||
Копировать команду
|
|
||||||
</Button>
|
|
||||||
</PanelCard>
|
|
||||||
|
|
||||||
<BadgeTabs
|
|
||||||
defaultValue="clients"
|
|
||||||
items={[
|
|
||||||
{ value: 'clients', label: 'Клиенты', count: activeClients.length },
|
|
||||||
{ value: 'rules', label: 'Правила', count: rules.length, badgeVariant: 'info-light' },
|
|
||||||
{
|
|
||||||
value: 'requests',
|
|
||||||
label: 'Запросы',
|
|
||||||
count: pending.length,
|
|
||||||
badgeVariant: pending.length > 0 ? 'warning-light' : 'primary-light',
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
<TabsContent value="clients" className="mt-0">
|
|
||||||
<DataGridCard title="Клиенты" description="Активные Linux-серверы с синхронизацией списка блокировок">
|
|
||||||
<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>
|
|
||||||
</DataGridCard>
|
|
||||||
</TabsContent>
|
|
||||||
|
|
||||||
<TabsContent value="rules" className="mt-0">
|
|
||||||
<DataGridCard
|
|
||||||
title="Правила"
|
|
||||||
actions={
|
|
||||||
<Button size="sm" type="button" onClick={() => setCreateRuleOpen(true)}>
|
|
||||||
<Plus />
|
|
||||||
Добавить правило
|
|
||||||
</Button>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<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>
|
|
||||||
</DataGridCard>
|
|
||||||
<FirewallRuleCreateDialog
|
|
||||||
open={createRuleOpen}
|
|
||||||
onOpenChange={setCreateRuleOpen}
|
|
||||||
communities={communities}
|
|
||||||
/>
|
|
||||||
</TabsContent>
|
|
||||||
|
|
||||||
<TabsContent value="requests" className="mt-0">
|
|
||||||
<DataGridCard
|
|
||||||
title="Запросы"
|
|
||||||
description="Запросы на регистрацию — одобрите или отклоните новые клиенты"
|
|
||||||
>
|
|
||||||
<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="Нет ожидающих запросов"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</QueryState>
|
|
||||||
</DataGridCard>
|
|
||||||
</TabsContent>
|
|
||||||
</BadgeTabs>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -375,41 +375,5 @@ export type AsyncJobAccepted = {
|
|||||||
job_id: string
|
job_id: string
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- Firewall blocklist ----
|
// Firewall blocklist types removed — the firewall subsystem moved to the standalone
|
||||||
export type FirewallClient = {
|
// EvoFirewall service. See docs/firewall.md.
|
||||||
id: string
|
|
||||||
name: string
|
|
||||||
hostname?: string
|
|
||||||
token_prefix: string
|
|
||||||
status: 'pending' | 'approved' | 'revoked'
|
|
||||||
last_seen_at?: string | null
|
|
||||||
last_seen_at_source?: string
|
|
||||||
last_apply_at?: string | null
|
|
||||||
last_apply_status?: string
|
|
||||||
last_apply_prefix_count?: number
|
|
||||||
last_apply_packets_dropped?: number
|
|
||||||
last_apply_packets_accepted?: number
|
|
||||||
last_apply_source?: string
|
|
||||||
client_version?: string
|
|
||||||
created_at: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export type FirewallClientsResponse = { items: FirewallClient[] }
|
|
||||||
|
|
||||||
export type FirewallRule = {
|
|
||||||
id: string
|
|
||||||
client_id?: string | null
|
|
||||||
priority: number
|
|
||||||
action: 'block' | 'accept'
|
|
||||||
community_id?: string | null
|
|
||||||
comment?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export type FirewallRulesResponse = { items: FirewallRule[] }
|
|
||||||
|
|
||||||
export type FirewallInstallContext = {
|
|
||||||
bundle_seed: string
|
|
||||||
bundle_seed_configured: boolean
|
|
||||||
suggested_cp_url: string
|
|
||||||
install_sh_url: string
|
|
||||||
}
|
|
||||||
|
|||||||
+9
-46
@@ -1,52 +1,15 @@
|
|||||||
# Firewall blocklist
|
# Firewall (deprecated in EvoBGP)
|
||||||
|
|
||||||
Подсистема синхронизации blocklist на произвольные Linux-серверы через bash-скрипт и HTTP API.
|
**Hard cutover:** подсистема firewall перенесена в отдельный продукт **[EvoFirewall](https://git.shts.su/denozord/EvoFirewall)**.
|
||||||
|
|
||||||
## Авторизация
|
Все HTTP-эндпоинты `/v1/firewall/*` в EvoBGP отвечают **410 Gone**.
|
||||||
|
|
||||||
1. **Enroll** — `POST /v1/firewall/enroll` с заголовком `X-EvoBGP-Seed` (значение `EVOBGP_BUNDLE_SEED_HEX` на CP). Клиент генерирует токен `evobgp_fw_*` локально.
|
Таблицы `firewall_client` / `firewall_rule` в БД оставлены (не удаляются миграциями) для истории; API/UI/scripts больше не обслуживают их.
|
||||||
2. **Approve** — operator в Web UI (`/firewall` → Запросы).
|
|
||||||
3. **Sync** — `GET /v1/firewall/blocklist` с `Authorization: Bearer <client_token>`.
|
|
||||||
|
|
||||||
## Политика block/accept
|
## Миграция клиентов
|
||||||
|
|
||||||
- **`block`** — добавить префиксы выбранного BGP community в kernel blocklist.
|
1. Разверните EvoFirewall (auth-portal app id `fw`).
|
||||||
- **`accept`** — не блокировать префиксы этого community.
|
2. Переустановите агенты one-liner'ом EvoFirewall (`/v1/agent/install.sh`).
|
||||||
- **Community** — правило применяется к префиксам с этим `community_id` в опубликованной revision; пустое значение («Все») — ко всем communities.
|
3. Для списков по community создайте IP list type `evobgp_community` и укажите `EVOBGP_API_URL` + token в настройках EvoFirewall.
|
||||||
- **Default** — accept (пустой blocklist без явных `block`).
|
|
||||||
|
|
||||||
Порядок: сначала per-server overrides клиента, затем tenant-default. Для каждого community берётся первое подходящее правило по приоритету.
|
Старые токены `evobgp_fw_*` **не** переносятся — только re-enroll.
|
||||||
|
|
||||||
Справочник 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> \
|
|
||||||
EVOBGP_SEED=<bundle_seed_hex> \
|
|
||||||
EVOBGP_CLIENT_NAME="web-01" \
|
|
||||||
bash
|
|
||||||
```
|
|
||||||
|
|
||||||
Файлы: `/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-домен.
|
|
||||||
|
|
||||||
См. также [access.md](access.md), [remote-speakers.md](remote-speakers.md).
|
|
||||||
|
|||||||
@@ -1,118 +0,0 @@
|
|||||||
package httpapi
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"io"
|
|
||||||
"log"
|
|
||||||
"net/http"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"evobgp/internal/nodedispatch"
|
|
||||||
"evobgp/internal/store"
|
|
||||||
)
|
|
||||||
|
|
||||||
func (s *Server) replicateFirewallStateToSpeakers(tenantID string) {
|
|
||||||
if !nodedispatch.Enabled() {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
clients, err := s.store.ListApprovedFirewallClientsForReplication(tenantID)
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("httpapi: firewall replicate clients: %v", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
rules, err := s.store.ListAllFirewallRulesForReplication(tenantID)
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("httpapi: firewall replicate rules: %v", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
revs, _, _ := s.store.ListRevisions(tenantID, "", "", 1)
|
|
||||||
if len(revs) == 0 {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
revID := revs[0].ID
|
|
||||||
prefixesByCommunity, _, err := s.loadPrefixesByCommunity(tenantID, revID)
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("httpapi: firewall replicate prefixes: %v", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
payloadRules := make([]map[string]any, 0, len(rules))
|
|
||||||
for _, r := range rules {
|
|
||||||
payloadRules = append(payloadRules, map[string]any{
|
|
||||||
"client_id": r.ClientID,
|
|
||||||
"priority": r.Priority,
|
|
||||||
"action": r.Action,
|
|
||||||
"community_id": r.CommunityID,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
payloadClients := make([]map[string]any, 0, len(clients))
|
|
||||||
for _, c := range clients {
|
|
||||||
payloadClients = append(payloadClients, map[string]any{
|
|
||||||
"token_hash_hex": c.TokenHashHex,
|
|
||||||
"client_id": c.ClientID,
|
|
||||||
"name": c.Name,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
body := map[string]any{
|
|
||||||
"tenant_id": tenantID,
|
|
||||||
"revision_id": revID,
|
|
||||||
"generated_at": time.Now().UTC().Format(time.RFC3339),
|
|
||||||
"clients": payloadClients,
|
|
||||||
"rules": payloadRules,
|
|
||||||
"prefixes_by_community": prefixesByCommunity,
|
|
||||||
}
|
|
||||||
|
|
||||||
speakers := s.store.ListSpeakersForTenant(tenantID)
|
|
||||||
for _, sp := range speakers {
|
|
||||||
meta := store.ParseSpeakerMeta(sp.MetaJSON)
|
|
||||||
if !store.SpeakerNeedsRemoteDispatch(sp.Role, meta) || !meta.FirewallFailover {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
domain := strings.TrimSpace(meta.AgentDomain)
|
|
||||||
if domain == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
url := "https://" + strings.TrimSuffix(domain, "/") + "/v1/agent/firewall-replicate"
|
|
||||||
status, errMsg := postFirewallReplicate(ctx, url, meta.AgentSecret, body)
|
|
||||||
patch := store.SpeakerMeta{
|
|
||||||
LastFirewallReplicateAt: time.Now().UTC().Format(time.RFC3339Nano),
|
|
||||||
LastFirewallReplicateStatus: status,
|
|
||||||
LastFirewallReplicateError: errMsg,
|
|
||||||
}
|
|
||||||
merged := store.MergeSpeakerMetaJSON(sp.MetaJSON, patch)
|
|
||||||
mp := merged
|
|
||||||
if _, err := s.store.UpdateSpeaker(tenantID, sp.ID, &store.SpeakerPatch{MetaJSON: &mp}); err != nil {
|
|
||||||
log.Printf("httpapi: firewall replicate meta update %s: %v", sp.ID, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func postFirewallReplicate(ctx context.Context, url, secret string, body map[string]any) (status, errMsg string) {
|
|
||||||
b, err := json.Marshal(body)
|
|
||||||
if err != nil {
|
|
||||||
return "error", err.Error()
|
|
||||||
}
|
|
||||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(b))
|
|
||||||
if err != nil {
|
|
||||||
return "error", err.Error()
|
|
||||||
}
|
|
||||||
req.Header.Set("Authorization", "Bearer "+strings.TrimSpace(secret))
|
|
||||||
req.Header.Set("Content-Type", "application/json")
|
|
||||||
client := &http.Client{Timeout: 30 * time.Second}
|
|
||||||
resp, err := client.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
return "error", err.Error()
|
|
||||||
}
|
|
||||||
defer func() { _ = resp.Body.Close() }()
|
|
||||||
_, _ = io.Copy(io.Discard, resp.Body)
|
|
||||||
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
|
|
||||||
return "ok", ""
|
|
||||||
}
|
|
||||||
return "error", resp.Status
|
|
||||||
}
|
|
||||||
@@ -36,9 +36,9 @@ func (s *Server) Handler() http.Handler {
|
|||||||
s.mux.HandleFunc("GET /v1/ready", s.handleReady)
|
s.mux.HandleFunc("GET /v1/ready", s.handleReady)
|
||||||
s.mux.HandleFunc("GET /v1/version", s.handleVersion)
|
s.mux.HandleFunc("GET /v1/version", s.handleVersion)
|
||||||
s.mux.HandleFunc("GET /v1/auth/config", s.handleAuthConfigPublic)
|
s.mux.HandleFunc("GET /v1/auth/config", s.handleAuthConfigPublic)
|
||||||
s.mux.HandleFunc("POST /v1/firewall/enroll", s.handleFirewallEnrollPublic)
|
// Firewall subsystem moved to the standalone EvoFirewall service; see docs/firewall.md.
|
||||||
s.mux.HandleFunc("GET /v1/firewall/install.sh", s.handleFirewallInstallScript)
|
// Registered on the public mux so it wins over the "/v1/" subtree below regardless of auth.
|
||||||
s.mux.HandleFunc("GET /v1/firewall/sync-script", s.handleFirewallSyncScript)
|
s.mux.HandleFunc("/v1/firewall/", s.handleFirewallGone)
|
||||||
s.mux.Handle("/v1/", s.authMiddleware(wrappedV1))
|
s.mux.Handle("/v1/", s.authMiddleware(wrappedV1))
|
||||||
return s.withCORS(observability.HTTPMiddleware(s.mux))
|
return s.withCORS(observability.HTTPMiddleware(s.mux))
|
||||||
}
|
}
|
||||||
@@ -87,7 +87,6 @@ func (s *Server) registerV1(m *http.ServeMux) {
|
|||||||
s.registerPostgresMaintenanceRoutes(m)
|
s.registerPostgresMaintenanceRoutes(m)
|
||||||
s.registerMaintenanceRoutes(m)
|
s.registerMaintenanceRoutes(m)
|
||||||
s.registerRuntimeLogsRoutes(m)
|
s.registerRuntimeLogsRoutes(m)
|
||||||
s.registerFirewallRoutes(m)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|||||||
@@ -1,709 +1,16 @@
|
|||||||
package httpapi
|
package httpapi
|
||||||
|
|
||||||
import (
|
import "net/http"
|
||||||
"context"
|
|
||||||
"crypto/sha256"
|
|
||||||
"encoding/hex"
|
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
|
||||||
"io"
|
|
||||||
"net/http"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"sort"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"evobgp/internal/authkey"
|
// handleFirewallGone responds 410 Gone for every legacy /v1/firewall/* endpoint
|
||||||
"evobgp/internal/firewall"
|
// (enroll, install.sh, sync-script, clients, rules, blocklist, apply-report,
|
||||||
"evobgp/internal/firewallscripts"
|
// heartbeat, install-context). The firewall subsystem (client enrollment,
|
||||||
"evobgp/internal/store"
|
// block/accept rules, blocklist distribution) has moved to the standalone
|
||||||
)
|
// EvoFirewall service; see docs/firewall.md.
|
||||||
|
//
|
||||||
func (s *Server) registerFirewallRoutes(m *http.ServeMux) {
|
// Existing firewall_client / firewall_rule tables and store code (memory_firewall.go,
|
||||||
m.HandleFunc("GET /firewall/install-context", s.handleFirewallInstallContext)
|
// postgres_firewall.go, firewall_types.go) are intentionally left in place — only the
|
||||||
m.HandleFunc("GET /firewall/clients", s.handleListFirewallClients)
|
// HTTP surface is decommissioned here.
|
||||||
m.HandleFunc("GET /firewall/clients/{id}", s.handleGetFirewallClient)
|
func (s *Server) handleFirewallGone(w http.ResponseWriter, r *http.Request) {
|
||||||
m.HandleFunc("GET /firewall/clients/{id}/preview", s.handleFirewallClientPreview)
|
writeProblem(w, http.StatusGone, "Gone", "firewall feature moved to EvoFirewall — see docs/firewall.md")
|
||||||
m.HandleFunc("PATCH /firewall/clients/{id}", s.handlePatchFirewallClient)
|
|
||||||
m.HandleFunc("POST /firewall/clients/{id}/approve", s.handleApproveFirewallClient)
|
|
||||||
m.HandleFunc("POST /firewall/clients/{id}/revoke", s.handleRevokeFirewallClient)
|
|
||||||
m.HandleFunc("DELETE /firewall/clients/{id}", s.handleDeleteFirewallClient)
|
|
||||||
|
|
||||||
m.HandleFunc("GET /firewall/rules", s.handleListFirewallRules)
|
|
||||||
m.HandleFunc("POST /firewall/rules", s.handleCreateFirewallRule)
|
|
||||||
m.HandleFunc("PATCH /firewall/rules/{id}", s.handlePatchFirewallRule)
|
|
||||||
m.HandleFunc("DELETE /firewall/rules/{id}", s.handleDeleteFirewallRule)
|
|
||||||
m.HandleFunc("POST /firewall/rules:reorder", s.handleReorderFirewallRules)
|
|
||||||
|
|
||||||
m.HandleFunc("GET /firewall/blocklist", s.handleFirewallBlocklist)
|
|
||||||
m.HandleFunc("POST /firewall/apply-report", s.handleFirewallApplyReport)
|
|
||||||
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.requirePerm(w, a, "bgp:firewall:write") {
|
|
||||||
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")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
seed := strings.TrimSpace(r.Header.Get("X-EvoBGP-Seed"))
|
|
||||||
if seed == "" || s.bundleSeedHex == "" || !strings.EqualFold(seed, s.bundleSeedHex) {
|
|
||||||
writeProblem(w, http.StatusForbidden, "Forbidden", "invalid or missing X-EvoBGP-Seed")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
var body struct {
|
|
||||||
Name string `json:"name"`
|
|
||||||
Hostname string `json:"hostname"`
|
|
||||||
ClientToken string `json:"client_token"`
|
|
||||||
ClientVersion string `json:"client_version"`
|
|
||||||
}
|
|
||||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil && !errors.Is(err, io.EOF) {
|
|
||||||
writeProblem(w, http.StatusBadRequest, "Bad Request", "invalid JSON body")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
name := strings.TrimSpace(body.Name)
|
|
||||||
tok := strings.TrimSpace(body.ClientToken)
|
|
||||||
if name == "" || tok == "" {
|
|
||||||
writeProblem(w, http.StatusUnprocessableEntity, "Unprocessable Entity", "name and client_token are required")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if !strings.HasPrefix(tok, "evobgp_fw_") {
|
|
||||||
writeProblem(w, http.StatusUnprocessableEntity, "Unprocessable Entity", "client_token must use evobgp_fw_ prefix")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
tenantID, err := s.firewallEnrollTenantID()
|
|
||||||
if err != nil {
|
|
||||||
writeInternalError(w, "internal", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
hash := authkey.HashToken(tok)
|
|
||||||
prefix := tok
|
|
||||||
if len(prefix) > 12 {
|
|
||||||
prefix = prefix[:12]
|
|
||||||
}
|
|
||||||
client, err := s.store.CreateFirewallClient(tenantID, &store.FirewallClientCreate{
|
|
||||||
Name: name,
|
|
||||||
Hostname: strings.TrimSpace(body.Hostname),
|
|
||||||
TokenPrefix: prefix,
|
|
||||||
TokenHash: hash,
|
|
||||||
ClientVersion: strings.TrimSpace(body.ClientVersion),
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
if errors.Is(err, store.ErrInvalidInput) {
|
|
||||||
writeProblem(w, http.StatusConflict, "Conflict", "client token already enrolled")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
writeStoreErr(w, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
writeJSON(w, http.StatusCreated, map[string]any{
|
|
||||||
"client_id": client.ID,
|
|
||||||
"status": client.Status,
|
|
||||||
"message": "pending operator approval in EvoBGP UI",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Server) firewallEnrollTenantID() (string, error) {
|
|
||||||
tid, _, _, _, _ := s.store.DemoIDs()
|
|
||||||
if tid != "" {
|
|
||||||
return tid, nil
|
|
||||||
}
|
|
||||||
ids, err := s.store.ListTenantIDs()
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
if len(ids) == 0 {
|
|
||||||
return "", errors.New("httpapi: no tenant for firewall enroll")
|
|
||||||
}
|
|
||||||
return ids[0], nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Server) handleFirewallInstallScript(w http.ResponseWriter, r *http.Request) {
|
|
||||||
s.serveFirewallScript(w, "install.sh")
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Server) handleFirewallSyncScript(w http.ResponseWriter, r *http.Request) {
|
|
||||||
s.serveFirewallScript(w, "evobgp-firewall.sh")
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Server) serveFirewallScript(w http.ResponseWriter, name string) {
|
|
||||||
b, err := readFirewallScript(name)
|
|
||||||
if err != nil {
|
|
||||||
writeProblem(w, http.StatusNotFound, "Not Found", "script not found")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
w.Header().Set("Content-Type", "text/x-shellscript; charset=utf-8")
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
_, _ = 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.requirePerm(w, a, "bgp:firewall:read") {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
items, err := s.store.ListFirewallClients(a.TenantID)
|
|
||||||
if err != nil {
|
|
||||||
writeInternalError(w, "internal", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
items = store.FilterOwned(items, func(c *store.FirewallClient) string { return c.CreatedByUserID }, a.Kind, a.IsAdmin, a.UserID)
|
|
||||||
writeJSON(w, http.StatusOK, map[string]any{"items": items})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Server) handleGetFirewallClient(w http.ResponseWriter, r *http.Request) {
|
|
||||||
a, ok := authFromContext(r.Context())
|
|
||||||
if !ok || !s.requirePerm(w, a, "bgp:firewall:read") {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
id := strings.TrimSpace(r.PathValue("id"))
|
|
||||||
client, err := s.store.GetFirewallClient(a.TenantID, id)
|
|
||||||
if err != nil {
|
|
||||||
writeProblem(w, http.StatusNotFound, "Not Found", "client not found")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if !store.CanAccessOwned(a.Kind, a.IsAdmin, a.UserID, client.CreatedByUserID) {
|
|
||||||
writeProblem(w, http.StatusNotFound, "Not Found", "client not found")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
writeJSON(w, http.StatusOK, client)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Server) handlePatchFirewallClient(w http.ResponseWriter, r *http.Request) {
|
|
||||||
a, ok := authFromContext(r.Context())
|
|
||||||
if !ok || !s.requirePerm(w, a, "bgp:firewall:write") {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
id := strings.TrimSpace(r.PathValue("id"))
|
|
||||||
if existing, gerr := s.store.GetFirewallClient(a.TenantID, id); gerr == nil {
|
|
||||||
if !store.CanAccessOwned(a.Kind, a.IsAdmin, a.UserID, existing.CreatedByUserID) {
|
|
||||||
writeProblem(w, http.StatusNotFound, "Not Found", "client not found")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
var patch store.FirewallClientPatch
|
|
||||||
if err := json.NewDecoder(r.Body).Decode(&patch); err != nil {
|
|
||||||
writeProblem(w, http.StatusBadRequest, "Bad Request", "invalid JSON body")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
client, err := s.store.UpdateFirewallClient(a.TenantID, id, &patch)
|
|
||||||
if err != nil {
|
|
||||||
writeProblem(w, http.StatusNotFound, "Not Found", "client not found")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
writeJSON(w, http.StatusOK, client)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Server) handleApproveFirewallClient(w http.ResponseWriter, r *http.Request) {
|
|
||||||
a, ok := authFromContext(r.Context())
|
|
||||||
if !ok || !s.requirePerm(w, a, "bgp:firewall:write") {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
id := strings.TrimSpace(r.PathValue("id"))
|
|
||||||
if existing, gerr := s.store.GetFirewallClient(a.TenantID, id); gerr == nil {
|
|
||||||
if !store.CanAccessOwned(a.Kind, a.IsAdmin, a.UserID, existing.CreatedByUserID) {
|
|
||||||
writeProblem(w, http.StatusNotFound, "Not Found", "client not found")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
client, err := s.store.ApproveFirewallClient(a.TenantID, id, a.APIKeyID)
|
|
||||||
if err != nil {
|
|
||||||
writeProblem(w, http.StatusNotFound, "Not Found", "client not found")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
_ = s.firewallResolver.Reload(s.store)
|
|
||||||
go s.replicateFirewallStateToSpeakers(a.TenantID)
|
|
||||||
writeJSON(w, http.StatusOK, client)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Server) handleRevokeFirewallClient(w http.ResponseWriter, r *http.Request) {
|
|
||||||
a, ok := authFromContext(r.Context())
|
|
||||||
if !ok || !s.requirePerm(w, a, "bgp:firewall:write") {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
id := strings.TrimSpace(r.PathValue("id"))
|
|
||||||
if existing, gerr := s.store.GetFirewallClient(a.TenantID, id); gerr == nil {
|
|
||||||
if !store.CanAccessOwned(a.Kind, a.IsAdmin, a.UserID, existing.CreatedByUserID) {
|
|
||||||
writeProblem(w, http.StatusNotFound, "Not Found", "client not found")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if err := s.store.RevokeFirewallClient(a.TenantID, id); err != nil {
|
|
||||||
writeProblem(w, http.StatusNotFound, "Not Found", "client not found")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
_ = s.firewallResolver.Reload(s.store)
|
|
||||||
go s.replicateFirewallStateToSpeakers(a.TenantID)
|
|
||||||
writeJSON(w, http.StatusOK, map[string]string{"status": "revoked"})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Server) handleDeleteFirewallClient(w http.ResponseWriter, r *http.Request) {
|
|
||||||
a, ok := authFromContext(r.Context())
|
|
||||||
if !ok || !s.requirePerm(w, a, "bgp:firewall:write") {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
id := strings.TrimSpace(r.PathValue("id"))
|
|
||||||
if existing, gerr := s.store.GetFirewallClient(a.TenantID, id); gerr == nil {
|
|
||||||
if !store.CanAccessOwned(a.Kind, a.IsAdmin, a.UserID, existing.CreatedByUserID) {
|
|
||||||
writeProblem(w, http.StatusNotFound, "Not Found", "client not found")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if err := s.store.DeleteFirewallClient(a.TenantID, id); err != nil {
|
|
||||||
writeProblem(w, http.StatusNotFound, "Not Found", "client not found")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
_ = s.firewallResolver.Reload(s.store)
|
|
||||||
go s.replicateFirewallStateToSpeakers(a.TenantID)
|
|
||||||
w.WriteHeader(http.StatusNoContent)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Server) handleListFirewallRules(w http.ResponseWriter, r *http.Request) {
|
|
||||||
a, ok := authFromContext(r.Context())
|
|
||||||
if !ok || !s.requirePerm(w, a, "bgp:firewall:read") {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
scope := strings.TrimSpace(r.URL.Query().Get("scope"))
|
|
||||||
var clientID *string
|
|
||||||
if scope == "client" {
|
|
||||||
cid := strings.TrimSpace(r.URL.Query().Get("client_id"))
|
|
||||||
if cid == "" {
|
|
||||||
writeProblem(w, http.StatusUnprocessableEntity, "Unprocessable Entity", "client_id required for scope=client")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
clientID = &cid
|
|
||||||
}
|
|
||||||
items, err := s.store.ListFirewallRules(a.TenantID, clientID)
|
|
||||||
if err != nil {
|
|
||||||
writeInternalError(w, "internal", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
items = store.FilterOwned(items, func(rule *store.FirewallRule) string { return rule.CreatedByUserID }, a.Kind, a.IsAdmin, a.UserID)
|
|
||||||
writeJSON(w, http.StatusOK, map[string]any{"items": items})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Server) handleCreateFirewallRule(w http.ResponseWriter, r *http.Request) {
|
|
||||||
a, ok := authFromContext(r.Context())
|
|
||||||
if !ok || !s.requirePerm(w, a, "bgp:firewall:write") {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
var body struct {
|
|
||||||
Scope string `json:"scope"`
|
|
||||||
ClientID *string `json:"client_id"`
|
|
||||||
Action string `json:"action"`
|
|
||||||
CommunityID *string `json:"community_id"`
|
|
||||||
Comment string `json:"comment"`
|
|
||||||
Priority *int `json:"priority"`
|
|
||||||
}
|
|
||||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
|
||||||
writeProblem(w, http.StatusBadRequest, "Bad Request", "invalid JSON body")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
var clientID *string
|
|
||||||
if strings.TrimSpace(body.Scope) == "client" {
|
|
||||||
if body.ClientID == nil || strings.TrimSpace(*body.ClientID) == "" {
|
|
||||||
writeProblem(w, http.StatusUnprocessableEntity, "Unprocessable Entity", "client_id required for scope=client")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
cid := strings.TrimSpace(*body.ClientID)
|
|
||||||
clientID = &cid
|
|
||||||
}
|
|
||||||
fwRule := &store.FirewallRuleCreate{
|
|
||||||
Priority: body.Priority,
|
|
||||||
Action: body.Action,
|
|
||||||
CommunityID: body.CommunityID,
|
|
||||||
Comment: body.Comment,
|
|
||||||
}
|
|
||||||
if a.Kind == AuthKindJWT && strings.TrimSpace(a.UserID) != "" {
|
|
||||||
fwRule.CreatedByUserID = a.UserID
|
|
||||||
}
|
|
||||||
rule, err := s.store.CreateFirewallRule(a.TenantID, clientID, fwRule)
|
|
||||||
if err != nil {
|
|
||||||
writeProblem(w, http.StatusUnprocessableEntity, "Unprocessable Entity", "invalid rule")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
go s.replicateFirewallStateToSpeakers(a.TenantID)
|
|
||||||
writeJSON(w, http.StatusCreated, rule)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Server) handlePatchFirewallRule(w http.ResponseWriter, r *http.Request) {
|
|
||||||
a, ok := authFromContext(r.Context())
|
|
||||||
if !ok || !s.requirePerm(w, a, "bgp:firewall:write") {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
id := strings.TrimSpace(r.PathValue("id"))
|
|
||||||
if existing, gerr := s.store.GetFirewallRule(a.TenantID, id); gerr == nil {
|
|
||||||
if !store.CanAccessOwned(a.Kind, a.IsAdmin, a.UserID, existing.CreatedByUserID) {
|
|
||||||
writeProblem(w, http.StatusNotFound, "Not Found", "rule not found")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
var patch store.FirewallRulePatch
|
|
||||||
if err := json.NewDecoder(r.Body).Decode(&patch); err != nil {
|
|
||||||
writeProblem(w, http.StatusBadRequest, "Bad Request", "invalid JSON body")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
rule, err := s.store.UpdateFirewallRule(a.TenantID, id, &patch)
|
|
||||||
if err != nil {
|
|
||||||
writeProblem(w, http.StatusNotFound, "Not Found", "rule not found")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
go s.replicateFirewallStateToSpeakers(a.TenantID)
|
|
||||||
writeJSON(w, http.StatusOK, rule)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Server) handleDeleteFirewallRule(w http.ResponseWriter, r *http.Request) {
|
|
||||||
a, ok := authFromContext(r.Context())
|
|
||||||
if !ok || !s.requirePerm(w, a, "bgp:firewall:write") {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
id := strings.TrimSpace(r.PathValue("id"))
|
|
||||||
if existing, gerr := s.store.GetFirewallRule(a.TenantID, id); gerr == nil {
|
|
||||||
if !store.CanAccessOwned(a.Kind, a.IsAdmin, a.UserID, existing.CreatedByUserID) {
|
|
||||||
writeProblem(w, http.StatusNotFound, "Not Found", "rule not found")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if err := s.store.DeleteFirewallRule(a.TenantID, id); err != nil {
|
|
||||||
writeProblem(w, http.StatusNotFound, "Not Found", "rule not found")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
go s.replicateFirewallStateToSpeakers(a.TenantID)
|
|
||||||
w.WriteHeader(http.StatusNoContent)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Server) handleReorderFirewallRules(w http.ResponseWriter, r *http.Request) {
|
|
||||||
a, ok := authFromContext(r.Context())
|
|
||||||
if !ok || !s.requirePerm(w, a, "bgp:firewall:write") {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
var body struct {
|
|
||||||
Scope string `json:"scope"`
|
|
||||||
ClientID *string `json:"client_id"`
|
|
||||||
OrderedIDs []string `json:"ordered_ids"`
|
|
||||||
}
|
|
||||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
|
||||||
writeProblem(w, http.StatusBadRequest, "Bad Request", "invalid JSON body")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
var clientID *string
|
|
||||||
if strings.TrimSpace(body.Scope) == "client" {
|
|
||||||
if body.ClientID == nil || strings.TrimSpace(*body.ClientID) == "" {
|
|
||||||
writeProblem(w, http.StatusUnprocessableEntity, "Unprocessable Entity", "client_id required")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
cid := strings.TrimSpace(*body.ClientID)
|
|
||||||
clientID = &cid
|
|
||||||
}
|
|
||||||
if err := s.store.ReorderFirewallRules(a.TenantID, clientID, body.OrderedIDs); err != nil {
|
|
||||||
writeProblem(w, http.StatusUnprocessableEntity, "Unprocessable Entity", "invalid reorder")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
go s.replicateFirewallStateToSpeakers(a.TenantID)
|
|
||||||
writeJSON(w, http.StatusOK, map[string]bool{"ok": true})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Server) handleFirewallBlocklist(w http.ResponseWriter, r *http.Request) {
|
|
||||||
a, ok := authFromContext(r.Context())
|
|
||||||
if !ok || !s.requireFirewall(w, a) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
client, err := s.store.GetFirewallClient(a.TenantID, a.APIKeyID)
|
|
||||||
if err != nil {
|
|
||||||
writeProblem(w, http.StatusUnauthorized, "Unauthorized", "unknown firewall client")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if client.Status != "approved" {
|
|
||||||
w.Header().Set("Retry-After", "60")
|
|
||||||
writeProblem(w, http.StatusForbidden, "Forbidden", "client pending approval")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
_ = s.store.TouchFirewallClientLastSeen(client.ID, "cp", clientIP(r), r.UserAgent())
|
|
||||||
resp, err := s.buildFirewallBlocklist(r.Context(), client)
|
|
||||||
if err != nil {
|
|
||||||
if errors.Is(err, errNoFirewallRevision) {
|
|
||||||
writeProblem(w, http.StatusNotFound, "Not Found", "no published revision")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
writeInternalError(w, "internal", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
w.Header().Set("X-EvoBGP-Source", "cp")
|
|
||||||
w.Header().Set("X-EvoBGP-Revision-ID", resp.RevisionID)
|
|
||||||
w.Header().Set("X-EvoBGP-Generated-At", resp.GeneratedAt)
|
|
||||||
w.Header().Set("X-EvoBGP-Rules-Version", resp.RulesVersion)
|
|
||||||
if strings.Contains(r.Header.Get("Accept"), "text/plain") {
|
|
||||||
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
|
||||||
for _, p := range resp.Prefixes {
|
|
||||||
_, _ = w.Write([]byte(p + "\n"))
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
writeJSON(w, http.StatusOK, resp)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Server) handleFirewallApplyReport(w http.ResponseWriter, r *http.Request) {
|
|
||||||
a, ok := authFromContext(r.Context())
|
|
||||||
if !ok || !s.requireFirewall(w, a) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
var body struct {
|
|
||||||
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")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
src := strings.TrimSpace(body.Source)
|
|
||||||
if src == "" {
|
|
||||||
src = "cp"
|
|
||||||
}
|
|
||||||
_ = 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})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Server) handleFirewallHeartbeat(w http.ResponseWriter, r *http.Request) {
|
|
||||||
a, ok := authFromContext(r.Context())
|
|
||||||
if !ok || !s.requireFirewall(w, a) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
var body struct {
|
|
||||||
Source string `json:"source"`
|
|
||||||
}
|
|
||||||
_ = json.NewDecoder(r.Body).Decode(&body)
|
|
||||||
src := strings.TrimSpace(body.Source)
|
|
||||||
if src == "" {
|
|
||||||
src = "cp"
|
|
||||||
}
|
|
||||||
_ = s.store.TouchFirewallClientLastSeen(a.APIKeyID, src, clientIP(r), r.UserAgent())
|
|
||||||
writeJSON(w, http.StatusOK, map[string]bool{"ok": true})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Server) handleFirewallClientPreview(w http.ResponseWriter, r *http.Request) {
|
|
||||||
a, ok := authFromContext(r.Context())
|
|
||||||
if !ok || !s.requirePerm(w, a, "bgp:firewall:read") {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
id := strings.TrimSpace(r.PathValue("id"))
|
|
||||||
client, err := s.store.GetFirewallClient(a.TenantID, id)
|
|
||||||
if err != nil {
|
|
||||||
writeProblem(w, http.StatusNotFound, "Not Found", "client not found")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
resp, err := s.buildFirewallBlocklist(r.Context(), client)
|
|
||||||
if err != nil {
|
|
||||||
if errors.Is(err, errNoFirewallRevision) {
|
|
||||||
writeProblem(w, http.StatusNotFound, "Not Found", "no published revision")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
writeInternalError(w, "internal", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
writeJSON(w, http.StatusOK, resp)
|
|
||||||
}
|
|
||||||
|
|
||||||
var errNoFirewallRevision = errors.New("httpapi: no firewall revision")
|
|
||||||
|
|
||||||
type firewallBlocklistResponse struct {
|
|
||||||
ClientID string `json:"client_id"`
|
|
||||||
RevisionID string `json:"revision_id"`
|
|
||||||
GeneratedAt string `json:"generated_at"`
|
|
||||||
Source string `json:"source"`
|
|
||||||
RulesApplied int `json:"rules_applied"`
|
|
||||||
CommunitiesEvaluated int `json:"communities_evaluated"`
|
|
||||||
CommunitiesBlocked int `json:"communities_blocked"`
|
|
||||||
Prefixes []string `json:"prefixes"`
|
|
||||||
Total int `json:"total"`
|
|
||||||
Hash string `json:"hash"`
|
|
||||||
RulesVersion string `json:"-"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Server) buildFirewallBlocklist(ctx context.Context, client *store.FirewallClient) (*firewallBlocklistResponse, error) {
|
|
||||||
_ = ctx
|
|
||||||
revs, _, _ := s.store.ListRevisions(client.TenantID, "", "", 1)
|
|
||||||
if len(revs) == 0 {
|
|
||||||
return nil, errNoFirewallRevision
|
|
||||||
}
|
|
||||||
rev := revs[0]
|
|
||||||
prefixesByCommunity, commCount, err := s.loadPrefixesByCommunity(client.TenantID, rev.ID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
rules, err := s.store.ListAllFirewallRulesForClient(client.TenantID, client.ID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
fwRules := storeRulesToFirewall(rules)
|
|
||||||
blocked := firewall.Evaluate(client.ID, fwRules, prefixesByCommunity)
|
|
||||||
blockedComm := countBlockedCommunities(client.ID, fwRules, prefixesByCommunity)
|
|
||||||
hash := prefixListHash(blocked)
|
|
||||||
return &firewallBlocklistResponse{
|
|
||||||
ClientID: client.ID,
|
|
||||||
RevisionID: rev.ID,
|
|
||||||
GeneratedAt: time.Now().UTC().Format(time.RFC3339),
|
|
||||||
Source: "cp",
|
|
||||||
RulesApplied: len(rules),
|
|
||||||
CommunitiesEvaluated: commCount,
|
|
||||||
CommunitiesBlocked: blockedComm,
|
|
||||||
Prefixes: blocked,
|
|
||||||
Total: len(blocked),
|
|
||||||
Hash: hash,
|
|
||||||
RulesVersion: firewall.RulesVersionHash(fwRules),
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Server) loadPrefixesByCommunity(tenantID, revisionID string) (map[string][]string, int, error) {
|
|
||||||
out := make(map[string][]string)
|
|
||||||
communities := make(map[string]struct{})
|
|
||||||
cursor := ""
|
|
||||||
for {
|
|
||||||
rows, next, more := s.store.ListRevisionPrefixes(tenantID, revisionID, cursor, 5000)
|
|
||||||
for _, row := range rows {
|
|
||||||
key := ""
|
|
||||||
if row.CommunityID != nil {
|
|
||||||
key = strings.TrimSpace(*row.CommunityID)
|
|
||||||
}
|
|
||||||
communities[key] = struct{}{}
|
|
||||||
out[key] = append(out[key], strings.TrimSpace(row.Prefix))
|
|
||||||
}
|
|
||||||
if !more {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
cursor = next
|
|
||||||
}
|
|
||||||
return out, len(communities), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func storeRulesToFirewall(rules []*store.FirewallRule) []firewall.Rule {
|
|
||||||
out := make([]firewall.Rule, 0, len(rules))
|
|
||||||
for _, r := range rules {
|
|
||||||
var cid *string
|
|
||||||
if r.CommunityID != nil {
|
|
||||||
v := *r.CommunityID
|
|
||||||
cid = &v
|
|
||||||
}
|
|
||||||
var cl *string
|
|
||||||
if r.ClientID != nil {
|
|
||||||
v := *r.ClientID
|
|
||||||
cl = &v
|
|
||||||
}
|
|
||||||
out = append(out, firewall.Rule{
|
|
||||||
ClientID: cl,
|
|
||||||
Priority: r.Priority,
|
|
||||||
Action: r.Action,
|
|
||||||
CommunityID: cid,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
func countBlockedCommunities(clientID string, rules []firewall.Rule, prefixesByCommunity map[string][]string) int {
|
|
||||||
n := 0
|
|
||||||
for k := range prefixesByCommunity {
|
|
||||||
ordered := mergeRulesForCount(clientID, rules)
|
|
||||||
for _, r := range ordered {
|
|
||||||
if r.CommunityID == nil || strings.TrimSpace(*r.CommunityID) == k {
|
|
||||||
if strings.EqualFold(r.Action, "block") {
|
|
||||||
n++
|
|
||||||
}
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return n
|
|
||||||
}
|
|
||||||
|
|
||||||
func mergeRulesForCount(clientID string, rules []firewall.Rule) []firewall.Rule {
|
|
||||||
var clientRules, tenantRules []firewall.Rule
|
|
||||||
for _, r := range rules {
|
|
||||||
if r.ClientID != nil && *r.ClientID == clientID {
|
|
||||||
clientRules = append(clientRules, r)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if r.ClientID == nil {
|
|
||||||
tenantRules = append(tenantRules, r)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
sort.Slice(clientRules, func(i, j int) bool { return clientRules[i].Priority < clientRules[j].Priority })
|
|
||||||
sort.Slice(tenantRules, func(i, j int) bool { return tenantRules[i].Priority < tenantRules[j].Priority })
|
|
||||||
out := append([]firewall.Rule{}, clientRules...)
|
|
||||||
return append(out, tenantRules...)
|
|
||||||
}
|
|
||||||
|
|
||||||
func prefixListHash(prefixes []string) string {
|
|
||||||
cp := append([]string(nil), prefixes...)
|
|
||||||
sort.Strings(cp)
|
|
||||||
sum := sha256.Sum256([]byte(strings.Join(cp, "\n")))
|
|
||||||
return "sha256:" + hex.EncodeToString(sum[:])
|
|
||||||
}
|
|
||||||
|
|
||||||
func clientIP(r *http.Request) string {
|
|
||||||
if xff := strings.TrimSpace(r.Header.Get("X-Forwarded-For")); xff != "" {
|
|
||||||
parts := strings.Split(xff, ",")
|
|
||||||
return strings.TrimSpace(parts[0])
|
|
||||||
}
|
|
||||||
host := r.RemoteAddr
|
|
||||||
if i := strings.LastIndex(host, ":"); i >= 0 {
|
|
||||||
return host[:i]
|
|
||||||
}
|
|
||||||
return host
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,19 +1,15 @@
|
|||||||
package httpapi
|
package httpapi
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
|
||||||
"io"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"strings"
|
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"evobgp/internal/authkey"
|
|
||||||
"evobgp/internal/store"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestFirewallEnrollAndBlocklist(t *testing.T) {
|
// TestFirewallRoutesGone verifies the firewall subsystem HTTP surface has been
|
||||||
|
// decommissioned in favor of the standalone EvoFirewall service (see docs/firewall.md).
|
||||||
|
// Every legacy /v1/firewall/* path — public and authenticated — must answer 410 Gone.
|
||||||
|
func TestFirewallRoutesGone(t *testing.T) {
|
||||||
srv, err := New(Options{SeedDemo: true, BundleSeedHex: testBundleSeed})
|
srv, err := New(Options{SeedDemo: true, BundleSeedHex: testBundleSeed})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
@@ -26,279 +22,37 @@ func TestFirewallEnrollAndBlocklist(t *testing.T) {
|
|||||||
defer ts.Close()
|
defer ts.Close()
|
||||||
client := ts.Client()
|
client := ts.Client()
|
||||||
|
|
||||||
tok := "evobgp_fw_testtoken123456789012345678901234"
|
cases := []struct {
|
||||||
enrollBody := `{"name":"web-01","hostname":"web-01.local","client_token":"` + tok + `","client_version":"test/1"}`
|
method string
|
||||||
reqEnroll, _ := http.NewRequest(http.MethodPost, ts.URL+"/v1/firewall/enroll", strings.NewReader(enrollBody))
|
path string
|
||||||
reqEnroll.Header.Set("Content-Type", "application/json")
|
auth bool // send a valid operator bearer token
|
||||||
reqEnroll.Header.Set("X-EvoBGP-Seed", testBundleSeed)
|
}{
|
||||||
respEnroll, err := client.Do(reqEnroll)
|
{http.MethodPost, "/v1/firewall/enroll", false},
|
||||||
if err != nil {
|
{http.MethodGet, "/v1/firewall/install.sh", false},
|
||||||
t.Fatal(err)
|
{http.MethodGet, "/v1/firewall/sync-script", false},
|
||||||
|
{http.MethodGet, "/v1/firewall/install-context", true},
|
||||||
|
{http.MethodGet, "/v1/firewall/clients", true},
|
||||||
|
{http.MethodGet, "/v1/firewall/clients/any-id", true},
|
||||||
|
{http.MethodGet, "/v1/firewall/rules", true},
|
||||||
|
{http.MethodGet, "/v1/firewall/blocklist", false},
|
||||||
|
{http.MethodPost, "/v1/firewall/apply-report", false},
|
||||||
|
{http.MethodPost, "/v1/firewall/heartbeat", false},
|
||||||
}
|
}
|
||||||
defer func() { _ = respEnroll.Body.Close() }()
|
for _, tc := range cases {
|
||||||
if respEnroll.StatusCode != http.StatusCreated {
|
req, err := http.NewRequest(tc.method, ts.URL+tc.path, nil)
|
||||||
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")
|
|
||||||
}
|
|
||||||
|
|
||||||
reqBlock, _ := http.NewRequest(http.MethodGet, ts.URL+"/v1/firewall/blocklist", nil)
|
|
||||||
reqBlock.Header.Set("Authorization", "Bearer "+tok)
|
|
||||||
respBlock, err := client.Do(reqBlock)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
defer func() { _ = respBlock.Body.Close() }()
|
|
||||||
if respBlock.StatusCode != http.StatusForbidden {
|
|
||||||
t.Fatalf("pending blocklist want 403 got %d", respBlock.StatusCode)
|
|
||||||
}
|
|
||||||
|
|
||||||
reqApprove, _ := http.NewRequest(http.MethodPost, ts.URL+"/v1/firewall/clients/"+clientID+"/approve", nil)
|
|
||||||
reqApprove.Header.Set("Authorization", "Bearer opkey")
|
|
||||||
respApprove, err := client.Do(reqApprove)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
defer func() { _ = respApprove.Body.Close() }()
|
|
||||||
if respApprove.StatusCode != http.StatusOK {
|
|
||||||
b, _ := io.ReadAll(respApprove.Body)
|
|
||||||
t.Fatalf("approve status=%d body=%s", respApprove.StatusCode, b)
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err = srv.Store().CreateFirewallRule(tenant, nil, &store.FirewallRuleCreate{Action: "accept", Comment: "default"})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
reqBlock2, _ := http.NewRequest(http.MethodGet, ts.URL+"/v1/firewall/blocklist", nil)
|
|
||||||
reqBlock2.Header.Set("Authorization", "Bearer "+tok)
|
|
||||||
respBlock2, err := client.Do(reqBlock2)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
defer func() { _ = respBlock2.Body.Close() }()
|
|
||||||
if respBlock2.StatusCode != http.StatusOK {
|
|
||||||
b, _ := io.ReadAll(respBlock2.Body)
|
|
||||||
t.Fatalf("blocklist status=%d body=%s", respBlock2.StatusCode, b)
|
|
||||||
}
|
|
||||||
var bl map[string]any
|
|
||||||
if err := json.NewDecoder(respBlock2.Body).Decode(&bl); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
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) {
|
|
||||||
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()
|
|
||||||
|
|
||||||
body := `{"name":"x","client_token":"evobgp_fw_` + strings.Repeat("a", 40) + `"}`
|
|
||||||
req, _ := http.NewRequest(http.MethodPost, ts.URL+"/v1/firewall/enroll", strings.NewReader(body))
|
|
||||||
req.Header.Set("Content-Type", "application/json")
|
|
||||||
req.Header.Set("X-EvoBGP-Seed", "deadbeef")
|
|
||||||
resp, err := ts.Client().Do(req)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
defer func() { _ = resp.Body.Close() }()
|
|
||||||
if resp.StatusCode != http.StatusForbidden {
|
|
||||||
t.Fatalf("want 403 got %d", resp.StatusCode)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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 {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
func() {
|
if tc.auth {
|
||||||
defer func() { _ = resp.Body.Close() }()
|
req.Header.Set("Authorization", "Bearer opkey")
|
||||||
if resp.StatusCode != http.StatusOK {
|
}
|
||||||
b, _ := io.ReadAll(resp.Body)
|
resp, err := client.Do(req)
|
||||||
t.Fatalf("%s status=%d body=%s", path, resp.StatusCode, b)
|
if err != nil {
|
||||||
}
|
t.Fatal(err)
|
||||||
ct := resp.Header.Get("Content-Type")
|
}
|
||||||
if !strings.Contains(ct, "shellscript") {
|
_ = resp.Body.Close()
|
||||||
t.Fatalf("%s content-type=%q", path, ct)
|
if resp.StatusCode != http.StatusGone {
|
||||||
}
|
t.Fatalf("%s %s: want 410 got %d", tc.method, tc.path, resp.StatusCode)
|
||||||
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)
|
|
||||||
if len(h) != 32 {
|
|
||||||
t.Fatalf("hash len %d", len(h))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user