feat(firewall): implement firewall blocklist feature with client management and policy rules
CI / changes (push) Successful in 12s
CI / commitlint (push) Has been skipped
CI / openapi (push) Successful in 25s
CI / web (push) Successful in 46s
CI / go (push) Successful in 1m15s
CI / bird2 (push) Successful in 18s
CI / release (push) Successful in 3m59s
CI / changes (push) Successful in 12s
CI / commitlint (push) Has been skipped
CI / openapi (push) Successful in 25s
CI / web (push) Successful in 46s
CI / go (push) Successful in 1m15s
CI / bird2 (push) Successful in 18s
CI / release (push) Successful in 3m59s
Introduced a comprehensive firewall blocklist feature, allowing for the management of firewall clients and their associated rules. This includes endpoints for enrolling clients, listing clients and rules, and reporting apply statuses. Enhanced the API to support firewall operations, including the ability to handle block/accept policies. Updated the documentation to reflect these changes and added necessary components in the web UI for better user interaction. Additionally, modified the agent server to support firewall failover and integrated firewall functionality into the existing architecture.
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"pid": 40060,
|
||||
"pid": 39884,
|
||||
"version": "0.9.9",
|
||||
"socketPath": "\\\\.\\pipe\\codegraph-97b92efdcc5351da",
|
||||
"startedAt": 1783395081742
|
||||
"startedAt": 1783489774882
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -9,6 +9,7 @@ import {
|
||||
BookText,
|
||||
KeyRound,
|
||||
ServerCog,
|
||||
Shield,
|
||||
} from 'lucide-react'
|
||||
|
||||
import {
|
||||
@@ -69,6 +70,7 @@ const NAV_GROUPS: NavGroup[] = [
|
||||
label: 'Операции',
|
||||
items: [
|
||||
{ to: '/operations', label: 'Операции', icon: Cog },
|
||||
{ to: '/firewall', label: 'Firewall', icon: Shield },
|
||||
{ to: '/schedule', label: 'Задачи', icon: ListChecks },
|
||||
{ to: '/monitoring', label: 'Мониторинг', icon: Activity },
|
||||
],
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { queryOptions, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { apiJSON } from '@/lib/api-client'
|
||||
import type { FirewallClient, FirewallClientsResponse, FirewallRule, FirewallRulesResponse } from '@/types/api'
|
||||
|
||||
export const firewallKeys = {
|
||||
all: ['firewall'] as const,
|
||||
clients: () => [...firewallKeys.all, 'clients'] as const,
|
||||
rules: (scope: string, clientId?: string) =>
|
||||
[...firewallKeys.all, 'rules', scope, clientId ?? ''] as const,
|
||||
}
|
||||
|
||||
export function firewallClientsQueryOptions() {
|
||||
return queryOptions<FirewallClientsResponse>({
|
||||
queryKey: firewallKeys.clients(),
|
||||
queryFn: () => apiJSON<FirewallClientsResponse>('/v1/firewall/clients'),
|
||||
staleTime: 15_000,
|
||||
})
|
||||
}
|
||||
|
||||
export function firewallRulesQueryOptions(scope: 'tenant' | 'client', clientId?: string) {
|
||||
const qs =
|
||||
scope === 'client' && clientId
|
||||
? `?scope=client&client_id=${encodeURIComponent(clientId)}`
|
||||
: '?scope=tenant'
|
||||
return queryOptions<FirewallRulesResponse>({
|
||||
queryKey: firewallKeys.rules(scope, clientId),
|
||||
queryFn: () => apiJSON<FirewallRulesResponse>(`/v1/firewall/rules${qs}`),
|
||||
staleTime: 15_000,
|
||||
})
|
||||
}
|
||||
|
||||
export function useApproveFirewallClient() {
|
||||
const qc = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: (id: string) =>
|
||||
apiJSON<FirewallClient>(`/v1/firewall/clients/${id}/approve`, { method: 'POST' }),
|
||||
onSuccess: () => {
|
||||
void qc.invalidateQueries({ queryKey: firewallKeys.clients() })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useCreateFirewallRule() {
|
||||
const qc = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: (body: Record<string, unknown>) =>
|
||||
apiJSON<FirewallRule>('/v1/firewall/rules', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
onSuccess: () => {
|
||||
void qc.invalidateQueries({ queryKey: firewallKeys.all })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useDeleteFirewallRule() {
|
||||
const qc = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: (id: string) =>
|
||||
apiJSON<void>(`/v1/firewall/rules/${id}`, { method: 'DELETE' }),
|
||||
onSuccess: () => {
|
||||
void qc.invalidateQueries({ queryKey: firewallKeys.all })
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,293 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { Copy, Info, RefreshCw, Shield } from 'lucide-react'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { Alert, AlertDescription, AlertTitle } from '@evobgp/ui/components/alert'
|
||||
import { Button } from '@evobgp/ui/components/button'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@evobgp/ui/components/card'
|
||||
import { Input } from '@evobgp/ui/components/input'
|
||||
import { Label } from '@evobgp/ui/components/label'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@evobgp/ui/components/tabs'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@evobgp/ui/components/table'
|
||||
|
||||
import { PageHeader } from '@/components/page-header'
|
||||
import { StatusBadge } from '@/components/status-badge'
|
||||
import {
|
||||
firewallClientsQueryOptions,
|
||||
firewallRulesQueryOptions,
|
||||
useApproveFirewallClient,
|
||||
useCreateFirewallRule,
|
||||
useDeleteFirewallRule,
|
||||
} from '@/queries/firewall'
|
||||
import type { FirewallClient } from '@/types/api'
|
||||
|
||||
export const Route = createFileRoute('/_auth/firewall')({
|
||||
component: FirewallPage,
|
||||
})
|
||||
|
||||
function FirewallPage() {
|
||||
const clientsQ = useQuery(firewallClientsQueryOptions())
|
||||
const rulesQ = useQuery(firewallRulesQueryOptions('tenant'))
|
||||
const approve = useApproveFirewallClient()
|
||||
const createRule = useCreateFirewallRule()
|
||||
const deleteRule = useDeleteFirewallRule()
|
||||
|
||||
const [clientName, setClientName] = useState('web-01')
|
||||
const [cpUrl, setCpUrl] = useState(() =>
|
||||
typeof window !== 'undefined' ? window.location.origin : 'https://api.example.com',
|
||||
)
|
||||
const [seed, setSeed] = useState('')
|
||||
const [ruleAction, setRuleAction] = useState<'block' | 'accept'>('block')
|
||||
const [ruleComment, setRuleComment] = useState('')
|
||||
|
||||
const clients = clientsQ.data?.items ?? []
|
||||
const pending = clients.filter((c) => c.status === 'pending')
|
||||
const rules = rulesQ.data?.items ?? []
|
||||
|
||||
const installCmd = useMemo(() => {
|
||||
const s = seed.trim() || '<bundle_seed_hex>'
|
||||
return `curl -fsSL ${cpUrl.replace(/\/$/, '')}/v1/firewall/install.sh | \\
|
||||
EVOBGP_CP_URL=${cpUrl.replace(/\/$/, '')} \\
|
||||
EVOBGP_SEED=${s} \\
|
||||
EVOBGP_CLIENT_NAME="${clientName}" \\
|
||||
bash`
|
||||
}, [clientName, cpUrl, seed])
|
||||
|
||||
async function copyInstall() {
|
||||
if (!seed.trim()) {
|
||||
toast.error('Укажите bundle seed')
|
||||
return
|
||||
}
|
||||
try {
|
||||
await navigator.clipboard.writeText(installCmd)
|
||||
toast.success('Команда скопирована')
|
||||
} catch {
|
||||
toast.error('Не удалось скопировать')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<PageHeader
|
||||
title="Firewall blocklist"
|
||||
description="Linux-серверы: синхронизация CIDR по policy block/accept"
|
||||
actions={
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
void clientsQ.refetch()
|
||||
void rulesQ.refetch()
|
||||
}}
|
||||
disabled={clientsQ.isFetching}
|
||||
>
|
||||
<RefreshCw className={clientsQ.isFetching ? 'animate-spin' : ''} />
|
||||
Обновить
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<Alert className="border-info/30 bg-info/5">
|
||||
<Info className="text-info" />
|
||||
<AlertTitle>Политика</AlertTitle>
|
||||
<AlertDescription>
|
||||
Только явный <strong>block</strong> добавляет IP в blocklist. Правила <strong>accept</strong> сами по себе
|
||||
не создают block all. Default — accept.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Shield className="size-5" />
|
||||
Установка на сервер
|
||||
</CardTitle>
|
||||
<CardDescription>One-liner для root на целевом Linux (bash, curl). После enroll — approve в «Запросы».</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-4">
|
||||
<div className="grid gap-4 sm:grid-cols-3">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="fw-name">Имя сервера</Label>
|
||||
<Input id="fw-name" value={clientName} onChange={(e) => setClientName(e.target.value)} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="fw-url">URL API</Label>
|
||||
<Input id="fw-url" value={cpUrl} onChange={(e) => setCpUrl(e.target.value)} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="fw-seed">Bundle seed</Label>
|
||||
<Input
|
||||
id="fw-seed"
|
||||
type="password"
|
||||
placeholder="EVOBGP_BUNDLE_SEED_HEX"
|
||||
value={seed}
|
||||
onChange={(e) => setSeed(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<pre className="overflow-x-auto rounded-md border bg-muted/40 p-3 text-xs">{installCmd}</pre>
|
||||
<Button variant="outline" size="sm" className="w-fit" onClick={copyInstall}>
|
||||
<Copy />
|
||||
Копировать команду
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Tabs defaultValue="clients">
|
||||
<TabsList>
|
||||
<TabsTrigger value="clients">Клиенты ({clients.length})</TabsTrigger>
|
||||
<TabsTrigger value="rules">Правила ({rules.length})</TabsTrigger>
|
||||
<TabsTrigger value="requests">Запросы ({pending.length})</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="clients" className="mt-4">
|
||||
<ClientsTable clients={clients} onApprove={(id) => approve.mutate(id)} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="rules" className="mt-4 space-y-4">
|
||||
<div className="flex flex-wrap items-end gap-2">
|
||||
<div className="space-y-1">
|
||||
<Label>Действие</Label>
|
||||
<select
|
||||
className="border-input bg-background h-9 rounded-md border px-2 text-sm"
|
||||
value={ruleAction}
|
||||
onChange={(e) => setRuleAction(e.target.value as 'block' | 'accept')}
|
||||
>
|
||||
<option value="block">block</option>
|
||||
<option value="accept">accept</option>
|
||||
</select>
|
||||
</div>
|
||||
<Input
|
||||
className="max-w-xs"
|
||||
placeholder="Комментарий"
|
||||
value={ruleComment}
|
||||
onChange={(e) => setRuleComment(e.target.value)}
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
createRule.mutate({
|
||||
scope: 'tenant',
|
||||
action: ruleAction,
|
||||
comment: ruleComment,
|
||||
})
|
||||
}
|
||||
>
|
||||
Добавить правило
|
||||
</Button>
|
||||
</div>
|
||||
<RulesTable rules={rules} onDelete={(id) => deleteRule.mutate(id)} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="requests" className="mt-4">
|
||||
<ClientsTable
|
||||
clients={pending}
|
||||
onApprove={(id) => approve.mutate(id)}
|
||||
emptyTitle="Нет pending-запросов"
|
||||
/>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ClientsTable({
|
||||
clients,
|
||||
onApprove,
|
||||
emptyTitle = 'Нет клиентов',
|
||||
}: {
|
||||
clients: FirewallClient[]
|
||||
onApprove: (id: string) => void
|
||||
emptyTitle?: string
|
||||
}) {
|
||||
if (clients.length === 0) {
|
||||
return <p className="text-muted-foreground text-sm">{emptyTitle}</p>
|
||||
}
|
||||
return (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Имя</TableHead>
|
||||
<TableHead>Статус</TableHead>
|
||||
<TableHead>Last seen</TableHead>
|
||||
<TableHead>Apply</TableHead>
|
||||
<TableHead />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{clients.map((c) => (
|
||||
<TableRow key={c.id}>
|
||||
<TableCell>
|
||||
<div className="font-medium">{c.name}</div>
|
||||
<div className="text-muted-foreground text-xs">{c.hostname || c.token_prefix}</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<StatusBadge status={c.status} />
|
||||
</TableCell>
|
||||
<TableCell className="text-xs">{c.last_seen_at?.slice(0, 19) ?? '—'}</TableCell>
|
||||
<TableCell className="text-xs">
|
||||
{c.last_apply_status ?? '—'}
|
||||
{c.last_apply_prefix_count != null ? ` (${c.last_apply_prefix_count})` : ''}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{c.status === 'pending' ? (
|
||||
<Button size="sm" variant="outline" onClick={() => onApprove(c.id)}>
|
||||
Approve
|
||||
</Button>
|
||||
) : null}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)
|
||||
}
|
||||
|
||||
function RulesTable({
|
||||
rules,
|
||||
onDelete,
|
||||
}: {
|
||||
rules: { id: string; priority: number; action: string; comment?: string }[]
|
||||
onDelete: (id: string) => void
|
||||
}) {
|
||||
if (rules.length === 0) {
|
||||
return <p className="text-muted-foreground text-sm">Нет правил — blocklist пуст (default accept).</p>
|
||||
}
|
||||
return (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>#</TableHead>
|
||||
<TableHead>Действие</TableHead>
|
||||
<TableHead>Комментарий</TableHead>
|
||||
<TableHead />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{rules.map((r) => (
|
||||
<TableRow key={r.id}>
|
||||
<TableCell>{r.priority}</TableCell>
|
||||
<TableCell>
|
||||
<StatusBadge status={r.action} label={r.action} />
|
||||
</TableCell>
|
||||
<TableCell>{r.comment || '—'}</TableCell>
|
||||
<TableCell>
|
||||
<Button size="sm" variant="ghost" onClick={() => onDelete(r.id)}>
|
||||
Удалить
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)
|
||||
}
|
||||
@@ -343,3 +343,33 @@ export type ApiKeyCreated = ApiKey & { token: string }
|
||||
export type AsyncJobAccepted = {
|
||||
job_id: string
|
||||
}
|
||||
|
||||
// ---- Firewall blocklist ----
|
||||
export type FirewallClient = {
|
||||
id: string
|
||||
name: string
|
||||
hostname?: string
|
||||
token_prefix: string
|
||||
status: 'pending' | 'approved' | 'revoked'
|
||||
last_seen_at?: string | null
|
||||
last_seen_at_source?: string
|
||||
last_apply_at?: string | null
|
||||
last_apply_status?: string
|
||||
last_apply_prefix_count?: number
|
||||
last_apply_source?: string
|
||||
client_version?: string
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export type FirewallClientsResponse = { items: FirewallClient[] }
|
||||
|
||||
export type FirewallRule = {
|
||||
id: string
|
||||
client_id?: string | null
|
||||
priority: number
|
||||
action: 'block' | 'accept'
|
||||
community_id?: string | null
|
||||
comment?: string
|
||||
}
|
||||
|
||||
export type FirewallRulesResponse = { items: FirewallRule[] }
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"root":["./src/main.tsx","./src/routetree.gen.ts","./src/components/confirm-dialog.tsx","./src/components/empty-state.tsx","./src/components/loading-button.tsx","./src/components/mode-toggle.tsx","./src/components/page-header.tsx","./src/components/page-shell.tsx","./src/components/query-state.tsx","./src/components/section-cards.tsx","./src/components/skeletons.tsx","./src/components/status-badge.tsx","./src/components/theme-provider.tsx","./src/components/truncated-text.tsx","./src/components/access/access-api-keys-card.tsx","./src/components/access/api-key-create-dialog.tsx","./src/components/access/api-key-token-dialog.tsx","./src/components/layout/app-shell.tsx","./src/components/modules/community-select.tsx","./src/components/modules/module-as-entry-dialog.tsx","./src/components/modules/module-cdn-source-dialog.tsx","./src/components/modules/module-domain-entry-dialog.tsx","./src/components/modules/module-entries-section.tsx","./src/components/modules/module-ip-range-entry-dialog.tsx","./src/components/modules/module-kpi-cards.tsx","./src/components/reui/autocomplete.tsx","./src/components/reui/badge.tsx","./src/components/reui/date-selector.tsx","./src/components/reui/filters.tsx","./src/components/reui/number-field.tsx","./src/components/reui/data-grid/data-grid-column-filter.tsx","./src/components/reui/data-grid/data-grid-column-header.tsx","./src/components/reui/data-grid/data-grid-column-visibility.tsx","./src/components/reui/data-grid/data-grid-pagination.tsx","./src/components/reui/data-grid/data-grid-scroll-area.tsx","./src/components/reui/data-grid/data-grid-table-dnd-rows.tsx","./src/components/reui/data-grid/data-grid-table-dnd.tsx","./src/components/reui/data-grid/data-grid-table-virtual.tsx","./src/components/reui/data-grid/data-grid-table.tsx","./src/components/reui/data-grid/data-grid.tsx","./src/lib/api-client.ts","./src/lib/queryclient.ts","./src/lib/router.ts","./src/lib/ui-labels.ts","./src/lib/access/api-key-labels.ts","./src/lib/modules/display.ts","./src/lib/modules/helpers.ts","./src/queries/api-keys.ts","./src/queries/auth.ts","./src/queries/directories.ts","./src/queries/modules.ts","./src/queries/monitoring.ts","./src/queries/network.ts","./src/queries/operations.ts","./src/queries/overview.ts","./src/queries/settings.ts","./src/routes/__root.tsx","./src/routes/_auth.tsx","./src/routes/index.tsx","./src/routes/_auth/access.tsx","./src/routes/_auth/dashboard.tsx","./src/routes/_auth/directories.tsx","./src/routes/_auth/monitoring.tsx","./src/routes/_auth/network.tsx","./src/routes/_auth/operations.tsx","./src/routes/_auth/schedule.tsx","./src/routes/_auth/settings.tsx","./src/routes/_auth/tenant-settings.tsx","./src/routes/_auth/modules/$moduleid.tsx","./src/routes/_auth/modules/index.tsx","./src/routes/_auth/modules/new.tsx","./src/types/api.ts","./vite.config.ts"],"version":"5.9.3"}
|
||||
{"root":["./src/main.tsx","./src/routetree.gen.ts","./src/components/confirm-dialog.tsx","./src/components/empty-state.tsx","./src/components/loading-button.tsx","./src/components/mode-toggle.tsx","./src/components/page-header.tsx","./src/components/page-shell.tsx","./src/components/query-state.tsx","./src/components/section-cards.tsx","./src/components/skeletons.tsx","./src/components/status-badge.tsx","./src/components/theme-provider.tsx","./src/components/truncated-text.tsx","./src/components/access/access-api-keys-card.tsx","./src/components/access/api-key-create-dialog.tsx","./src/components/access/api-key-token-dialog.tsx","./src/components/layout/app-shell.tsx","./src/components/modules/community-select.tsx","./src/components/modules/module-as-entry-dialog.tsx","./src/components/modules/module-cdn-source-dialog.tsx","./src/components/modules/module-domain-entry-dialog.tsx","./src/components/modules/module-entries-section.tsx","./src/components/modules/module-ip-range-entry-dialog.tsx","./src/components/modules/module-kpi-cards.tsx","./src/components/reui/autocomplete.tsx","./src/components/reui/badge.tsx","./src/components/reui/date-selector.tsx","./src/components/reui/filters.tsx","./src/components/reui/number-field.tsx","./src/components/reui/data-grid/data-grid-column-filter.tsx","./src/components/reui/data-grid/data-grid-column-header.tsx","./src/components/reui/data-grid/data-grid-column-visibility.tsx","./src/components/reui/data-grid/data-grid-pagination.tsx","./src/components/reui/data-grid/data-grid-scroll-area.tsx","./src/components/reui/data-grid/data-grid-table-dnd-rows.tsx","./src/components/reui/data-grid/data-grid-table-dnd.tsx","./src/components/reui/data-grid/data-grid-table-virtual.tsx","./src/components/reui/data-grid/data-grid-table.tsx","./src/components/reui/data-grid/data-grid.tsx","./src/lib/api-client.ts","./src/lib/queryclient.ts","./src/lib/router.ts","./src/lib/ui-labels.ts","./src/lib/access/api-key-labels.ts","./src/lib/modules/display.ts","./src/lib/modules/helpers.ts","./src/queries/api-keys.ts","./src/queries/auth.ts","./src/queries/directories.ts","./src/queries/firewall.ts","./src/queries/modules.ts","./src/queries/monitoring.ts","./src/queries/network.ts","./src/queries/operations.ts","./src/queries/overview.ts","./src/queries/settings.ts","./src/routes/__root.tsx","./src/routes/_auth.tsx","./src/routes/index.tsx","./src/routes/_auth/access.tsx","./src/routes/_auth/dashboard.tsx","./src/routes/_auth/directories.tsx","./src/routes/_auth/firewall.tsx","./src/routes/_auth/monitoring.tsx","./src/routes/_auth/network.tsx","./src/routes/_auth/operations.tsx","./src/routes/_auth/schedule.tsx","./src/routes/_auth/settings.tsx","./src/routes/_auth/tenant-settings.tsx","./src/routes/_auth/modules/$moduleid.tsx","./src/routes/_auth/modules/index.tsx","./src/routes/_auth/modules/new.tsx","./src/types/api.ts","./vite.config.ts"],"version":"5.9.3"}
|
||||
@@ -43,6 +43,7 @@ opkey|01ARZ3NDEKTSV4RRFFQ69G5FAV|operator,nodekey|01ARZ3NDEKTSV4RRFFQ69G5FAV|nod
|
||||
| `editor` | 2 | Чтение + создание/изменение CRUD (модули, записи, peers и т.д.), без опасных операций уровня оператора. |
|
||||
| `operator` | 3 | Полный операторский доступ: apply, rollback, настройки, отмена задач и т.п. (как задано в handlers). |
|
||||
| `node` | отдельная | Только API для реплики: latest revision, скачивание бандла, enroll. Роль **`node` запрещена** для обычного CRUD — ответ `403 Forbidden`. |
|
||||
| `firewall` | отдельная | Только data-plane firewall-клиента: `GET /v1/firewall/blocklist`, `POST /v1/firewall/apply-report`, `POST /v1/firewall/heartbeat`. Токен в таблице `firewall_client`, не в `api_key`. См. [firewall.md](firewall.md). |
|
||||
|
||||
Обратное ограничение: для эндпоинтов ноды требуется именно роль **`node`**; остальные роли получают отказ.
|
||||
|
||||
|
||||
@@ -41,7 +41,8 @@
|
||||
| `broker` | Опциональный `EVOBGP_BROKER_URL` для будущей шины; сейчас задачи только in-process (`jobs.Registry`), пакет лишь логирует факт настройки URL. |
|
||||
| `pipeline` | Ingest+render в одном шаге для `module_refresh`: выборка префиксов (CDN/AS/IP/пустые DOMAINS), `CreateRenderRevision`, превью BIRD через `birdfmt`. |
|
||||
| `nodedispatch` | Panel→Node HTTP wake-up (`POST /v1/agent/sync`) после `deploy_apply`. |
|
||||
| `agentserver` | HTTP API на реплике (`serve`): sync + health для Traefik. |
|
||||
| `agentserver` | HTTP API на реплике (`serve`): sync + health для Traefik; опционально firewall failover (`/v1/firewall/*`). |
|
||||
| `firewall` | Вычисление policy block/accept → плоский CIDR blocklist. |
|
||||
|
||||
## Удалённые спикеры
|
||||
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
# Firewall blocklist
|
||||
|
||||
Подсистема синхронизации blocklist на произвольные Linux-серверы через bash-скрипт и HTTP API.
|
||||
|
||||
## Авторизация
|
||||
|
||||
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 <client_token>`.
|
||||
|
||||
## Политика block/accept
|
||||
|
||||
- **`block`** — добавить префиксы community в kernel blocklist.
|
||||
- **`accept`** — не блокировать.
|
||||
- **Default** — accept (пустой blocklist без явных `block`).
|
||||
|
||||
Правила задаются на уровне tenant (по умолчанию) и per-server (overrides клиента). Client scope проверяется раньше tenant-default.
|
||||
|
||||
## Установка на сервер
|
||||
|
||||
```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`.
|
||||
|
||||
## 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).
|
||||
@@ -58,6 +58,8 @@ tags:
|
||||
Файловые runtime-логи Docker-сервисов (каталог EVOBGP_RUNTIME_LOGS_DIR).
|
||||
Доступно только в процессе evobgp-all с примонтированным volume; иначе 503.
|
||||
Просмотр — viewer+; очистка — operator+ (синхронно, с audit).
|
||||
- name: Firewall
|
||||
description: Linux firewall blocklist clients, policy rules (block/accept), and data-plane sync.
|
||||
|
||||
security:
|
||||
- bearerAuth: []
|
||||
@@ -1579,6 +1581,68 @@ components:
|
||||
type: string
|
||||
format: date-time
|
||||
|
||||
FirewallClient:
|
||||
type: object
|
||||
properties:
|
||||
id:
|
||||
$ref: "#/components/schemas/ResourceId"
|
||||
name:
|
||||
type: string
|
||||
hostname:
|
||||
type: string
|
||||
token_prefix:
|
||||
type: string
|
||||
status:
|
||||
type: string
|
||||
enum: [pending, approved, revoked]
|
||||
last_seen_at:
|
||||
type: string
|
||||
format: date-time
|
||||
last_apply_at:
|
||||
type: string
|
||||
format: date-time
|
||||
last_apply_status:
|
||||
type: string
|
||||
last_apply_prefix_count:
|
||||
type: integer
|
||||
client_version:
|
||||
type: string
|
||||
|
||||
FirewallRule:
|
||||
type: object
|
||||
properties:
|
||||
id:
|
||||
$ref: "#/components/schemas/ResourceId"
|
||||
client_id:
|
||||
$ref: "#/components/schemas/ResourceId"
|
||||
nullable: true
|
||||
priority:
|
||||
type: integer
|
||||
action:
|
||||
type: string
|
||||
enum: [block, accept]
|
||||
community_id:
|
||||
$ref: "#/components/schemas/ResourceId"
|
||||
nullable: true
|
||||
comment:
|
||||
type: string
|
||||
|
||||
FirewallBlocklist:
|
||||
type: object
|
||||
properties:
|
||||
client_id:
|
||||
$ref: "#/components/schemas/ResourceId"
|
||||
revision_id:
|
||||
$ref: "#/components/schemas/ResourceId"
|
||||
prefixes:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
total:
|
||||
type: integer
|
||||
hash:
|
||||
type: string
|
||||
|
||||
paths:
|
||||
/v1/health:
|
||||
get:
|
||||
@@ -4299,3 +4363,134 @@ paths:
|
||||
$ref: "#/components/responses/Forbidden"
|
||||
default:
|
||||
$ref: "#/components/responses/DefaultProblem"
|
||||
|
||||
/v1/firewall/enroll:
|
||||
post:
|
||||
tags: [Firewall]
|
||||
summary: Enroll firewall client (public, X-EvoBGP-Seed)
|
||||
security: []
|
||||
operationId: firewallEnroll
|
||||
parameters:
|
||||
- name: X-EvoBGP-Seed
|
||||
in: header
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [name, client_token]
|
||||
properties:
|
||||
name:
|
||||
type: string
|
||||
hostname:
|
||||
type: string
|
||||
client_token:
|
||||
type: string
|
||||
client_version:
|
||||
type: string
|
||||
responses:
|
||||
"201":
|
||||
description: Client created (pending).
|
||||
default:
|
||||
$ref: "#/components/responses/DefaultProblem"
|
||||
|
||||
/v1/firewall/clients:
|
||||
get:
|
||||
tags: [Firewall]
|
||||
summary: List firewall clients
|
||||
operationId: listFirewallClients
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
items:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/FirewallClient"
|
||||
default:
|
||||
$ref: "#/components/responses/DefaultProblem"
|
||||
|
||||
/v1/firewall/clients/{id}/approve:
|
||||
post:
|
||||
tags: [Firewall]
|
||||
summary: Approve pending client
|
||||
operationId: approveFirewallClient
|
||||
parameters:
|
||||
- name: id
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
$ref: "#/components/schemas/ResourceId"
|
||||
responses:
|
||||
"200":
|
||||
description: Approved
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/FirewallClient"
|
||||
default:
|
||||
$ref: "#/components/responses/DefaultProblem"
|
||||
|
||||
/v1/firewall/rules:
|
||||
get:
|
||||
tags: [Firewall]
|
||||
summary: List firewall rules
|
||||
operationId: listFirewallRules
|
||||
parameters:
|
||||
- name: scope
|
||||
in: query
|
||||
schema:
|
||||
type: string
|
||||
enum: [tenant, client]
|
||||
- name: client_id
|
||||
in: query
|
||||
schema:
|
||||
$ref: "#/components/schemas/ResourceId"
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
default:
|
||||
$ref: "#/components/responses/DefaultProblem"
|
||||
post:
|
||||
tags: [Firewall]
|
||||
summary: Create firewall rule
|
||||
operationId: createFirewallRule
|
||||
responses:
|
||||
"201":
|
||||
description: Created
|
||||
default:
|
||||
$ref: "#/components/responses/DefaultProblem"
|
||||
|
||||
/v1/firewall/blocklist:
|
||||
get:
|
||||
tags: [Firewall]
|
||||
summary: Get evaluated blocklist (firewall client token)
|
||||
operationId: getFirewallBlocklist
|
||||
responses:
|
||||
"200":
|
||||
description: Blocklist
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/FirewallBlocklist"
|
||||
default:
|
||||
$ref: "#/components/responses/DefaultProblem"
|
||||
|
||||
/v1/firewall/apply-report:
|
||||
post:
|
||||
tags: [Firewall]
|
||||
summary: Report last apply status
|
||||
operationId: firewallApplyReport
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
default:
|
||||
$ref: "#/components/responses/DefaultProblem"
|
||||
|
||||
@@ -114,5 +114,7 @@ Tenant `/v1/settings` (`bird_bgp_source_ipv4`) — fallback для master / ес
|
||||
| `EVOBGP_NODE_DISPATCH_ENABLED=1` | CP |
|
||||
| `EVOBGP_AGENT_SECRET` | реплика |
|
||||
| `EVOBGP_NODE_TOKEN` | реплика |
|
||||
| `EVOBGP_FIREWALL_FAILOVER_ENABLED=1` | реплика (опционально: отдавать `/v1/firewall/blocklist` при недоступности CP) |
|
||||
| `EVOBGP_FIREWALL_STATE_FILE` | реплика (default `/var/lib/evobgp-agent/firewall-state.json`) |
|
||||
| `EVOBGP_BUNDLE_PUBKEY_BASE64` | реплика |
|
||||
| `PANEL_IP_WHITELIST` | Traefik на реплике |
|
||||
|
||||
@@ -0,0 +1,256 @@
|
||||
package agentserver
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"evobgp/internal/authkey"
|
||||
"evobgp/internal/firewall"
|
||||
)
|
||||
|
||||
type firewallReplicatePayload struct {
|
||||
TenantID string `json:"tenant_id"`
|
||||
RevisionID string `json:"revision_id"`
|
||||
GeneratedAt string `json:"generated_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
ClientsByHash map[string]clientMeta `json:"clients_by_hash"`
|
||||
Rules []firewallRuleJSON `json:"rules"`
|
||||
PrefixesByCommunity map[string][]string `json:"prefixes_by_community"`
|
||||
}
|
||||
|
||||
type clientMeta struct {
|
||||
ClientID string `json:"client_id"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
type firewallRuleJSON struct {
|
||||
ClientID *string `json:"client_id"`
|
||||
Priority int `json:"priority"`
|
||||
Action string `json:"action"`
|
||||
CommunityID *string `json:"community_id"`
|
||||
}
|
||||
|
||||
type firewallAgent struct {
|
||||
mu sync.RWMutex
|
||||
stateFile string
|
||||
cpURL string
|
||||
enabled bool
|
||||
state firewallReplicatePayload
|
||||
}
|
||||
|
||||
func newFirewallAgent(cfg Config) *firewallAgent {
|
||||
enabled := strings.TrimSpace(os.Getenv("EVOBGP_FIREWALL_FAILOVER_ENABLED")) == "1"
|
||||
stateFile := envOr("EVOBGP_FIREWALL_STATE_FILE", "/var/lib/evobgp-agent/firewall-state.json")
|
||||
fa := &firewallAgent{
|
||||
stateFile: stateFile,
|
||||
cpURL: strings.TrimSpace(cfg.ControlPlaneURL),
|
||||
enabled: enabled,
|
||||
}
|
||||
if enabled {
|
||||
_ = fa.load()
|
||||
}
|
||||
return fa
|
||||
}
|
||||
|
||||
func (s *Server) handleFirewallReplicate(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.authorize(r) {
|
||||
writeProblem(w, http.StatusUnauthorized, "missing or invalid Authorization")
|
||||
return
|
||||
}
|
||||
var raw struct {
|
||||
TenantID string `json:"tenant_id"`
|
||||
RevisionID string `json:"revision_id"`
|
||||
GeneratedAt string `json:"generated_at"`
|
||||
Clients []struct {
|
||||
TokenHashHex string `json:"token_hash_hex"`
|
||||
ClientID string `json:"client_id"`
|
||||
Name string `json:"name"`
|
||||
} `json:"clients"`
|
||||
Rules []firewallRuleJSON `json:"rules"`
|
||||
PrefixesByCommunity map[string][]string `json:"prefixes_by_community"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&raw); err != nil {
|
||||
writeProblem(w, http.StatusBadRequest, "invalid JSON body")
|
||||
return
|
||||
}
|
||||
byHash := make(map[string]clientMeta, len(raw.Clients))
|
||||
for _, c := range raw.Clients {
|
||||
byHash[strings.ToLower(c.TokenHashHex)] = clientMeta{ClientID: c.ClientID, Name: c.Name}
|
||||
}
|
||||
payload := firewallReplicatePayload{
|
||||
TenantID: raw.TenantID,
|
||||
RevisionID: raw.RevisionID,
|
||||
GeneratedAt: raw.GeneratedAt,
|
||||
UpdatedAt: time.Now().UTC().Format(time.RFC3339),
|
||||
ClientsByHash: byHash,
|
||||
Rules: raw.Rules,
|
||||
PrefixesByCommunity: raw.PrefixesByCommunity,
|
||||
}
|
||||
s.firewall.mu.Lock()
|
||||
s.firewall.state = payload
|
||||
s.firewall.mu.Unlock()
|
||||
if err := s.firewall.save(); err != nil {
|
||||
log.Printf("agentserver: firewall state save: %v", err)
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"ok": true,
|
||||
"clients_count": len(byHash),
|
||||
"rules_count": len(raw.Rules),
|
||||
"prefix_count": len(raw.PrefixesByCommunity),
|
||||
})
|
||||
}
|
||||
|
||||
func (fa *firewallAgent) handleBlocklist(w http.ResponseWriter, r *http.Request) {
|
||||
token := bearerToken(r)
|
||||
if token == "" {
|
||||
writeProblem(w, http.StatusUnauthorized, "missing bearer token")
|
||||
return
|
||||
}
|
||||
hash := hex.EncodeToString(authkey.HashToken(token))
|
||||
fa.mu.RLock()
|
||||
st := fa.state
|
||||
meta, ok := st.ClientsByHash[hash]
|
||||
fa.mu.RUnlock()
|
||||
if !ok {
|
||||
writeProblem(w, http.StatusUnauthorized, "unknown firewall client")
|
||||
return
|
||||
}
|
||||
rules := make([]firewall.Rule, 0, len(st.Rules))
|
||||
for _, r := range st.Rules {
|
||||
rules = append(rules, firewall.Rule{
|
||||
ClientID: r.ClientID,
|
||||
Priority: r.Priority,
|
||||
Action: r.Action,
|
||||
CommunityID: r.CommunityID,
|
||||
})
|
||||
}
|
||||
prefixes := firewall.Evaluate(meta.ClientID, rules, st.PrefixesByCommunity)
|
||||
updatedAt, _ := time.Parse(time.RFC3339, st.UpdatedAt)
|
||||
age := int(time.Since(updatedAt).Seconds())
|
||||
if updatedAt.IsZero() {
|
||||
age = 0
|
||||
}
|
||||
w.Header().Set("X-EvoBGP-Source", "speaker")
|
||||
w.Header().Set("X-EvoBGP-Revision-ID", st.RevisionID)
|
||||
w.Header().Set("X-EvoBGP-Generated-At", st.GeneratedAt)
|
||||
w.Header().Set("X-EvoBGP-Rules-Version", firewall.RulesVersionHash(rules))
|
||||
if age > 3600 {
|
||||
w.Header().Set("X-EvoBGP-Stale", "true")
|
||||
}
|
||||
if age > 0 {
|
||||
w.Header().Set("Age", strconvItoa(age))
|
||||
}
|
||||
resp := map[string]any{
|
||||
"client_id": meta.ClientID,
|
||||
"revision_id": st.RevisionID,
|
||||
"generated_at": st.GeneratedAt,
|
||||
"source": "speaker",
|
||||
"rules_applied": len(rules),
|
||||
"communities_evaluated": len(st.PrefixesByCommunity),
|
||||
"prefixes": prefixes,
|
||||
"total": len(prefixes),
|
||||
"hash": prefixHash(prefixes),
|
||||
}
|
||||
if strings.Contains(r.Header.Get("Accept"), "text/plain") {
|
||||
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||
for _, p := range prefixes {
|
||||
_, _ = w.Write([]byte(p + "\n"))
|
||||
}
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, resp)
|
||||
}
|
||||
|
||||
func (fa *firewallAgent) handleApplyReportForward(w http.ResponseWriter, r *http.Request) {
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
forwarded := false
|
||||
if fa.cpURL != "" {
|
||||
req, err := http.NewRequestWithContext(r.Context(), http.MethodPost, strings.TrimSuffix(fa.cpURL, "/")+"/v1/firewall/apply-report", strings.NewReader(string(body)))
|
||||
if err == nil {
|
||||
req.Header.Set("Authorization", r.Header.Get("Authorization"))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err == nil {
|
||||
forwarded = resp.StatusCode >= 200 && resp.StatusCode < 300
|
||||
_ = resp.Body.Close()
|
||||
}
|
||||
}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "forwarded": forwarded})
|
||||
}
|
||||
|
||||
func (fa *firewallAgent) load() error {
|
||||
b, err := os.ReadFile(fa.stateFile)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
var st firewallReplicatePayload
|
||||
if err := json.Unmarshal(b, &st); err != nil {
|
||||
return err
|
||||
}
|
||||
fa.mu.Lock()
|
||||
fa.state = st
|
||||
fa.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (fa *firewallAgent) save() error {
|
||||
fa.mu.RLock()
|
||||
b, err := json.MarshalIndent(fa.state, "", " ")
|
||||
fa.mu.RUnlock()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
dir := filepath.Dir(fa.stateFile)
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
tmp := fa.stateFile + ".tmp"
|
||||
if err := os.WriteFile(tmp, b, 0o600); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Rename(tmp, fa.stateFile)
|
||||
}
|
||||
|
||||
func bearerToken(r *http.Request) string {
|
||||
h := r.Header.Get("Authorization")
|
||||
const p = "Bearer "
|
||||
if !strings.HasPrefix(h, p) {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(h[len(p):])
|
||||
}
|
||||
|
||||
func prefixHash(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 strconvItoa(n int) string {
|
||||
if n == 0 {
|
||||
return "0"
|
||||
}
|
||||
var b [20]byte
|
||||
i := len(b)
|
||||
for n > 0 {
|
||||
i--
|
||||
b[i] = byte('0' + n%10)
|
||||
n /= 10
|
||||
}
|
||||
return string(b[i:])
|
||||
}
|
||||
@@ -36,16 +36,22 @@ type Config struct {
|
||||
|
||||
// Server serves Panel→Node internal API (Remnawave-style wake-up).
|
||||
type Server struct {
|
||||
cfg Config
|
||||
mux *http.ServeMux
|
||||
cfg Config
|
||||
mux *http.ServeMux
|
||||
firewall *firewallAgent
|
||||
}
|
||||
|
||||
// New builds an agent HTTP server.
|
||||
func New(cfg Config) *Server {
|
||||
s := &Server{cfg: cfg, mux: http.NewServeMux()}
|
||||
s := &Server{cfg: cfg, mux: http.NewServeMux(), firewall: newFirewallAgent(cfg)}
|
||||
s.mux.HandleFunc("GET /v1/agent/health", s.handleHealth)
|
||||
s.mux.HandleFunc("GET /v1/agent/bird/protocols", s.handleBirdProtocols)
|
||||
s.mux.HandleFunc("POST /v1/agent/sync", s.handleSync)
|
||||
if s.firewall.enabled {
|
||||
s.mux.HandleFunc("GET /v1/firewall/blocklist", s.firewall.handleBlocklist)
|
||||
s.mux.HandleFunc("POST /v1/firewall/apply-report", s.firewall.handleApplyReportForward)
|
||||
s.mux.HandleFunc("POST /v1/agent/firewall-replicate", s.handleFirewallReplicate)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
// Package firewall evaluates block/accept policy rules into CIDR blocklists.
|
||||
package firewall
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Rule is one ordered firewall policy rule for evaluation.
|
||||
type Rule struct {
|
||||
ClientID *string
|
||||
Priority int
|
||||
Action string // "block" | "accept"
|
||||
CommunityID *string
|
||||
}
|
||||
|
||||
// Evaluate returns a deduplicated flat CIDR list to block in the kernel.
|
||||
// communityPrefixes maps community ID to prefixes; key "" holds prefixes without community.
|
||||
// Default when no rule matches: accept (do not block).
|
||||
func Evaluate(clientID string, rules []Rule, communityPrefixes map[string][]string) []string {
|
||||
ordered := mergeRules(clientID, rules)
|
||||
if len(communityPrefixes) == 0 {
|
||||
return nil
|
||||
}
|
||||
keys := make([]string, 0, len(communityPrefixes))
|
||||
for k := range communityPrefixes {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
|
||||
var out []string
|
||||
seen := make(map[string]struct{})
|
||||
for _, commKey := range keys {
|
||||
if !shouldBlockCommunity(commKey, ordered) {
|
||||
continue
|
||||
}
|
||||
for _, p := range communityPrefixes[commKey] {
|
||||
p = strings.TrimSpace(p)
|
||||
if p == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[p]; ok {
|
||||
continue
|
||||
}
|
||||
seen[p] = struct{}{}
|
||||
out = append(out, p)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func mergeRules(clientID string, rules []Rule) []Rule {
|
||||
var clientRules, tenantRules []Rule
|
||||
for _, r := range rules {
|
||||
if r.ClientID != nil && strings.TrimSpace(*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 := make([]Rule, 0, len(clientRules)+len(tenantRules))
|
||||
out = append(out, clientRules...)
|
||||
out = append(out, tenantRules...)
|
||||
return out
|
||||
}
|
||||
|
||||
func shouldBlockCommunity(communityKey string, ordered []Rule) bool {
|
||||
for _, r := range ordered {
|
||||
if r.CommunityID == nil || strings.TrimSpace(*r.CommunityID) == communityKey {
|
||||
return strings.EqualFold(strings.TrimSpace(r.Action), "block")
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// RulesVersionHash returns a stable fingerprint of rules for cache headers.
|
||||
func RulesVersionHash(rules []Rule) string {
|
||||
if len(rules) == 0 {
|
||||
return "sha256:empty"
|
||||
}
|
||||
cp := append([]Rule(nil), rules...)
|
||||
sort.Slice(cp, func(i, j int) bool {
|
||||
a, b := cp[i], cp[j]
|
||||
ac, bc := "", ""
|
||||
if a.ClientID != nil {
|
||||
ac = *a.ClientID
|
||||
}
|
||||
if b.ClientID != nil {
|
||||
bc = *b.ClientID
|
||||
}
|
||||
if ac != bc {
|
||||
return ac < bc
|
||||
}
|
||||
if a.Priority != b.Priority {
|
||||
return a.Priority < b.Priority
|
||||
}
|
||||
return a.Action < b.Action
|
||||
})
|
||||
var b strings.Builder
|
||||
for _, r := range cp {
|
||||
cid := "*"
|
||||
if r.CommunityID != nil {
|
||||
cid = *r.CommunityID
|
||||
}
|
||||
cl := "tenant"
|
||||
if r.ClientID != nil {
|
||||
cl = *r.ClientID
|
||||
}
|
||||
b.WriteString(cl)
|
||||
b.WriteByte('|')
|
||||
b.WriteString(r.Action)
|
||||
b.WriteByte('|')
|
||||
b.WriteString(cid)
|
||||
b.WriteByte('|')
|
||||
b.WriteString(strconv.Itoa(r.Priority))
|
||||
b.WriteByte(';')
|
||||
}
|
||||
sum := sha256.Sum256([]byte(b.String()))
|
||||
return "sha256:" + hex.EncodeToString(sum[:])
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package firewall
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestEvaluate_emptyRules(t *testing.T) {
|
||||
prefixes := map[string][]string{"c1": {"1.2.3.0/24"}}
|
||||
got := Evaluate("client1", nil, prefixes)
|
||||
if len(got) != 0 {
|
||||
t.Fatalf("expected empty blocklist, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvaluate_onlyAccept(t *testing.T) {
|
||||
wild := (*string)(nil)
|
||||
rules := []Rule{{Priority: 1, Action: "accept", CommunityID: wild}}
|
||||
prefixes := map[string][]string{"c1": {"1.2.3.0/24"}}
|
||||
got := Evaluate("client1", rules, prefixes)
|
||||
if len(got) != 0 {
|
||||
t.Fatalf("accept alone must not block, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvaluate_explicitBlock(t *testing.T) {
|
||||
cid := "c1"
|
||||
rules := []Rule{{Priority: 1, Action: "block", CommunityID: &cid}}
|
||||
prefixes := map[string][]string{"c1": {"1.2.3.0/24", "5.6.7.8/32"}, "c2": {"9.9.9.9/32"}}
|
||||
got := Evaluate("client1", rules, prefixes)
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("expected 2 prefixes, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvaluate_clientOverrideAccept(t *testing.T) {
|
||||
wild := (*string)(nil)
|
||||
cTrusted := "trusted"
|
||||
clientID := "srv1"
|
||||
rules := []Rule{
|
||||
{Priority: 1, Action: "block", CommunityID: wild},
|
||||
{ClientID: &clientID, Priority: 1, Action: "accept", CommunityID: &cTrusted},
|
||||
}
|
||||
prefixes := map[string][]string{
|
||||
"trusted": {"1.1.1.0/24"},
|
||||
"bad": {"2.2.2.0/24"},
|
||||
}
|
||||
got := Evaluate(clientID, rules, prefixes)
|
||||
if len(got) != 1 || got[0] != "2.2.2.0/24" {
|
||||
t.Fatalf("expected only bad community, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvaluate_clientOverrideWins(t *testing.T) {
|
||||
cid := "c1"
|
||||
clientID := "srv1"
|
||||
rules := []Rule{
|
||||
{Priority: 1, Action: "accept", CommunityID: &cid},
|
||||
{ClientID: &clientID, Priority: 1, Action: "block", CommunityID: &cid},
|
||||
}
|
||||
prefixes := map[string][]string{"c1": {"1.2.3.0/24"}}
|
||||
got := Evaluate(clientID, rules, prefixes)
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("client override should block, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvaluate_noCommunityKey(t *testing.T) {
|
||||
empty := ""
|
||||
rules := []Rule{{Priority: 1, Action: "block", CommunityID: &empty}}
|
||||
prefixes := map[string][]string{"": {"10.0.0.0/8"}}
|
||||
got := Evaluate("c", rules, prefixes)
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("expected prefix without community, got %v", got)
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,8 @@ import (
|
||||
"context"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"evobgp/internal/authkey"
|
||||
)
|
||||
|
||||
type ctxKey int
|
||||
@@ -95,6 +97,14 @@ func (s *Server) resolveAuth(raw string) (Auth, bool) {
|
||||
}
|
||||
rec, ok := s.keyResolver.Lookup(raw)
|
||||
if !ok {
|
||||
if s.firewallResolver != nil {
|
||||
if fw, ok := s.firewallResolver.Lookup(raw); ok {
|
||||
return Auth{TenantID: fw.tenantID, Role: "firewall", Token: raw, APIKeyID: fw.clientID}, true
|
||||
}
|
||||
}
|
||||
if client, err := s.store.LookupFirewallClientByTokenHash(authkey.HashToken(raw)); err == nil {
|
||||
return Auth{TenantID: client.TenantID, Role: "firewall", Token: raw, APIKeyID: client.ID}, true
|
||||
}
|
||||
return Auth{}, false
|
||||
}
|
||||
return authFromKeyRecord(raw, rec), true
|
||||
@@ -134,6 +144,14 @@ func (s *Server) requireAtLeast(w http.ResponseWriter, a Auth, need string) bool
|
||||
return true
|
||||
}
|
||||
|
||||
func (s *Server) requireFirewall(w http.ResponseWriter, a Auth) bool {
|
||||
if strings.ToLower(a.Role) != "firewall" {
|
||||
writeProblem(w, http.StatusForbidden, "Forbidden", "firewall client role required")
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (s *Server) requireNode(w http.ResponseWriter, a Auth) bool {
|
||||
if strings.ToLower(a.Role) != "node" {
|
||||
writeProblem(w, http.StatusForbidden, "Forbidden", "node role required")
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"encoding/hex"
|
||||
"sync"
|
||||
|
||||
"evobgp/internal/authkey"
|
||||
"evobgp/internal/store"
|
||||
)
|
||||
|
||||
type firewallAuthRow struct {
|
||||
tenantID string
|
||||
clientID string
|
||||
}
|
||||
|
||||
type firewallTokenResolver struct {
|
||||
mu sync.RWMutex
|
||||
byHash map[string]firewallAuthRow
|
||||
}
|
||||
|
||||
func newFirewallTokenResolver(st store.Backend) (*firewallTokenResolver, error) {
|
||||
r := &firewallTokenResolver{byHash: make(map[string]firewallAuthRow)}
|
||||
return r, r.reloadFromStore(st)
|
||||
}
|
||||
|
||||
func (r *firewallTokenResolver) reloadFromStore(st store.Backend) error {
|
||||
rows, err := st.ListActiveFirewallClientHashes()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
byHash := make(map[string]firewallAuthRow, len(rows))
|
||||
for _, row := range rows {
|
||||
if len(row.TokenHash) != 32 {
|
||||
continue
|
||||
}
|
||||
byHash[hex.EncodeToString(row.TokenHash)] = firewallAuthRow{
|
||||
tenantID: row.TenantID,
|
||||
clientID: row.ID,
|
||||
}
|
||||
}
|
||||
r.mu.Lock()
|
||||
r.byHash = byHash
|
||||
r.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *firewallTokenResolver) Reload(st store.Backend) error {
|
||||
return r.reloadFromStore(st)
|
||||
}
|
||||
|
||||
func (r *firewallTokenResolver) Lookup(raw string) (firewallAuthRow, bool) {
|
||||
hash := authkey.HashToken(raw)
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
rec, ok := r.byHash[hex.EncodeToString(hash)]
|
||||
return rec, ok
|
||||
}
|
||||
@@ -35,6 +35,9 @@ func (s *Server) Handler() http.Handler {
|
||||
s.mux.HandleFunc("GET /v1/health", s.handleHealth)
|
||||
s.mux.HandleFunc("GET /v1/ready", s.handleReady)
|
||||
s.mux.HandleFunc("GET /v1/version", s.handleVersion)
|
||||
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)
|
||||
s.mux.Handle("/v1/", s.authMiddleware(wrappedV1))
|
||||
return s.withCORS(observability.HTTPMiddleware(s.mux))
|
||||
}
|
||||
@@ -82,6 +85,7 @@ 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) {
|
||||
|
||||
@@ -0,0 +1,609 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"evobgp/internal/authkey"
|
||||
"evobgp/internal/firewall"
|
||||
"evobgp/internal/store"
|
||||
)
|
||||
|
||||
func (s *Server) registerFirewallRoutes(m *http.ServeMux) {
|
||||
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) 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
|
||||
}
|
||||
writeInternalError(w, "internal", 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) {
|
||||
path := filepath.Join("scripts", "firewall", name)
|
||||
b, err := os.ReadFile(path)
|
||||
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 (s *Server) handleListFirewallClients(w http.ResponseWriter, r *http.Request) {
|
||||
a, ok := authFromContext(r.Context())
|
||||
if !ok || !s.requireAtLeast(w, a, "viewer") {
|
||||
return
|
||||
}
|
||||
items, err := s.store.ListFirewallClients(a.TenantID)
|
||||
if err != nil {
|
||||
writeInternalError(w, "internal", err)
|
||||
return
|
||||
}
|
||||
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.requireAtLeast(w, a, "viewer") {
|
||||
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
|
||||
}
|
||||
writeJSON(w, http.StatusOK, client)
|
||||
}
|
||||
|
||||
func (s *Server) handlePatchFirewallClient(w http.ResponseWriter, r *http.Request) {
|
||||
a, ok := authFromContext(r.Context())
|
||||
if !ok || !s.requireAtLeast(w, a, "operator") {
|
||||
return
|
||||
}
|
||||
id := strings.TrimSpace(r.PathValue("id"))
|
||||
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.requireAtLeast(w, a, "operator") {
|
||||
return
|
||||
}
|
||||
id := strings.TrimSpace(r.PathValue("id"))
|
||||
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.requireAtLeast(w, a, "operator") {
|
||||
return
|
||||
}
|
||||
id := strings.TrimSpace(r.PathValue("id"))
|
||||
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.requireAtLeast(w, a, "operator") {
|
||||
return
|
||||
}
|
||||
id := strings.TrimSpace(r.PathValue("id"))
|
||||
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.requireAtLeast(w, a, "viewer") {
|
||||
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
|
||||
}
|
||||
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.requireAtLeast(w, a, "operator") {
|
||||
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
|
||||
}
|
||||
rule, err := s.store.CreateFirewallRule(a.TenantID, clientID, &store.FirewallRuleCreate{
|
||||
Priority: body.Priority,
|
||||
Action: body.Action,
|
||||
CommunityID: body.CommunityID,
|
||||
Comment: body.Comment,
|
||||
})
|
||||
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.requireAtLeast(w, a, "operator") {
|
||||
return
|
||||
}
|
||||
id := strings.TrimSpace(r.PathValue("id"))
|
||||
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.requireAtLeast(w, a, "operator") {
|
||||
return
|
||||
}
|
||||
id := strings.TrimSpace(r.PathValue("id"))
|
||||
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.requireAtLeast(w, a, "operator") {
|
||||
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"`
|
||||
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)
|
||||
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.requireAtLeast(w, a, "viewer") {
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"evobgp/internal/authkey"
|
||||
"evobgp/internal/store"
|
||||
)
|
||||
|
||||
func TestFirewallEnrollAndBlocklist(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_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)
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
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 TestFirewallTokenHashMatchesAuthkey(t *testing.T) {
|
||||
tok := "evobgp_fw_sample"
|
||||
h := authkey.HashToken(tok)
|
||||
if len(h) != 32 {
|
||||
t.Fatalf("hash len %d", len(h))
|
||||
}
|
||||
}
|
||||
@@ -29,6 +29,8 @@ type Server struct {
|
||||
jobs *jobs.Registry
|
||||
bundlePriv ed25519.PrivateKey
|
||||
keyResolver *apiKeyResolver
|
||||
firewallResolver *firewallTokenResolver
|
||||
bundleSeedHex string
|
||||
corsOrigins []string
|
||||
cdnHTTP *http.Client
|
||||
runtimeLogs *runtimelogs.Service
|
||||
@@ -74,6 +76,10 @@ func New(opts Options) (*Server, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
fwResolver, err := newFirewallTokenResolver(backend)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var pgMon *pgmonitor.Service
|
||||
var maintCfg *maintenance.ConfigProvider
|
||||
var maintStats *maintenance.DBStatsProvider
|
||||
@@ -92,6 +98,8 @@ func New(opts Options) (*Server, error) {
|
||||
jobs: reg,
|
||||
bundlePriv: priv,
|
||||
keyResolver: resolver,
|
||||
firewallResolver: fwResolver,
|
||||
bundleSeedHex: strings.TrimSpace(opts.BundleSeedHex),
|
||||
corsOrigins: parseCORSOrigins(opts.CORSAllowedOrigins),
|
||||
cdnHTTP: NewCDNHTTPClient(),
|
||||
runtimeLogs: runtimelogs.NewService(runtimelogs.ConfigFromEnv()),
|
||||
|
||||
@@ -0,0 +1,494 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"evobgp/internal/store"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
func (p *Postgres) ListFirewallClients(tenantID string) ([]*store.FirewallClient, error) {
|
||||
ctx := context.Background()
|
||||
rows, err := p.pool.Query(ctx, `
|
||||
SELECT id, name, hostname, token_prefix, status,
|
||||
last_seen_at, last_seen_at_source, last_seen_ip,
|
||||
last_apply_at, last_apply_status, last_apply_error,
|
||||
last_apply_prefix_count, last_apply_ip_count, last_apply_source,
|
||||
client_version, created_at, approved_at, approved_by_api_key_id, revoked_at
|
||||
FROM firewall_client WHERE tenant_id=$1 ORDER BY created_at DESC`, tenantID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []*store.FirewallClient
|
||||
for rows.Next() {
|
||||
c, err := scanFirewallClientRow(rows.Scan, tenantID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, c)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (p *Postgres) GetFirewallClient(tenantID, id string) (*store.FirewallClient, error) {
|
||||
ctx := context.Background()
|
||||
row := p.pool.QueryRow(ctx, `
|
||||
SELECT id, name, hostname, token_prefix, status,
|
||||
last_seen_at, last_seen_at_source, last_seen_ip,
|
||||
last_apply_at, last_apply_status, last_apply_error,
|
||||
last_apply_prefix_count, last_apply_ip_count, last_apply_source,
|
||||
client_version, created_at, approved_at, approved_by_api_key_id, revoked_at
|
||||
FROM firewall_client WHERE id=$1 AND tenant_id=$2`, id, tenantID)
|
||||
c, err := scanFirewallClientRow(row.Scan, tenantID)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, store.ErrNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
func (p *Postgres) CreateFirewallClient(tenantID string, in *store.FirewallClientCreate) (*store.FirewallClient, error) {
|
||||
if in == nil || strings.TrimSpace(in.Name) == "" || len(in.TokenHash) != 32 {
|
||||
return nil, store.ErrInvalidInput
|
||||
}
|
||||
id := uuid.NewString()
|
||||
ctx := context.Background()
|
||||
_, err := p.pool.Exec(ctx, `
|
||||
INSERT INTO firewall_client (id, tenant_id, name, hostname, token_prefix, token_hash, client_version)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7)`,
|
||||
id, tenantID, strings.TrimSpace(in.Name), strings.TrimSpace(in.Hostname),
|
||||
in.TokenPrefix, in.TokenHash, strings.TrimSpace(in.ClientVersion))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return p.GetFirewallClient(tenantID, id)
|
||||
}
|
||||
|
||||
func (p *Postgres) UpdateFirewallClient(tenantID, id string, patch *store.FirewallClientPatch) (*store.FirewallClient, error) {
|
||||
cur, err := p.GetFirewallClient(tenantID, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if patch == nil {
|
||||
return nil, store.ErrInvalidInput
|
||||
}
|
||||
name := cur.Name
|
||||
hostname := cur.Hostname
|
||||
if patch.Name != nil {
|
||||
name = strings.TrimSpace(*patch.Name)
|
||||
if name == "" {
|
||||
return nil, store.ErrInvalidInput
|
||||
}
|
||||
}
|
||||
if patch.Hostname != nil {
|
||||
hostname = strings.TrimSpace(*patch.Hostname)
|
||||
}
|
||||
ctx := context.Background()
|
||||
_, err = p.pool.Exec(ctx, `UPDATE firewall_client SET name=$3, hostname=$4 WHERE id=$1 AND tenant_id=$2`,
|
||||
id, tenantID, name, hostname)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return p.GetFirewallClient(tenantID, id)
|
||||
}
|
||||
|
||||
func (p *Postgres) ApproveFirewallClient(tenantID, id, approverAPIKeyID string) (*store.FirewallClient, error) {
|
||||
ctx := context.Background()
|
||||
tag, err := p.pool.Exec(ctx, `
|
||||
UPDATE firewall_client
|
||||
SET status='approved', approved_at=now(), approved_by_api_key_id=$3, revoked_at=NULL
|
||||
WHERE id=$1 AND tenant_id=$2 AND status != 'revoked'`, id, tenantID, nullIfEmpty(approverAPIKeyID))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return nil, store.ErrNotFound
|
||||
}
|
||||
return p.GetFirewallClient(tenantID, id)
|
||||
}
|
||||
|
||||
func (p *Postgres) RevokeFirewallClient(tenantID, id string) error {
|
||||
ctx := context.Background()
|
||||
tag, err := p.pool.Exec(ctx, `
|
||||
UPDATE firewall_client SET status='revoked', revoked_at=now() WHERE id=$1 AND tenant_id=$2`, id, tenantID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return store.ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *Postgres) DeleteFirewallClient(tenantID, id string) error {
|
||||
ctx := context.Background()
|
||||
tag, err := p.pool.Exec(ctx, `DELETE FROM firewall_client WHERE id=$1 AND tenant_id=$2`, id, tenantID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return store.ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *Postgres) LookupFirewallClientByTokenHash(hash []byte) (*store.FirewallClient, error) {
|
||||
if len(hash) != 32 {
|
||||
return nil, store.ErrNotFound
|
||||
}
|
||||
ctx := context.Background()
|
||||
row := p.pool.QueryRow(ctx, `
|
||||
SELECT tenant_id, id, name, hostname, token_prefix, status,
|
||||
last_seen_at, last_seen_at_source, last_seen_ip,
|
||||
last_apply_at, last_apply_status, last_apply_error,
|
||||
last_apply_prefix_count, last_apply_ip_count, last_apply_source,
|
||||
client_version, created_at, approved_at, approved_by_api_key_id, revoked_at
|
||||
FROM firewall_client WHERE token_hash=$1`, hash)
|
||||
c, err := scanFirewallClientLookupRow(row.Scan)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, store.ErrNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
func (p *Postgres) TouchFirewallClientLastSeen(id, source, clientIP, clientVersion string) error {
|
||||
ctx := context.Background()
|
||||
_, err := p.pool.Exec(ctx, `
|
||||
UPDATE firewall_client SET last_seen_at=now(), last_seen_at_source=$2, last_seen_ip=$3,
|
||||
client_version=COALESCE(NULLIF($4,''), client_version)
|
||||
WHERE id=$1`, id, strings.TrimSpace(source), strings.TrimSpace(clientIP), strings.TrimSpace(clientVersion))
|
||||
return err
|
||||
}
|
||||
|
||||
func (p *Postgres) TouchFirewallClientLastApply(id, source, status, errMsg string, prefixCount, ipCount int) error {
|
||||
ctx := context.Background()
|
||||
_, err := p.pool.Exec(ctx, `
|
||||
UPDATE firewall_client SET last_apply_at=now(), last_apply_source=$2, last_apply_status=$3,
|
||||
last_apply_error=$4, last_apply_prefix_count=$5, last_apply_ip_count=$6
|
||||
WHERE id=$1`, id, strings.TrimSpace(source), strings.TrimSpace(status), strings.TrimSpace(errMsg), prefixCount, ipCount)
|
||||
return err
|
||||
}
|
||||
|
||||
func (p *Postgres) ListActiveFirewallClientHashes() ([]store.FirewallClientAuthRow, error) {
|
||||
ctx := context.Background()
|
||||
rows, err := p.pool.Query(ctx, `
|
||||
SELECT id, tenant_id, token_hash FROM firewall_client WHERE status='approved'`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []store.FirewallClientAuthRow
|
||||
for rows.Next() {
|
||||
var row store.FirewallClientAuthRow
|
||||
if err := rows.Scan(&row.ID, &row.TenantID, &row.TokenHash); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(row.TokenHash) != 32 {
|
||||
continue
|
||||
}
|
||||
out = append(out, row)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (p *Postgres) ListApprovedFirewallClientsForReplication(tenantID string) ([]store.FirewallClientReplicationRow, error) {
|
||||
ctx := context.Background()
|
||||
rows, err := p.pool.Query(ctx, `
|
||||
SELECT id, name, token_hash FROM firewall_client
|
||||
WHERE tenant_id=$1 AND status='approved' ORDER BY id`, tenantID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []store.FirewallClientReplicationRow
|
||||
for rows.Next() {
|
||||
var id, name string
|
||||
var hash []byte
|
||||
if err := rows.Scan(&id, &name, &hash); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, store.FirewallClientReplicationRow{
|
||||
ClientID: id,
|
||||
Name: name,
|
||||
TokenHashHex: hex.EncodeToString(hash),
|
||||
})
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (p *Postgres) ListFirewallRules(tenantID string, clientID *string) ([]*store.FirewallRule, error) {
|
||||
ctx := context.Background()
|
||||
var rows pgx.Rows
|
||||
var err error
|
||||
if clientID == nil {
|
||||
rows, err = p.pool.Query(ctx, `
|
||||
SELECT id, client_id, priority, action, community_id, comment, created_at, updated_at
|
||||
FROM firewall_rule WHERE tenant_id=$1 AND client_id IS NULL ORDER BY priority`, tenantID)
|
||||
} else {
|
||||
rows, err = p.pool.Query(ctx, `
|
||||
SELECT id, client_id, priority, action, community_id, comment, created_at, updated_at
|
||||
FROM firewall_rule WHERE tenant_id=$1 AND client_id=$2 ORDER BY priority`, tenantID, *clientID)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
return scanFirewallRules(rows, tenantID)
|
||||
}
|
||||
|
||||
func (p *Postgres) ListAllFirewallRulesForClient(tenantID, clientID string) ([]*store.FirewallRule, error) {
|
||||
ctx := context.Background()
|
||||
rows, err := p.pool.Query(ctx, `
|
||||
SELECT id, client_id, priority, action, community_id, comment, created_at, updated_at
|
||||
FROM firewall_rule
|
||||
WHERE tenant_id=$1 AND (client_id IS NULL OR client_id=$2)
|
||||
ORDER BY CASE WHEN client_id IS NULL THEN 1 ELSE 0 END, priority`, tenantID, clientID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
return scanFirewallRules(rows, tenantID)
|
||||
}
|
||||
|
||||
func (p *Postgres) ListAllFirewallRulesForReplication(tenantID string) ([]*store.FirewallRule, error) {
|
||||
ctx := context.Background()
|
||||
rows, err := p.pool.Query(ctx, `
|
||||
SELECT id, client_id, priority, action, community_id, comment, created_at, updated_at
|
||||
FROM firewall_rule WHERE tenant_id=$1
|
||||
ORDER BY CASE WHEN client_id IS NULL THEN 1 ELSE 0 END, client_id, priority`, tenantID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
return scanFirewallRules(rows, tenantID)
|
||||
}
|
||||
|
||||
func (p *Postgres) CreateFirewallRule(tenantID string, clientID *string, in *store.FirewallRuleCreate) (*store.FirewallRule, error) {
|
||||
if in == nil || !store.ValidFirewallAction(in.Action) {
|
||||
return nil, store.ErrInvalidInput
|
||||
}
|
||||
if clientID != nil {
|
||||
if _, err := p.GetFirewallClient(tenantID, *clientID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
priority := 1
|
||||
if in.Priority != nil && *in.Priority >= 1 {
|
||||
priority = *in.Priority
|
||||
} else {
|
||||
next, err := p.nextFirewallRulePriority(tenantID, clientID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
priority = next
|
||||
}
|
||||
id := uuid.NewString()
|
||||
ctx := context.Background()
|
||||
_, err := p.pool.Exec(ctx, `
|
||||
INSERT INTO firewall_rule (id, tenant_id, client_id, priority, action, community_id, comment)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7)`,
|
||||
id, tenantID, clientID, priority, strings.ToLower(strings.TrimSpace(in.Action)), in.CommunityID, strings.TrimSpace(in.Comment))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return p.GetFirewallRule(tenantID, id)
|
||||
}
|
||||
|
||||
func (p *Postgres) GetFirewallRule(tenantID, id string) (*store.FirewallRule, error) {
|
||||
ctx := context.Background()
|
||||
row := p.pool.QueryRow(ctx, `
|
||||
SELECT id, client_id, priority, action, community_id, comment, created_at, updated_at
|
||||
FROM firewall_rule WHERE id=$1 AND tenant_id=$2`, id, tenantID)
|
||||
r, err := scanFirewallRuleRow(row.Scan, tenantID)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, store.ErrNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return r, nil
|
||||
}
|
||||
|
||||
func (p *Postgres) UpdateFirewallRule(tenantID, ruleID string, patch *store.FirewallRulePatch) (*store.FirewallRule, error) {
|
||||
cur, err := p.GetFirewallRule(tenantID, ruleID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if patch == nil {
|
||||
return nil, store.ErrInvalidInput
|
||||
}
|
||||
action := cur.Action
|
||||
communityID := cur.CommunityID
|
||||
comment := cur.Comment
|
||||
if patch.Action != nil {
|
||||
if !store.ValidFirewallAction(*patch.Action) {
|
||||
return nil, store.ErrInvalidInput
|
||||
}
|
||||
action = strings.ToLower(strings.TrimSpace(*patch.Action))
|
||||
}
|
||||
if patch.ClearCommunity {
|
||||
communityID = nil
|
||||
} else if patch.CommunityID != nil {
|
||||
communityID = patch.CommunityID
|
||||
}
|
||||
if patch.Comment != nil {
|
||||
comment = strings.TrimSpace(*patch.Comment)
|
||||
}
|
||||
ctx := context.Background()
|
||||
_, err = p.pool.Exec(ctx, `
|
||||
UPDATE firewall_rule SET action=$3, community_id=$4, comment=$5, updated_at=now()
|
||||
WHERE id=$1 AND tenant_id=$2`, ruleID, tenantID, action, communityID, comment)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return p.GetFirewallRule(tenantID, ruleID)
|
||||
}
|
||||
|
||||
func (p *Postgres) DeleteFirewallRule(tenantID, ruleID string) error {
|
||||
ctx := context.Background()
|
||||
tag, err := p.pool.Exec(ctx, `DELETE FROM firewall_rule WHERE id=$1 AND tenant_id=$2`, ruleID, tenantID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return store.ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *Postgres) ReorderFirewallRules(tenantID string, clientID *string, orderedIDs []string) error {
|
||||
ctx := context.Background()
|
||||
tx, err := p.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = tx.Rollback(ctx) }()
|
||||
for i, id := range orderedIDs {
|
||||
var execErr error
|
||||
if clientID == nil {
|
||||
_, execErr = tx.Exec(ctx, `
|
||||
UPDATE firewall_rule SET priority=$3, updated_at=now()
|
||||
WHERE id=$1 AND tenant_id=$2 AND client_id IS NULL`, id, tenantID, i+1)
|
||||
} else {
|
||||
_, execErr = tx.Exec(ctx, `
|
||||
UPDATE firewall_rule SET priority=$4, updated_at=now()
|
||||
WHERE id=$1 AND tenant_id=$2 AND client_id=$3`, id, tenantID, *clientID, i+1)
|
||||
}
|
||||
if execErr != nil {
|
||||
return execErr
|
||||
}
|
||||
}
|
||||
return tx.Commit(ctx)
|
||||
}
|
||||
|
||||
func (p *Postgres) nextFirewallRulePriority(tenantID string, clientID *string) (int, error) {
|
||||
ctx := context.Background()
|
||||
var max int
|
||||
var err error
|
||||
if clientID == nil {
|
||||
err = p.pool.QueryRow(ctx, `
|
||||
SELECT COALESCE(MAX(priority),0) FROM firewall_rule WHERE tenant_id=$1 AND client_id IS NULL`, tenantID).Scan(&max)
|
||||
} else {
|
||||
err = p.pool.QueryRow(ctx, `
|
||||
SELECT COALESCE(MAX(priority),0) FROM firewall_rule WHERE tenant_id=$1 AND client_id=$2`, tenantID, *clientID).Scan(&max)
|
||||
}
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return max + 1, nil
|
||||
}
|
||||
|
||||
func scanFirewallRules(rows pgx.Rows, tenantID string) ([]*store.FirewallRule, error) {
|
||||
var out []*store.FirewallRule
|
||||
for rows.Next() {
|
||||
r, err := scanFirewallRuleRow(rows.Scan, tenantID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, r)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func scanFirewallRuleRow(scan scanFn, tenantID string) (*store.FirewallRule, error) {
|
||||
var r store.FirewallRule
|
||||
r.TenantID = tenantID
|
||||
var clientID, communityID *string
|
||||
if err := scan(&r.ID, &clientID, &r.Priority, &r.Action, &communityID, &r.Comment, &r.CreatedAt, &r.UpdatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
r.ClientID = clientID
|
||||
r.CommunityID = communityID
|
||||
return &r, nil
|
||||
}
|
||||
|
||||
func scanFirewallClientRow(scan scanFn, tenantID string) (*store.FirewallClient, error) {
|
||||
var c store.FirewallClient
|
||||
c.TenantID = tenantID
|
||||
var approvedBy *string
|
||||
var lastSeen, lastApply, approved, revoked *time.Time
|
||||
var prefixCount, ipCount *int
|
||||
if err := scan(
|
||||
&c.ID, &c.Name, &c.Hostname, &c.TokenPrefix, &c.Status,
|
||||
&lastSeen, &c.LastSeenAtSource, &c.LastSeenIP,
|
||||
&lastApply, &c.LastApplyStatus, &c.LastApplyError,
|
||||
&prefixCount, &ipCount, &c.LastApplySource,
|
||||
&c.ClientVersion, &c.CreatedAt, &approved, &approvedBy, &revoked,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return finishFirewallClientScan(&c, lastSeen, lastApply, approved, revoked, approvedBy, prefixCount, ipCount), nil
|
||||
}
|
||||
|
||||
func scanFirewallClientLookupRow(scan scanFn) (*store.FirewallClient, error) {
|
||||
var c store.FirewallClient
|
||||
var approvedBy *string
|
||||
var lastSeen, lastApply, approved, revoked *time.Time
|
||||
var prefixCount, ipCount *int
|
||||
if err := scan(
|
||||
&c.TenantID, &c.ID, &c.Name, &c.Hostname, &c.TokenPrefix, &c.Status,
|
||||
&lastSeen, &c.LastSeenAtSource, &c.LastSeenIP,
|
||||
&lastApply, &c.LastApplyStatus, &c.LastApplyError,
|
||||
&prefixCount, &ipCount, &c.LastApplySource,
|
||||
&c.ClientVersion, &c.CreatedAt, &approved, &approvedBy, &revoked,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return finishFirewallClientScan(&c, lastSeen, lastApply, approved, revoked, approvedBy, prefixCount, ipCount), nil
|
||||
}
|
||||
|
||||
func finishFirewallClientScan(c *store.FirewallClient, lastSeen, lastApply, approved, revoked *time.Time, approvedBy *string, prefixCount, ipCount *int) *store.FirewallClient {
|
||||
c.LastSeenAt = lastSeen
|
||||
c.LastApplyAt = lastApply
|
||||
c.ApprovedAt = approved
|
||||
c.RevokedAt = revoked
|
||||
if approvedBy != nil {
|
||||
c.ApprovedByAPIKeyID = *approvedBy
|
||||
}
|
||||
if prefixCount != nil {
|
||||
c.LastApplyPrefixCount = *prefixCount
|
||||
}
|
||||
if ipCount != nil {
|
||||
c.LastApplyIPCount = *ipCount
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
func nullIfEmpty(s string) any {
|
||||
if strings.TrimSpace(s) == "" {
|
||||
return nil
|
||||
}
|
||||
return s
|
||||
}
|
||||
@@ -131,6 +131,28 @@ type Backend interface {
|
||||
// Runtime log cleanup audit (filesystem ops logged per tenant).
|
||||
AppendRuntimeLogCleanupAudit(tenantID, actor, filename, action string, sizeBefore int64, sizeAfter *int64, detail map[string]any) (string, error)
|
||||
ListRuntimeLogCleanupAudit(tenantID, cursor string, limit int) ([]*RuntimeLogCleanupAudit, string, bool, error)
|
||||
|
||||
// Firewall blocklist clients and policy rules.
|
||||
ListFirewallClients(tenantID string) ([]*FirewallClient, error)
|
||||
GetFirewallClient(tenantID, id string) (*FirewallClient, error)
|
||||
CreateFirewallClient(tenantID string, in *FirewallClientCreate) (*FirewallClient, error)
|
||||
UpdateFirewallClient(tenantID, id string, patch *FirewallClientPatch) (*FirewallClient, error)
|
||||
ApproveFirewallClient(tenantID, id, approverAPIKeyID string) (*FirewallClient, error)
|
||||
RevokeFirewallClient(tenantID, id string) error
|
||||
DeleteFirewallClient(tenantID, id string) error
|
||||
LookupFirewallClientByTokenHash(hash []byte) (*FirewallClient, error)
|
||||
TouchFirewallClientLastSeen(id, source, clientIP, clientVersion string) error
|
||||
TouchFirewallClientLastApply(id, source, status, errMsg string, prefixCount, ipCount int) error
|
||||
ListActiveFirewallClientHashes() ([]FirewallClientAuthRow, error)
|
||||
ListApprovedFirewallClientsForReplication(tenantID string) ([]FirewallClientReplicationRow, error)
|
||||
|
||||
ListFirewallRules(tenantID string, clientID *string) ([]*FirewallRule, error)
|
||||
ListAllFirewallRulesForClient(tenantID, clientID string) ([]*FirewallRule, error)
|
||||
ListAllFirewallRulesForReplication(tenantID string) ([]*FirewallRule, error)
|
||||
CreateFirewallRule(tenantID string, clientID *string, in *FirewallRuleCreate) (*FirewallRule, error)
|
||||
UpdateFirewallRule(tenantID, ruleID string, patch *FirewallRulePatch) (*FirewallRule, error)
|
||||
DeleteFirewallRule(tenantID, ruleID string) error
|
||||
ReorderFirewallRules(tenantID string, clientID *string, orderedIDs []string) error
|
||||
}
|
||||
|
||||
// ASNPrefixCacheEntry is a cached RIPEstat response for one ASN.
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// FirewallClient is a Linux blocklist sync client enrolled via seed.
|
||||
type FirewallClient struct {
|
||||
ID string `json:"id"`
|
||||
TenantID string `json:"tenant_id,omitempty"`
|
||||
Name string `json:"name"`
|
||||
Hostname string `json:"hostname,omitempty"`
|
||||
TokenPrefix string `json:"token_prefix"`
|
||||
Status string `json:"status"`
|
||||
LastSeenAt *time.Time `json:"last_seen_at,omitempty"`
|
||||
LastSeenAtSource string `json:"last_seen_at_source,omitempty"`
|
||||
LastSeenIP string `json:"last_seen_ip,omitempty"`
|
||||
LastApplyAt *time.Time `json:"last_apply_at,omitempty"`
|
||||
LastApplyStatus string `json:"last_apply_status,omitempty"`
|
||||
LastApplyError string `json:"last_apply_error,omitempty"`
|
||||
LastApplyPrefixCount int `json:"last_apply_prefix_count,omitempty"`
|
||||
LastApplyIPCount int `json:"last_apply_ip_count,omitempty"`
|
||||
LastApplySource string `json:"last_apply_source,omitempty"`
|
||||
ClientVersion string `json:"client_version,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
ApprovedAt *time.Time `json:"approved_at,omitempty"`
|
||||
ApprovedByAPIKeyID string `json:"approved_by_api_key_id,omitempty"`
|
||||
RevokedAt *time.Time `json:"revoked_at,omitempty"`
|
||||
}
|
||||
|
||||
// FirewallClientCreate is input for enroll (token hash supplied by caller).
|
||||
type FirewallClientCreate struct {
|
||||
Name string
|
||||
Hostname string
|
||||
TokenPrefix string
|
||||
TokenHash []byte
|
||||
ClientVersion string
|
||||
}
|
||||
|
||||
// FirewallClientPatch is a partial update for operator edits.
|
||||
type FirewallClientPatch struct {
|
||||
Name *string `json:"name,omitempty"`
|
||||
Hostname *string `json:"hostname,omitempty"`
|
||||
}
|
||||
|
||||
// FirewallClientAuthRow is used to build the in-process firewall token index.
|
||||
type FirewallClientAuthRow struct {
|
||||
ID string
|
||||
TenantID string
|
||||
TokenHash []byte
|
||||
}
|
||||
|
||||
// FirewallClientReplicationRow is pushed to speaker agents.
|
||||
type FirewallClientReplicationRow struct {
|
||||
ClientID string `json:"client_id"`
|
||||
Name string `json:"name"`
|
||||
TokenHashHex string `json:"token_hash_hex"`
|
||||
}
|
||||
|
||||
// FirewallRule is one block/accept policy rule.
|
||||
type FirewallRule struct {
|
||||
ID string `json:"id"`
|
||||
TenantID string `json:"tenant_id,omitempty"`
|
||||
ClientID *string `json:"client_id,omitempty"`
|
||||
Priority int `json:"priority"`
|
||||
Action string `json:"action"`
|
||||
CommunityID *string `json:"community_id,omitempty"`
|
||||
Comment string `json:"comment,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// FirewallRuleCreate is input for creating a rule.
|
||||
type FirewallRuleCreate struct {
|
||||
Priority *int `json:"priority,omitempty"`
|
||||
Action string `json:"action"`
|
||||
CommunityID *string `json:"community_id,omitempty"`
|
||||
Comment string `json:"comment,omitempty"`
|
||||
}
|
||||
|
||||
// FirewallRulePatch is a partial rule update.
|
||||
type FirewallRulePatch struct {
|
||||
Action *string `json:"action,omitempty"`
|
||||
CommunityID *string `json:"community_id,omitempty"`
|
||||
ClearCommunity bool `json:"-"`
|
||||
Comment *string `json:"comment,omitempty"`
|
||||
}
|
||||
|
||||
// ValidFirewallAction reports whether action is block or accept.
|
||||
func ValidFirewallAction(action string) bool {
|
||||
switch strings.ToLower(strings.TrimSpace(action)) {
|
||||
case "block", "accept":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -44,6 +44,9 @@ type Memory struct {
|
||||
moduleSnapshots map[string]*moduleSnapshotRec
|
||||
asnPrefixCache map[int64]*ASNPrefixCacheEntry
|
||||
apiKeys map[string]*apiKeyRec
|
||||
firewallClients map[string]*firewallClientRec
|
||||
firewallRules map[string]*FirewallRule
|
||||
firewallHashIndex map[string]string // hex hash -> client id
|
||||
maintenancePolicies map[string]*MaintenancePolicy
|
||||
maintConfigAudit []*MaintenancePolicyConfigAudit
|
||||
runtimeLogCleanupAudit []*RuntimeLogCleanupAudit
|
||||
@@ -66,6 +69,11 @@ type apiKeyRec struct {
|
||||
TokenHash []byte
|
||||
}
|
||||
|
||||
type firewallClientRec struct {
|
||||
FirewallClient
|
||||
TokenHash []byte
|
||||
}
|
||||
|
||||
type Tenant struct {
|
||||
ID string
|
||||
Name string
|
||||
@@ -143,6 +151,9 @@ func NewMemory() *Memory {
|
||||
moduleSnapshots: make(map[string]*moduleSnapshotRec),
|
||||
asnPrefixCache: make(map[int64]*ASNPrefixCacheEntry),
|
||||
apiKeys: make(map[string]*apiKeyRec),
|
||||
firewallClients: make(map[string]*firewallClientRec),
|
||||
firewallRules: make(map[string]*FirewallRule),
|
||||
firewallHashIndex: make(map[string]string),
|
||||
maintenancePolicies: make(map[string]*MaintenancePolicy),
|
||||
maintConfigAudit: nil,
|
||||
runtimeLogCleanupAudit: nil,
|
||||
|
||||
@@ -0,0 +1,463 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"encoding/hex"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
func (m *Memory) ListFirewallClients(tenantID string) ([]*FirewallClient, error) {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
var out []*FirewallClient
|
||||
for _, rec := range m.firewallClients {
|
||||
if rec.TenantID == tenantID {
|
||||
out = append(out, firewallClientCopy(&rec.FirewallClient))
|
||||
}
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].CreatedAt.After(out[j].CreatedAt) })
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (m *Memory) GetFirewallClient(tenantID, id string) (*FirewallClient, error) {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
rec, ok := m.firewallClients[id]
|
||||
if !ok || rec.TenantID != tenantID {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
return firewallClientCopy(&rec.FirewallClient), nil
|
||||
}
|
||||
|
||||
func (m *Memory) CreateFirewallClient(tenantID string, in *FirewallClientCreate) (*FirewallClient, error) {
|
||||
if in == nil || strings.TrimSpace(in.Name) == "" || len(in.TokenHash) != 32 {
|
||||
return nil, ErrInvalidInput
|
||||
}
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if _, ok := m.tenants[tenantID]; !ok {
|
||||
return nil, ErrTenantScope
|
||||
}
|
||||
hashKey := hex.EncodeToString(in.TokenHash)
|
||||
if _, dup := m.firewallHashIndex[hashKey]; dup {
|
||||
return nil, ErrInvalidInput
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
id := uuid.NewString()
|
||||
rec := &firewallClientRec{
|
||||
FirewallClient: FirewallClient{
|
||||
ID: id,
|
||||
TenantID: tenantID,
|
||||
Name: strings.TrimSpace(in.Name),
|
||||
Hostname: strings.TrimSpace(in.Hostname),
|
||||
TokenPrefix: in.TokenPrefix,
|
||||
Status: "pending",
|
||||
ClientVersion: strings.TrimSpace(in.ClientVersion),
|
||||
CreatedAt: now,
|
||||
},
|
||||
TokenHash: append([]byte(nil), in.TokenHash...),
|
||||
}
|
||||
m.firewallClients[id] = rec
|
||||
m.firewallHashIndex[hashKey] = id
|
||||
return firewallClientCopy(&rec.FirewallClient), nil
|
||||
}
|
||||
|
||||
func (m *Memory) UpdateFirewallClient(tenantID, id string, patch *FirewallClientPatch) (*FirewallClient, error) {
|
||||
if patch == nil {
|
||||
return nil, ErrInvalidInput
|
||||
}
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
rec, ok := m.firewallClients[id]
|
||||
if !ok || rec.TenantID != tenantID {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
if patch.Name != nil {
|
||||
n := strings.TrimSpace(*patch.Name)
|
||||
if n == "" {
|
||||
return nil, ErrInvalidInput
|
||||
}
|
||||
rec.Name = n
|
||||
}
|
||||
if patch.Hostname != nil {
|
||||
rec.Hostname = strings.TrimSpace(*patch.Hostname)
|
||||
}
|
||||
return firewallClientCopy(&rec.FirewallClient), nil
|
||||
}
|
||||
|
||||
func (m *Memory) ApproveFirewallClient(tenantID, id, approverAPIKeyID string) (*FirewallClient, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
rec, ok := m.firewallClients[id]
|
||||
if !ok || rec.TenantID != tenantID {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
if rec.Status == "revoked" {
|
||||
return nil, ErrInvalidInput
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
rec.Status = "approved"
|
||||
rec.ApprovedAt = &now
|
||||
rec.ApprovedByAPIKeyID = strings.TrimSpace(approverAPIKeyID)
|
||||
rec.RevokedAt = nil
|
||||
return firewallClientCopy(&rec.FirewallClient), nil
|
||||
}
|
||||
|
||||
func (m *Memory) RevokeFirewallClient(tenantID, id string) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
rec, ok := m.firewallClients[id]
|
||||
if !ok || rec.TenantID != tenantID {
|
||||
return ErrNotFound
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
rec.Status = "revoked"
|
||||
rec.RevokedAt = &now
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Memory) DeleteFirewallClient(tenantID, id string) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
rec, ok := m.firewallClients[id]
|
||||
if !ok || rec.TenantID != tenantID {
|
||||
return ErrNotFound
|
||||
}
|
||||
hashKey := hex.EncodeToString(rec.TokenHash)
|
||||
delete(m.firewallHashIndex, hashKey)
|
||||
delete(m.firewallClients, id)
|
||||
for rid, rule := range m.firewallRules {
|
||||
if rule.ClientID != nil && *rule.ClientID == id {
|
||||
delete(m.firewallRules, rid)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Memory) LookupFirewallClientByTokenHash(hash []byte) (*FirewallClient, error) {
|
||||
if len(hash) != 32 {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
id, ok := m.firewallHashIndex[hex.EncodeToString(hash)]
|
||||
if !ok {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
rec, ok := m.firewallClients[id]
|
||||
if !ok {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
return firewallClientCopy(&rec.FirewallClient), nil
|
||||
}
|
||||
|
||||
func (m *Memory) TouchFirewallClientLastSeen(id, source, clientIP, clientVersion string) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
rec, ok := m.firewallClients[id]
|
||||
if !ok {
|
||||
return ErrNotFound
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
rec.LastSeenAt = &now
|
||||
rec.LastSeenAtSource = strings.TrimSpace(source)
|
||||
rec.LastSeenIP = strings.TrimSpace(clientIP)
|
||||
if v := strings.TrimSpace(clientVersion); v != "" {
|
||||
rec.ClientVersion = v
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Memory) TouchFirewallClientLastApply(id, source, status, errMsg string, prefixCount, ipCount int) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
rec, ok := m.firewallClients[id]
|
||||
if !ok {
|
||||
return ErrNotFound
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
rec.LastApplyAt = &now
|
||||
rec.LastApplySource = strings.TrimSpace(source)
|
||||
rec.LastApplyStatus = strings.TrimSpace(status)
|
||||
rec.LastApplyError = strings.TrimSpace(errMsg)
|
||||
rec.LastApplyPrefixCount = prefixCount
|
||||
rec.LastApplyIPCount = ipCount
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Memory) ListActiveFirewallClientHashes() ([]FirewallClientAuthRow, error) {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
var out []FirewallClientAuthRow
|
||||
for _, rec := range m.firewallClients {
|
||||
if rec.Status != "approved" {
|
||||
continue
|
||||
}
|
||||
out = append(out, FirewallClientAuthRow{
|
||||
ID: rec.ID,
|
||||
TenantID: rec.TenantID,
|
||||
TokenHash: append([]byte(nil), rec.TokenHash...),
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (m *Memory) ListApprovedFirewallClientsForReplication(tenantID string) ([]FirewallClientReplicationRow, error) {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
var out []FirewallClientReplicationRow
|
||||
for _, rec := range m.firewallClients {
|
||||
if rec.TenantID != tenantID || rec.Status != "approved" {
|
||||
continue
|
||||
}
|
||||
out = append(out, FirewallClientReplicationRow{
|
||||
ClientID: rec.ID,
|
||||
Name: rec.Name,
|
||||
TokenHashHex: hex.EncodeToString(rec.TokenHash),
|
||||
})
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].ClientID < out[j].ClientID })
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (m *Memory) ListFirewallRules(tenantID string, clientID *string) ([]*FirewallRule, error) {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
var out []*FirewallRule
|
||||
for _, rule := range m.firewallRules {
|
||||
if rule.TenantID != tenantID {
|
||||
continue
|
||||
}
|
||||
if clientID == nil {
|
||||
if rule.ClientID != nil {
|
||||
continue
|
||||
}
|
||||
} else if rule.ClientID == nil || *rule.ClientID != *clientID {
|
||||
continue
|
||||
}
|
||||
out = append(out, firewallRuleCopy(rule))
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].Priority < out[j].Priority })
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (m *Memory) ListAllFirewallRulesForClient(tenantID, clientID string) ([]*FirewallRule, error) {
|
||||
tenantRules, err := m.ListFirewallRules(tenantID, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cid := clientID
|
||||
clientRules, err := m.ListFirewallRules(tenantID, &cid)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]*FirewallRule, 0, len(tenantRules)+len(clientRules))
|
||||
out = append(out, clientRules...)
|
||||
out = append(out, tenantRules...)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (m *Memory) ListAllFirewallRulesForReplication(tenantID string) ([]*FirewallRule, error) {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
var out []*FirewallRule
|
||||
for _, rule := range m.firewallRules {
|
||||
if rule.TenantID == tenantID {
|
||||
out = append(out, firewallRuleCopy(rule))
|
||||
}
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool {
|
||||
ac, bc := "", ""
|
||||
if out[i].ClientID != nil {
|
||||
ac = *out[i].ClientID
|
||||
}
|
||||
if out[j].ClientID != nil {
|
||||
bc = *out[j].ClientID
|
||||
}
|
||||
if ac != bc {
|
||||
return ac < bc
|
||||
}
|
||||
return out[i].Priority < out[j].Priority
|
||||
})
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (m *Memory) CreateFirewallRule(tenantID string, clientID *string, in *FirewallRuleCreate) (*FirewallRule, error) {
|
||||
if in == nil || !ValidFirewallAction(in.Action) {
|
||||
return nil, ErrInvalidInput
|
||||
}
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if _, ok := m.tenants[tenantID]; !ok {
|
||||
return nil, ErrTenantScope
|
||||
}
|
||||
if clientID != nil {
|
||||
if rec, ok := m.firewallClients[*clientID]; !ok || rec.TenantID != tenantID {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
}
|
||||
priority := m.nextFirewallRulePriorityLocked(tenantID, clientID)
|
||||
if in.Priority != nil && *in.Priority >= 1 {
|
||||
priority = *in.Priority
|
||||
}
|
||||
if m.firewallRulePriorityTakenLocked(tenantID, clientID, priority, "") {
|
||||
return nil, ErrInvalidInput
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
id := uuid.NewString()
|
||||
rule := &FirewallRule{
|
||||
ID: id,
|
||||
TenantID: tenantID,
|
||||
ClientID: clientID,
|
||||
Priority: priority,
|
||||
Action: strings.ToLower(strings.TrimSpace(in.Action)),
|
||||
CommunityID: in.CommunityID,
|
||||
Comment: strings.TrimSpace(in.Comment),
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
m.firewallRules[id] = rule
|
||||
return firewallRuleCopy(rule), nil
|
||||
}
|
||||
|
||||
func (m *Memory) UpdateFirewallRule(tenantID, ruleID string, patch *FirewallRulePatch) (*FirewallRule, error) {
|
||||
if patch == nil {
|
||||
return nil, ErrInvalidInput
|
||||
}
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
rule, ok := m.firewallRules[ruleID]
|
||||
if !ok || rule.TenantID != tenantID {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
if patch.Action != nil {
|
||||
if !ValidFirewallAction(*patch.Action) {
|
||||
return nil, ErrInvalidInput
|
||||
}
|
||||
rule.Action = strings.ToLower(strings.TrimSpace(*patch.Action))
|
||||
}
|
||||
if patch.ClearCommunity {
|
||||
rule.CommunityID = nil
|
||||
} else if patch.CommunityID != nil {
|
||||
rule.CommunityID = patch.CommunityID
|
||||
}
|
||||
if patch.Comment != nil {
|
||||
rule.Comment = strings.TrimSpace(*patch.Comment)
|
||||
}
|
||||
rule.UpdatedAt = time.Now().UTC()
|
||||
return firewallRuleCopy(rule), nil
|
||||
}
|
||||
|
||||
func (m *Memory) DeleteFirewallRule(tenantID, ruleID string) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
rule, ok := m.firewallRules[ruleID]
|
||||
if !ok || rule.TenantID != tenantID {
|
||||
return ErrNotFound
|
||||
}
|
||||
delete(m.firewallRules, ruleID)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Memory) ReorderFirewallRules(tenantID string, clientID *string, orderedIDs []string) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
scope := make(map[string]*FirewallRule)
|
||||
for id, rule := range m.firewallRules {
|
||||
if rule.TenantID != tenantID {
|
||||
continue
|
||||
}
|
||||
if clientID == nil {
|
||||
if rule.ClientID != nil {
|
||||
continue
|
||||
}
|
||||
} else if rule.ClientID == nil || *rule.ClientID != *clientID {
|
||||
continue
|
||||
}
|
||||
scope[id] = rule
|
||||
}
|
||||
if len(orderedIDs) != len(scope) {
|
||||
return ErrInvalidInput
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
for i, id := range orderedIDs {
|
||||
rule, ok := scope[id]
|
||||
if !ok {
|
||||
return ErrInvalidInput
|
||||
}
|
||||
rule.Priority = i + 1
|
||||
rule.UpdatedAt = now
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Memory) nextFirewallRulePriorityLocked(tenantID string, clientID *string) int {
|
||||
max := 0
|
||||
for _, rule := range m.firewallRules {
|
||||
if rule.TenantID != tenantID {
|
||||
continue
|
||||
}
|
||||
if clientID == nil {
|
||||
if rule.ClientID != nil {
|
||||
continue
|
||||
}
|
||||
} else if rule.ClientID == nil || *rule.ClientID != *clientID {
|
||||
continue
|
||||
}
|
||||
if rule.Priority > max {
|
||||
max = rule.Priority
|
||||
}
|
||||
}
|
||||
return max + 1
|
||||
}
|
||||
|
||||
func (m *Memory) firewallRulePriorityTakenLocked(tenantID string, clientID *string, priority int, exceptID string) bool {
|
||||
for id, rule := range m.firewallRules {
|
||||
if id == exceptID || rule.TenantID != tenantID || rule.Priority != priority {
|
||||
continue
|
||||
}
|
||||
if clientID == nil {
|
||||
if rule.ClientID == nil {
|
||||
return true
|
||||
}
|
||||
continue
|
||||
}
|
||||
if rule.ClientID != nil && *rule.ClientID == *clientID {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func firewallClientCopy(c *FirewallClient) *FirewallClient {
|
||||
cp := *c
|
||||
cp.LastSeenAt = cloneTime(c.LastSeenAt)
|
||||
cp.LastApplyAt = cloneTime(c.LastApplyAt)
|
||||
cp.ApprovedAt = cloneTime(c.ApprovedAt)
|
||||
cp.RevokedAt = cloneTime(c.RevokedAt)
|
||||
return &cp
|
||||
}
|
||||
|
||||
func firewallRuleCopy(r *FirewallRule) *FirewallRule {
|
||||
cp := *r
|
||||
if r.ClientID != nil {
|
||||
v := *r.ClientID
|
||||
cp.ClientID = &v
|
||||
}
|
||||
if r.CommunityID != nil {
|
||||
v := *r.CommunityID
|
||||
cp.CommunityID = &v
|
||||
}
|
||||
return &cp
|
||||
}
|
||||
|
||||
func cloneTime(t *time.Time) *time.Time {
|
||||
if t == nil {
|
||||
return nil
|
||||
}
|
||||
v := *t
|
||||
return &v
|
||||
}
|
||||
@@ -9,17 +9,21 @@ import (
|
||||
|
||||
// SpeakerMeta holds well-known keys from bgp_speaker.meta_json.
|
||||
type SpeakerMeta struct {
|
||||
AgentDomain string `json:"agent_domain,omitempty"`
|
||||
AgentSecret string `json:"agent_secret,omitempty"`
|
||||
AgentPort int `json:"agent_port,omitempty"`
|
||||
NodeIPv4 string `json:"node_ipv4,omitempty"`
|
||||
BirdBgpSourceIPv4 string `json:"bird_bgp_source_ipv4,omitempty"`
|
||||
BirdBgpSourceIPv6 string `json:"bird_bgp_source_ipv6,omitempty"`
|
||||
NodeEnrolledAt string `json:"node_enrolled_at,omitempty"`
|
||||
LastDispatchAt string `json:"last_dispatch_at,omitempty"`
|
||||
LastDispatchError string `json:"last_dispatch_error,omitempty"`
|
||||
LastDispatchStatus string `json:"last_dispatch_status,omitempty"`
|
||||
SyncStatus string `json:"sync_status,omitempty"`
|
||||
AgentDomain string `json:"agent_domain,omitempty"`
|
||||
AgentSecret string `json:"agent_secret,omitempty"`
|
||||
AgentPort int `json:"agent_port,omitempty"`
|
||||
NodeIPv4 string `json:"node_ipv4,omitempty"`
|
||||
BirdBgpSourceIPv4 string `json:"bird_bgp_source_ipv4,omitempty"`
|
||||
BirdBgpSourceIPv6 string `json:"bird_bgp_source_ipv6,omitempty"`
|
||||
NodeEnrolledAt string `json:"node_enrolled_at,omitempty"`
|
||||
LastDispatchAt string `json:"last_dispatch_at,omitempty"`
|
||||
LastDispatchError string `json:"last_dispatch_error,omitempty"`
|
||||
LastDispatchStatus string `json:"last_dispatch_status,omitempty"`
|
||||
SyncStatus string `json:"sync_status,omitempty"`
|
||||
FirewallFailover bool `json:"firewall_failover,omitempty"`
|
||||
LastFirewallReplicateAt string `json:"last_firewall_replicate_at,omitempty"`
|
||||
LastFirewallReplicateStatus string `json:"last_firewall_replicate_status,omitempty"`
|
||||
LastFirewallReplicateError string `json:"last_firewall_replicate_error,omitempty"`
|
||||
}
|
||||
|
||||
// ParseSpeakerMeta decodes meta_json object; unknown keys are ignored.
|
||||
@@ -83,6 +87,20 @@ func MergeSpeakerMetaJSON(existing string, patch SpeakerMeta) string {
|
||||
if patch.SyncStatus != "" {
|
||||
cur.SyncStatus = patch.SyncStatus
|
||||
}
|
||||
if patch.FirewallFailover {
|
||||
cur.FirewallFailover = true
|
||||
}
|
||||
if patch.LastFirewallReplicateAt != "" {
|
||||
cur.LastFirewallReplicateAt = patch.LastFirewallReplicateAt
|
||||
}
|
||||
if patch.LastFirewallReplicateStatus == "ok" {
|
||||
cur.LastFirewallReplicateError = ""
|
||||
} else if patch.LastFirewallReplicateError != "" {
|
||||
cur.LastFirewallReplicateError = patch.LastFirewallReplicateError
|
||||
}
|
||||
if patch.LastFirewallReplicateStatus != "" {
|
||||
cur.LastFirewallReplicateStatus = patch.LastFirewallReplicateStatus
|
||||
}
|
||||
return SpeakerMetaJSON(cur)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
DROP TABLE IF EXISTS firewall_rule;
|
||||
DROP TABLE IF EXISTS firewall_client;
|
||||
@@ -0,0 +1,51 @@
|
||||
CREATE TABLE firewall_client (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID NOT NULL REFERENCES tenant (id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
hostname TEXT,
|
||||
token_prefix TEXT NOT NULL,
|
||||
token_hash BYTEA NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'pending',
|
||||
enroll_seed_used BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
last_seen_at TIMESTAMPTZ,
|
||||
last_seen_at_source TEXT,
|
||||
last_seen_ip TEXT,
|
||||
last_apply_at TIMESTAMPTZ,
|
||||
last_apply_status TEXT,
|
||||
last_apply_error TEXT,
|
||||
last_apply_prefix_count INTEGER DEFAULT 0,
|
||||
last_apply_ip_count INTEGER DEFAULT 0,
|
||||
last_apply_source TEXT,
|
||||
client_version TEXT,
|
||||
settings_json JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
approved_at TIMESTAMPTZ,
|
||||
approved_by_api_key_id UUID,
|
||||
revoked_at TIMESTAMPTZ,
|
||||
CONSTRAINT firewall_client_status_chk CHECK (status IN ('pending', 'approved', 'revoked')),
|
||||
CONSTRAINT firewall_client_name_chk CHECK (length(trim(name)) > 0),
|
||||
CONSTRAINT firewall_client_token_hash_len_chk CHECK (octet_length(token_hash) = 32)
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX idx_firewall_client_token_hash ON firewall_client (token_hash);
|
||||
CREATE INDEX idx_firewall_client_tenant_status ON firewall_client (tenant_id, status);
|
||||
CREATE INDEX idx_firewall_client_last_seen ON firewall_client (last_seen_at DESC) WHERE status = 'approved';
|
||||
|
||||
CREATE TABLE firewall_rule (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID NOT NULL REFERENCES tenant (id) ON DELETE CASCADE,
|
||||
client_id UUID REFERENCES firewall_client (id) ON DELETE CASCADE,
|
||||
priority INTEGER NOT NULL,
|
||||
action TEXT NOT NULL,
|
||||
community_id UUID REFERENCES bgp_community (id) ON DELETE CASCADE,
|
||||
comment TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
CONSTRAINT firewall_rule_action_chk CHECK (action IN ('block', 'accept')),
|
||||
CONSTRAINT firewall_rule_priority_chk CHECK (priority >= 1 AND priority <= 10000),
|
||||
CONSTRAINT firewall_rule_scope_uniq UNIQUE (tenant_id, client_id, priority)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_firewall_rule_tenant_priority ON firewall_rule (tenant_id, priority);
|
||||
CREATE INDEX idx_firewall_rule_client ON firewall_rule (client_id) WHERE client_id IS NOT NULL;
|
||||
CREATE INDEX idx_firewall_rule_tenant_default ON firewall_rule (tenant_id, priority) WHERE client_id IS NULL;
|
||||
@@ -0,0 +1,2 @@
|
||||
DROP TABLE IF EXISTS firewall_rule;
|
||||
DROP TABLE IF EXISTS firewall_client;
|
||||
@@ -0,0 +1,51 @@
|
||||
CREATE TABLE firewall_client (
|
||||
id TEXT PRIMARY KEY,
|
||||
tenant_id TEXT NOT NULL REFERENCES tenant (id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
hostname TEXT,
|
||||
token_prefix TEXT NOT NULL,
|
||||
token_hash BLOB NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'pending',
|
||||
enroll_seed_used INTEGER NOT NULL DEFAULT 1,
|
||||
last_seen_at TEXT,
|
||||
last_seen_at_source TEXT,
|
||||
last_seen_ip TEXT,
|
||||
last_apply_at TEXT,
|
||||
last_apply_status TEXT,
|
||||
last_apply_error TEXT,
|
||||
last_apply_prefix_count INTEGER DEFAULT 0,
|
||||
last_apply_ip_count INTEGER DEFAULT 0,
|
||||
last_apply_source TEXT,
|
||||
client_version TEXT,
|
||||
settings_json TEXT NOT NULL DEFAULT '{}',
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
||||
approved_at TEXT,
|
||||
approved_by_api_key_id TEXT,
|
||||
revoked_at TEXT,
|
||||
CHECK (status IN ('pending', 'approved', 'revoked')),
|
||||
CHECK (length(trim(name)) > 0),
|
||||
CHECK (length(token_hash) = 32)
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX idx_firewall_client_token_hash ON firewall_client (token_hash);
|
||||
CREATE INDEX idx_firewall_client_tenant_status ON firewall_client (tenant_id, status);
|
||||
CREATE INDEX idx_firewall_client_last_seen ON firewall_client (last_seen_at DESC) WHERE status = 'approved';
|
||||
|
||||
CREATE TABLE firewall_rule (
|
||||
id TEXT PRIMARY KEY,
|
||||
tenant_id TEXT NOT NULL REFERENCES tenant (id) ON DELETE CASCADE,
|
||||
client_id TEXT REFERENCES firewall_client (id) ON DELETE CASCADE,
|
||||
priority INTEGER NOT NULL,
|
||||
action TEXT NOT NULL,
|
||||
community_id TEXT REFERENCES bgp_community (id) ON DELETE CASCADE,
|
||||
comment TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
||||
CHECK (action IN ('block', 'accept')),
|
||||
CHECK (priority >= 1 AND priority <= 10000),
|
||||
UNIQUE (tenant_id, client_id, priority)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_firewall_rule_tenant_priority ON firewall_rule (tenant_id, priority);
|
||||
CREATE INDEX idx_firewall_rule_client ON firewall_rule (client_id) WHERE client_id IS NOT NULL;
|
||||
CREATE INDEX idx_firewall_rule_tenant_default ON firewall_rule (tenant_id, priority) WHERE client_id IS NULL;
|
||||
@@ -0,0 +1,169 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
CONF_FILE=/etc/evobgp/firewall.conf
|
||||
LOG_FILE=/var/log/evobgp-firewall.log
|
||||
STATE_DIR=/var/lib/evobgp-firewall
|
||||
HASH_FILE="${STATE_DIR}/last_hash"
|
||||
|
||||
log() { echo "$(date -u +%Y-%m-%dT%H:%M:%SZ) $*" | tee -a "$LOG_FILE"; }
|
||||
|
||||
if [[ ! -f "$CONF_FILE" ]]; then
|
||||
log "missing $CONF_FILE"
|
||||
exit 1
|
||||
fi
|
||||
# shellcheck disable=SC1090
|
||||
source "$CONF_FILE"
|
||||
|
||||
: "${EVOBGP_CP_URL:?}"
|
||||
: "${CLIENT_TOKEN:?}"
|
||||
|
||||
mkdir -p "$STATE_DIR"
|
||||
BACKEND="${KERNEL_BACKEND:-auto}"
|
||||
|
||||
curl_get_blocklist() {
|
||||
local url="$1"
|
||||
local host
|
||||
host=$(echo "$url" | sed -E 's#https?://([^/]+)/?.*#\1#')
|
||||
local tmp
|
||||
tmp=$(mktemp)
|
||||
local code
|
||||
code=$(curl -sS -o "$tmp" -w "%{http_code}" \
|
||||
-H "Authorization: Bearer ${CLIENT_TOKEN}" \
|
||||
-H "Accept: application/json" \
|
||||
"${url}/v1/firewall/blocklist") || return 1
|
||||
if [[ "$code" == "403" ]]; then
|
||||
log "pending approval"
|
||||
rm -f "$tmp"
|
||||
exit 0
|
||||
fi
|
||||
if [[ "$code" != "200" ]]; then
|
||||
log "blocklist HTTP $code from $url"
|
||||
rm -f "$tmp"
|
||||
return 1
|
||||
fi
|
||||
cat "$tmp"
|
||||
rm -f "$tmp"
|
||||
}
|
||||
|
||||
try_urls() {
|
||||
local urls=()
|
||||
if [[ -n "${EVOBGP_FAILOVER_URLS:-}" ]]; then
|
||||
IFS=',' read -r -a urls <<<"$EVOBGP_FAILOVER_URLS"
|
||||
else
|
||||
urls=("${EVOBGP_CP_URL%/}")
|
||||
fi
|
||||
local u
|
||||
for u in "${urls[@]}"; do
|
||||
u="${u// /}"
|
||||
u="${u%/}"
|
||||
if OUT=$(curl_get_blocklist "$u"); then
|
||||
CP_HIT="$u"
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
if ! OUT=$(try_urls); then
|
||||
log "all endpoints failed"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if command -v jq >/dev/null 2>&1; then
|
||||
HASH=$(echo "$OUT" | jq -r '.hash // empty')
|
||||
TOTAL=$(echo "$OUT" | jq -r '.total // 0')
|
||||
mapfile -t PREFIXES < <(echo "$OUT" | jq -r '.prefixes[]?')
|
||||
else
|
||||
HASH=$(echo "$OUT" | grep -o '"hash"[[:space:]]*:[[:space:]]*"[^"]*"' | head -1 | sed 's/.*"\(sha256:[^"]*\)".*/\1/')
|
||||
TOTAL=$(echo "$OUT" | grep -o '"total"[[:space:]]*:[[:space:]]*[0-9]*' | head -1 | grep -o '[0-9]*$')
|
||||
mapfile -t PREFIXES < <(echo "$OUT" | grep -o '"[0-9a-fA-F:.]*/[0-9]*"' | tr -d '"')
|
||||
fi
|
||||
|
||||
if [[ -f "$HASH_FILE" && "$(cat "$HASH_FILE")" == "$HASH" ]]; then
|
||||
log "unchanged hash $HASH — skip kernel apply"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
apply_nft() {
|
||||
local table=inet
|
||||
local name=evobgp_blocklist
|
||||
nft list table "$table" "$name" >/dev/null 2>&1 || nft add table "$table" "$name"
|
||||
nft list set "$table" "$name" v4 >/dev/null 2>&1 || nft add set "$table" "$name" v4 '{ type ipv4_addr; flags interval; }'
|
||||
nft flush set "$table" "$name" v4
|
||||
if ((${#PREFIXES[@]})); then
|
||||
local v4=()
|
||||
local p
|
||||
for p in "${PREFIXES[@]}"; do
|
||||
[[ "$p" == *:* ]] && continue
|
||||
v4+=("$p")
|
||||
done
|
||||
if ((${#v4[@]})); then
|
||||
nft add element "$table" "$name" v4 "{ $(IFS=,; echo "${v4[*]}") }"
|
||||
fi
|
||||
fi
|
||||
nft list chain "$table" "$name" input >/dev/null 2>&1 || {
|
||||
nft add chain "$table" "$name" input '{ type filter hook input priority 0; }'
|
||||
nft add rule "$table" "$name" input ip saddr @v4 drop
|
||||
}
|
||||
}
|
||||
|
||||
apply_ipset() {
|
||||
local set=evobgp_blocklist_v4
|
||||
ipset list "$set" >/dev/null 2>&1 || ipset create "$set" hash:net family inet hashsize 4096 maxelem 1048576
|
||||
ipset flush "$set"
|
||||
local p
|
||||
for p in "${PREFIXES[@]}"; do
|
||||
[[ "$p" == *:* ]] && continue
|
||||
ipset add "$set" "$p" -exist
|
||||
done
|
||||
iptables -C INPUT -m set --match-set "$set" src -j DROP 2>/dev/null || \
|
||||
iptables -I INPUT -m set --match-set "$set" src -j DROP
|
||||
}
|
||||
|
||||
apply_iptables_only() {
|
||||
iptables -D INPUT -m comment --comment evobgp-block -j DROP 2>/dev/null || true
|
||||
if ((${#PREFIXES[@]})); then
|
||||
local p
|
||||
for p in "${PREFIXES[@]}"; do
|
||||
[[ "$p" == *:* ]] && continue
|
||||
iptables -C INPUT -s "$p" -j DROP 2>/dev/null || iptables -A INPUT -s "$p" -j DROP
|
||||
done
|
||||
fi
|
||||
}
|
||||
|
||||
clear_block() {
|
||||
case "$BACKEND" in
|
||||
nft) nft delete table inet evobgp_blocklist 2>/dev/null || true ;;
|
||||
ipset)
|
||||
ipset destroy evobgp_blocklist_v4 2>/dev/null || true
|
||||
iptables -D INPUT -m set --match-set evobgp_blocklist_v4 src -j DROP 2>/dev/null || true
|
||||
;;
|
||||
iptables) iptables -S INPUT | grep -i evobgp | sed 's/^-A /-D /' | while read -r line; do iptables $line 2>/dev/null || true; done ;;
|
||||
esac
|
||||
}
|
||||
|
||||
if [[ "$TOTAL" == "0" || ${#PREFIXES[@]} -eq 0 ]]; then
|
||||
clear_block
|
||||
else
|
||||
case "$BACKEND" in
|
||||
nft|auto) if command -v nft >/dev/null 2>&1; then apply_nft; else apply_ipset; fi ;;
|
||||
ipset) apply_ipset ;;
|
||||
iptables) apply_iptables_only ;;
|
||||
*) apply_ipset ;;
|
||||
esac
|
||||
fi
|
||||
|
||||
echo "$HASH" >"$HASH_FILE"
|
||||
log "applied $TOTAL prefixes from ${CP_HIT:-$EVOBGP_CP_URL} backend=$BACKEND"
|
||||
|
||||
REPORT=$(printf '{"status":"ok","prefix_count":%s,"ip_count":0,"source":"cp"}' "${TOTAL:-0}")
|
||||
curl -fsS -X POST "${EVOBGP_CP_URL%/}/v1/firewall/apply-report" \
|
||||
-H "Authorization: Bearer ${CLIENT_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "$REPORT" >/dev/null 2>&1 || true
|
||||
|
||||
curl -fsS -X POST "${EVOBGP_CP_URL%/}/v1/firewall/heartbeat" \
|
||||
-H "Authorization: Bearer ${CLIENT_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"source":"cp"}' >/dev/null 2>&1 || true
|
||||
@@ -0,0 +1,110 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
if [[ "${EUID:-$(id -u)}" -ne 0 ]]; then
|
||||
echo "evobgp-firewall install: run as root" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
for cmd in curl bash; do
|
||||
command -v "$cmd" >/dev/null 2>&1 || { echo "missing $cmd" >&2; exit 1; }
|
||||
done
|
||||
|
||||
: "${EVOBGP_CP_URL:?EVOBGP_CP_URL required}"
|
||||
: "${EVOBGP_SEED:?EVOBGP_SEED required}"
|
||||
: "${EVOBGP_CLIENT_NAME:?EVOBGP_CLIENT_NAME required}"
|
||||
|
||||
CONF_DIR=/etc/evobgp
|
||||
CONF_FILE="${CONF_DIR}/firewall.conf"
|
||||
SYNC_SCRIPT=/usr/local/sbin/evobgp-firewall.sh
|
||||
|
||||
if [[ -f "$CONF_FILE" && "${EVOBGP_INSTALL_FORCE:-}" != "1" ]]; then
|
||||
echo "Already installed ($CONF_FILE). Set EVOBGP_INSTALL_FORCE=1 to reinstall." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
gen_token() {
|
||||
if command -v openssl >/dev/null 2>&1; then
|
||||
echo -n "evobgp_fw_$(openssl rand -base64 32 | tr '+/' '-_' | tr -d '=')"
|
||||
else
|
||||
echo -n "evobgp_fw_$(head -c 32 /dev/urandom | base64 | tr '+/' '-_' | tr -d '=\n')"
|
||||
fi
|
||||
}
|
||||
|
||||
CLIENT_TOKEN="$(gen_token)"
|
||||
HOSTNAME="$(hostname -f 2>/dev/null || hostname)"
|
||||
CP_URL="${EVOBGP_CP_URL%/}"
|
||||
|
||||
ENROLL_BODY=$(printf '{"name":"%s","hostname":"%s","client_token":"%s","client_version":"install.sh/1"}' \
|
||||
"$EVOBGP_CLIENT_NAME" "$HOSTNAME" "$CLIENT_TOKEN")
|
||||
|
||||
RESP=$(curl -fsS -X POST "${CP_URL}/v1/firewall/enroll" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "X-EvoBGP-Seed: ${EVOBGP_SEED}" \
|
||||
-d "$ENROLL_BODY")
|
||||
|
||||
CLIENT_ID=""
|
||||
if command -v jq >/dev/null 2>&1; then
|
||||
CLIENT_ID=$(echo "$RESP" | jq -r '.client_id')
|
||||
else
|
||||
CLIENT_ID=$(echo "$RESP" | sed -n 's/.*"client_id"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p')
|
||||
fi
|
||||
|
||||
mkdir -p "$CONF_DIR"
|
||||
chmod 700 "$CONF_DIR"
|
||||
cat >"$CONF_FILE" <<EOF
|
||||
EVOBGP_CP_URL=${CP_URL}
|
||||
CLIENT_ID=${CLIENT_ID}
|
||||
CLIENT_TOKEN=${CLIENT_TOKEN}
|
||||
CLIENT_NAME=${EVOBGP_CLIENT_NAME}
|
||||
KERNEL_BACKEND=auto
|
||||
EOF
|
||||
chmod 600 "$CONF_FILE"
|
||||
|
||||
curl -fsSL "${CP_URL}/v1/firewall/sync-script" -o "$SYNC_SCRIPT"
|
||||
chmod 755 "$SYNC_SCRIPT"
|
||||
|
||||
if command -v nft >/dev/null 2>&1; then
|
||||
BACKEND=nft
|
||||
elif command -v ipset >/dev/null 2>&1 && command -v iptables >/dev/null 2>&1; then
|
||||
BACKEND=ipset
|
||||
elif command -v iptables >/dev/null 2>&1; then
|
||||
BACKEND=iptables
|
||||
else
|
||||
echo "no supported firewall backend (nft/ipset/iptables)" >&2
|
||||
exit 1
|
||||
fi
|
||||
sed -i "s/^KERNEL_BACKEND=.*/KERNEL_BACKEND=${BACKEND}/" "$CONF_FILE" 2>/dev/null || \
|
||||
echo "KERNEL_BACKEND=${BACKEND}" >>"$CONF_FILE"
|
||||
|
||||
INTERVAL="${EVOBGP_SYNC_INTERVAL:-5min}"
|
||||
if command -v systemctl >/dev/null 2>&1; then
|
||||
cat >/etc/systemd/system/evobgp-firewall.service <<'UNIT'
|
||||
[Unit]
|
||||
Description=EvoBGP firewall blocklist sync
|
||||
After=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
ExecStart=/usr/local/sbin/evobgp-firewall.sh
|
||||
UNIT
|
||||
cat >/etc/systemd/system/evobgp-firewall.timer <<UNIT
|
||||
[Unit]
|
||||
Description=EvoBGP firewall sync timer
|
||||
|
||||
[Timer]
|
||||
OnBootSec=2min
|
||||
OnUnitActiveSec=${INTERVAL}
|
||||
Unit=evobgp-firewall.service
|
||||
|
||||
[Install]
|
||||
WantedBy=timers.target
|
||||
UNIT
|
||||
systemctl daemon-reload
|
||||
systemctl enable --now evobgp-firewall.timer
|
||||
else
|
||||
echo "*/5 * * * * root ${SYNC_SCRIPT}" >/etc/cron.d/evobgp-firewall
|
||||
fi
|
||||
|
||||
echo "Client ID: ${CLIENT_ID}"
|
||||
echo "Status: pending — approve in EvoBGP UI → Firewall → Запросы"
|
||||
@@ -0,0 +1,19 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
systemctl disable --now evobgp-firewall.timer 2>/dev/null || true
|
||||
rm -f /etc/cron.d/evobgp-firewall
|
||||
rm -f /etc/systemd/system/evobgp-firewall.service /etc/systemd/system/evobgp-firewall.timer
|
||||
systemctl daemon-reload 2>/dev/null || true
|
||||
|
||||
nft delete table inet evobgp_blocklist 2>/dev/null || true
|
||||
ipset destroy evobgp_blocklist_v4 2>/dev/null || true
|
||||
iptables -D INPUT -m set --match-set evobgp_blocklist_v4 src -j DROP 2>/dev/null || true
|
||||
|
||||
rm -f /usr/local/sbin/evobgp-firewall.sh /usr/local/sbin/evobgp-firewall-uninstall.sh
|
||||
rm -rf /var/lib/evobgp-firewall
|
||||
if [[ "${EVOBGP_UNINSTALL_REMOVE_CONF:-}" == "1" ]]; then
|
||||
rm -f /etc/evobgp/firewall.conf
|
||||
fi
|
||||
|
||||
echo "evobgp-firewall uninstalled"
|
||||
Reference in New Issue
Block a user