diff --git a/apps/web/src/components/firewall/firewall-clients-grid.tsx b/apps/web/src/components/firewall/firewall-clients-grid.tsx deleted file mode 100644 index 345d869..0000000 --- a/apps/web/src/components/firewall/firewall-clients-grid.tsx +++ /dev/null @@ -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[]>( - () => [ - { - accessorKey: 'name', - header: ({ column }) => , - cell: ({ row }) => ( - - ), - meta: { headerTitle: 'Имя' }, - }, - { - accessorKey: 'status', - header: ({ column }) => , - cell: ({ row }) => , - meta: { headerTitle: 'Статус' }, - }, - { - id: 'last_seen_at', - accessorFn: (row) => row.last_seen_at ?? '', - header: ({ column }) => , - cell: ({ row }) => ( - {row.original.last_seen_at?.slice(0, 19) ?? '—'} - ), - 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 ( - - {c.last_apply_status ?? '—'} - {c.last_apply_prefix_count != null ? ` (${c.last_apply_prefix_count})` : ''} - - ) - }, - 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 - } - return ( - - {dropped ? ↓{dropped} : null} - {dropped && accepted ? ' · ' : null} - {accepted ? ↑{accepted} : null} - - ) - }, - meta: { headerTitle: 'Пакеты' }, - }, - { - id: 'actions', - enableSorting: false, - header: () => null, - cell: ({ row }) => { - const c = row.original - return ( -
- {c.status === 'pending' ? ( - <> - - - Отклонить - - } - title="Отклонить запрос?" - description={`${c.name}${c.hostname ? ` (${c.hostname})` : ''} — запись будет удалена, токен перестанет работать.`} - confirmLabel="Отклонить" - destructive - onConfirm={() => onReject(c.id)} - /> - - ) : null} - {c.status === 'approved' ? ( - - Удалить - - } - title="Удалить клиент?" - description={`${c.name} — запись будет удалена, blocklist и токен перестанут работать.`} - confirmLabel="Удалить" - destructive - onConfirm={() => onReject(c.id)} - /> - ) : null} -
- ) - }, - }, - ], - [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 ( - - ) -} diff --git a/apps/web/src/components/firewall/firewall-rule-create-dialog.tsx b/apps/web/src/components/firewall/firewall-rule-create-dialog.tsx deleted file mode 100644 index e56d036..0000000 --- a/apps/web/src/components/firewall/firewall-rule-create-dialog.tsx +++ /dev/null @@ -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(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 ( - - - - Добавить - - - } - > - v && setAction(v as 'block' | 'accept')} - /> - -
- - setComment(e.target.value)} - /> -
-
- ) -} diff --git a/apps/web/src/components/firewall/firewall-rules-grid.tsx b/apps/web/src/components/firewall/firewall-rules-grid.tsx deleted file mode 100644 index d6bd550..0000000 --- a/apps/web/src/components/firewall/firewall-rules-grid.tsx +++ /dev/null @@ -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[]>( - () => [ - { - accessorKey: 'priority', - header: ({ column }) => , - cell: ({ row }) => row.original.priority, - meta: { headerTitle: '#' }, - }, - { - accessorKey: 'action', - header: ({ column }) => , - cell: ({ row }) => ( - - ), - meta: { headerTitle: 'Действие' }, - }, - { - id: 'community', - enableSorting: false, - header: 'Community', - cell: ({ row }) => ( - - {row.original.community_id - ? communityLabel(row.original.community_id, communities) - : 'Все'} - - ), - 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 ( - - Удалить - - } - 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 ( - - ) -} diff --git a/apps/web/src/components/layout/app-shell.tsx b/apps/web/src/components/layout/app-shell.tsx index bf7f839..848f6ec 100644 --- a/apps/web/src/components/layout/app-shell.tsx +++ b/apps/web/src/components/layout/app-shell.tsx @@ -9,7 +9,6 @@ import { BookText, KeyRound, ServerCog, - Shield, Search, } from 'lucide-react' @@ -90,7 +89,6 @@ const NAV_GROUPS: NavGroup[] = [ label: 'Операции', items: [ { to: '/operations', label: 'Операции', icon: Cog, description: 'Ревизии и apply', search: { tab: 'revisions' } }, - { to: '/firewall', label: 'Файрвол', icon: Shield, description: 'Клиенты и правила' }, { to: '/schedule', label: 'Задачи', icon: ListChecks, description: 'Расписание refresh' }, { to: '/monitoring', label: 'Мониторинг', icon: Activity, description: 'Health и BIRD', search: { tab: 'system' } }, ], diff --git a/apps/web/src/lib/auth.ts b/apps/web/src/lib/auth.ts index a3bf0a6..e072cc2 100644 --- a/apps/web/src/lib/auth.ts +++ b/apps/web/src/lib/auth.ts @@ -269,7 +269,6 @@ export function permissionForPath(pathname: string): string | null { if (pathname.startsWith('/network')) return 'bgp:network:read' if (pathname.startsWith('/directories')) return 'bgp:directories: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('/monitoring')) return 'bgp:monitoring:read' if (pathname.startsWith('/access')) return 'bgp:access:admin' @@ -289,7 +288,6 @@ export function firstAllowedPath(): string { '/network', '/directories', '/operations', - '/firewall', '/schedule', '/monitoring', '/access', diff --git a/apps/web/src/queries/firewall.ts b/apps/web/src/queries/firewall.ts deleted file mode 100644 index e442713..0000000 --- a/apps/web/src/queries/firewall.ts +++ /dev/null @@ -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({ - queryKey: firewallKeys.installContext(), - queryFn: () => apiJSON('/v1/firewall/install-context'), - staleTime: 60_000, - retry: false, - }) -} - -export function firewallClientsQueryOptions() { - return queryOptions({ - queryKey: firewallKeys.clients(), - queryFn: () => apiJSON('/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({ - queryKey: firewallKeys.rules(scope, clientId), - queryFn: () => apiJSON(`/v1/firewall/rules${qs}`), - staleTime: 15_000, - }) -} - -export function useApproveFirewallClient() { - const qc = useQueryClient() - return useMutation({ - mutationFn: (id: string) => - apiJSON(`/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(`/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) => - apiJSON('/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(`/v1/firewall/rules/${id}`, { method: 'DELETE' }), - onSuccess: () => { - void qc.invalidateQueries({ queryKey: firewallKeys.all }) - }, - }) -} diff --git a/apps/web/src/routes/_auth/firewall.tsx b/apps/web/src/routes/_auth/firewall.tsx deleted file mode 100644 index e627fa1..0000000 --- a/apps/web/src/routes/_auth/firewall.tsx +++ /dev/null @@ -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() || '' - 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 ( -
- { - void clientsQ.refetch() - void rulesQ.refetch() - }} - disabled={clientsQ.isFetching} - > - - Обновить - - } - /> - - - - Установка на сервер - - } - description="Команда для root на целевом Linux (bash, curl). После регистрации — одобрите клиента во вкладке «Запросы»." - contentClassName="flex flex-col gap-4 py-4" - > -
-
- - setClientName(e.target.value)} /> -
-
- - setCpUrl(e.target.value)} /> -
-
- - -

- {installCtxQ.isLoading - ? 'Загрузка с плоскости управления…' - : installCtx?.bundle_seed_configured - ? 'Из переменной EVOBGP_BUNDLE_SEED_HEX на CP (docker compose / .env)' - : 'На CP не задан EVOBGP_BUNDLE_SEED_HEX — регистрация невозможна'} -

-
-
-
{installCmd}
- -
- - 0 ? 'warning-light' : 'primary-light', - }, - ]} - > - - - void clientsQ.refetch()} - skeleton={} - > - {() => ( - approve.mutate(id)} - onReject={(id) => deleteClient.mutate(id)} - approvePending={approve.isPending} - rejectPending={deleteClient.isPending} - /> - )} - - - - - - setCreateRuleOpen(true)}> - - Добавить правило - - } - > - void rulesQ.refetch()} - skeleton={} - > - {() => ( - deleteRule.mutate(id)} - deletePending={deleteRule.isPending} - /> - )} - - - - - - - - void clientsQ.refetch()} - skeleton={} - > - {() => ( - approve.mutate(id)} - onReject={(id) => deleteClient.mutate(id)} - approvePending={approve.isPending} - rejectPending={deleteClient.isPending} - emptyTitle="Нет ожидающих запросов" - /> - )} - - - - -
- ) -} diff --git a/apps/web/src/types/api.ts b/apps/web/src/types/api.ts index 9aff8f0..b1aa61a 100644 --- a/apps/web/src/types/api.ts +++ b/apps/web/src/types/api.ts @@ -375,41 +375,5 @@ export type AsyncJobAccepted = { job_id: string } -// ---- Firewall blocklist ---- -export type FirewallClient = { - id: string - name: string - hostname?: string - token_prefix: string - status: 'pending' | 'approved' | 'revoked' - last_seen_at?: string | null - last_seen_at_source?: string - last_apply_at?: string | null - last_apply_status?: string - last_apply_prefix_count?: number - last_apply_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 -} +// Firewall blocklist types removed — the firewall subsystem moved to the standalone +// EvoFirewall service. See docs/firewall.md. diff --git a/docs/firewall.md b/docs/firewall.md index de7cb19..1bdfa5c 100644 --- a/docs/firewall.md +++ b/docs/firewall.md @@ -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_*` локально. -2. **Approve** — operator в Web UI (`/firewall` → Запросы). -3. **Sync** — `GET /v1/firewall/blocklist` с `Authorization: Bearer `. +Таблицы `firewall_client` / `firewall_rule` в БД оставлены (не удаляются миграциями) для истории; API/UI/scripts больше не обслуживают их. -## Политика block/accept +## Миграция клиентов -- **`block`** — добавить префиксы выбранного BGP community в kernel blocklist. -- **`accept`** — не блокировать префиксы этого community. -- **Community** — правило применяется к префиксам с этим `community_id` в опубликованной revision; пустое значение («Все») — ко всем communities. -- **Default** — accept (пустой blocklist без явных `block`). +1. Разверните EvoFirewall (auth-portal app id `fw`). +2. Переустановите агенты one-liner'ом EvoFirewall (`/v1/agent/install.sh`). +3. Для списков по community создайте IP list type `evobgp_community` и укажите `EVOBGP_API_URL` + token в настройках EvoFirewall. -Порядок: сначала 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:///v1/firewall/install.sh | \ - EVOBGP_CP_URL=https:// \ - EVOBGP_SEED= \ - 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). +Старые токены `evobgp_fw_*` **не** переносятся — только re-enroll. diff --git a/internal/httpapi/firewall_replicate.go b/internal/httpapi/firewall_replicate.go deleted file mode 100644 index 68dade1..0000000 --- a/internal/httpapi/firewall_replicate.go +++ /dev/null @@ -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 -} diff --git a/internal/httpapi/routes.go b/internal/httpapi/routes.go index 4baa9e1..8896b73 100644 --- a/internal/httpapi/routes.go +++ b/internal/httpapi/routes.go @@ -36,9 +36,9 @@ func (s *Server) Handler() http.Handler { s.mux.HandleFunc("GET /v1/ready", s.handleReady) s.mux.HandleFunc("GET /v1/version", s.handleVersion) s.mux.HandleFunc("GET /v1/auth/config", s.handleAuthConfigPublic) - s.mux.HandleFunc("POST /v1/firewall/enroll", s.handleFirewallEnrollPublic) - s.mux.HandleFunc("GET /v1/firewall/install.sh", s.handleFirewallInstallScript) - s.mux.HandleFunc("GET /v1/firewall/sync-script", s.handleFirewallSyncScript) + // Firewall subsystem moved to the standalone EvoFirewall service; see docs/firewall.md. + // Registered on the public mux so it wins over the "/v1/" subtree below regardless of auth. + s.mux.HandleFunc("/v1/firewall/", s.handleFirewallGone) s.mux.Handle("/v1/", s.authMiddleware(wrappedV1)) return s.withCORS(observability.HTTPMiddleware(s.mux)) } @@ -87,7 +87,6 @@ func (s *Server) registerV1(m *http.ServeMux) { s.registerPostgresMaintenanceRoutes(m) s.registerMaintenanceRoutes(m) s.registerRuntimeLogsRoutes(m) - s.registerFirewallRoutes(m) } func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) { diff --git a/internal/httpapi/routes_firewall.go b/internal/httpapi/routes_firewall.go index 2409692..b68f3c0 100644 --- a/internal/httpapi/routes_firewall.go +++ b/internal/httpapi/routes_firewall.go @@ -1,709 +1,16 @@ package httpapi -import ( - "context" - "crypto/sha256" - "encoding/hex" - "encoding/json" - "errors" - "io" - "net/http" - "os" - "path/filepath" - "sort" - "strings" - "time" +import "net/http" - "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) - 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 +// handleFirewallGone responds 410 Gone for every legacy /v1/firewall/* endpoint +// (enroll, install.sh, sync-script, clients, rules, blocklist, apply-report, +// heartbeat, install-context). The firewall subsystem (client enrollment, +// block/accept rules, blocklist distribution) has moved to the standalone +// EvoFirewall service; see docs/firewall.md. +// +// Existing firewall_client / firewall_rule tables and store code (memory_firewall.go, +// postgres_firewall.go, firewall_types.go) are intentionally left in place — only the +// HTTP surface is decommissioned here. +func (s *Server) handleFirewallGone(w http.ResponseWriter, r *http.Request) { + writeProblem(w, http.StatusGone, "Gone", "firewall feature moved to EvoFirewall — see docs/firewall.md") } diff --git a/internal/httpapi/routes_firewall_test.go b/internal/httpapi/routes_firewall_test.go index b088d00..be50aa9 100644 --- a/internal/httpapi/routes_firewall_test.go +++ b/internal/httpapi/routes_firewall_test.go @@ -1,19 +1,15 @@ package httpapi import ( - "encoding/json" - "errors" - "io" "net/http" "net/http/httptest" - "strings" "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}) if err != nil { t.Fatal(err) @@ -26,279 +22,37 @@ func TestFirewallEnrollAndBlocklist(t *testing.T) { defer ts.Close() client := ts.Client() - tok := "evobgp_fw_testtoken123456789012345678901234" - enrollBody := `{"name":"web-01","hostname":"web-01.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) + cases := []struct { + method string + path string + auth bool // send a valid operator bearer token + }{ + {http.MethodPost, "/v1/firewall/enroll", false}, + {http.MethodGet, "/v1/firewall/install.sh", false}, + {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() }() - 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") - } - - 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) + for _, tc := range cases { + req, err := http.NewRequest(tc.method, ts.URL+tc.path, nil) 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) - if len(h) != 32 { - t.Fatalf("hash len %d", len(h)) + if tc.auth { + req.Header.Set("Authorization", "Bearer opkey") + } + resp, err := client.Do(req) + if err != nil { + t.Fatal(err) + } + _ = resp.Body.Close() + if resp.StatusCode != http.StatusGone { + t.Fatalf("%s %s: want 410 got %d", tc.method, tc.path, resp.StatusCode) + } } }