Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e15768b25b | ||
|
|
7b3f002e5f | ||
|
|
fa2abc81f3 | ||
|
|
7a3eae98b1 | ||
|
|
276194a9d0 | ||
|
|
53b3c49612 | ||
|
|
db79820df0 | ||
|
|
a902a4270d |
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"pid": 59836,
|
"pid": 39884,
|
||||||
"version": "0.9.9",
|
"version": "0.9.9",
|
||||||
"socketPath": "\\\\.\\pipe\\codegraph-97b92efdcc5351da",
|
"socketPath": "\\\\.\\pipe\\codegraph-97b92efdcc5351da",
|
||||||
"startedAt": 1783332305171
|
"startedAt": 1783489774882
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,190 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { Plus, RefreshCw, Trash2 } from 'lucide-react'
|
||||||
|
|
||||||
|
import { Button } from '@evobgp/ui/components/button'
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@evobgp/ui/components/card'
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from '@evobgp/ui/components/table'
|
||||||
|
|
||||||
|
import { ApiKeyCreateDialog } from '@/components/access/api-key-create-dialog'
|
||||||
|
import { ApiKeyTokenDialog } from '@/components/access/api-key-token-dialog'
|
||||||
|
import { Badge } from '@/components/reui/badge'
|
||||||
|
import { ConfirmDialog } from '@/components/confirm-dialog'
|
||||||
|
import { QueryState } from '@/components/query-state'
|
||||||
|
import { StatusBadge } from '@/components/status-badge'
|
||||||
|
import { TableSkeleton } from '@/components/skeletons'
|
||||||
|
import { formatApiKeyDate } from '@/lib/access/api-key-labels'
|
||||||
|
import { useRevokeApiKeyMutation, useRotateApiKeyMutation } from '@/queries/api-keys'
|
||||||
|
import type { ApiKey, ApiKeyCreated } from '@/types/api'
|
||||||
|
|
||||||
|
interface AccessApiKeysCardProps {
|
||||||
|
items: ApiKey[]
|
||||||
|
isLoading: boolean
|
||||||
|
isError: boolean
|
||||||
|
error: unknown
|
||||||
|
onRetry: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AccessApiKeysCard({
|
||||||
|
items,
|
||||||
|
isLoading,
|
||||||
|
isError,
|
||||||
|
error,
|
||||||
|
onRetry,
|
||||||
|
}: AccessApiKeysCardProps) {
|
||||||
|
const [createOpen, setCreateOpen] = useState(false)
|
||||||
|
const [tokenDialogOpen, setTokenDialogOpen] = useState(false)
|
||||||
|
const [revealedToken, setRevealedToken] = useState('')
|
||||||
|
|
||||||
|
const revoke = useRevokeApiKeyMutation()
|
||||||
|
const rotate = useRotateApiKeyMutation()
|
||||||
|
|
||||||
|
function showToken(created: ApiKeyCreated) {
|
||||||
|
setRevealedToken(created.token)
|
||||||
|
setTokenDialogOpen(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleRotated(id: string) {
|
||||||
|
rotate.mutate(id, {
|
||||||
|
onSuccess: (created) => showToken(created),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-col gap-3 border-b py-3 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<CardTitle className="text-base">API-ключи</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Управление ключами tenant. Полный токен показывается только при создании и ротации.
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
<div className="flex shrink-0 flex-wrap items-center justify-end gap-2">
|
||||||
|
<Button size="sm" variant="outline" onClick={onRetry} disabled={isLoading}>
|
||||||
|
<RefreshCw className={isLoading ? 'animate-spin' : ''} />
|
||||||
|
Обновить
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" onClick={() => setCreateOpen(true)}>
|
||||||
|
<Plus />
|
||||||
|
Создать
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="p-0">
|
||||||
|
<QueryState
|
||||||
|
data={items}
|
||||||
|
isLoading={isLoading}
|
||||||
|
isError={isError}
|
||||||
|
error={error}
|
||||||
|
empty={items.length === 0}
|
||||||
|
emptyTitle="Нет ключей"
|
||||||
|
emptyDescription="Создайте API-ключ для автоматизации или отдельного доступа."
|
||||||
|
skeleton={<TableSkeleton rows={4} cols={6} />}
|
||||||
|
onRetry={onRetry}
|
||||||
|
>
|
||||||
|
{(data) => (
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Имя</TableHead>
|
||||||
|
<TableHead>Роль</TableHead>
|
||||||
|
<TableHead>Префикс</TableHead>
|
||||||
|
<TableHead>Статус</TableHead>
|
||||||
|
<TableHead>Истекает</TableHead>
|
||||||
|
<TableHead>Последнее использование</TableHead>
|
||||||
|
<TableHead className="w-24" />
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{data.map((k) => (
|
||||||
|
<TableRow key={k.id}>
|
||||||
|
<TableCell className="font-medium">{k.name}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant="outline" className="font-mono text-xs">
|
||||||
|
{k.role}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="font-mono text-xs text-muted-foreground">
|
||||||
|
{k.prefix}…
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{k.revoked_at ? (
|
||||||
|
<StatusBadge status="error" label="отозван" />
|
||||||
|
) : (
|
||||||
|
<StatusBadge status="active" label="активен" />
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-sm text-muted-foreground">
|
||||||
|
{formatApiKeyDate(k.expires_at)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-sm text-muted-foreground">
|
||||||
|
{formatApiKeyDate(k.last_used_at)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<ConfirmDialog
|
||||||
|
trigger={
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
title="Ротировать"
|
||||||
|
disabled={!!k.revoked_at || rotate.isPending}
|
||||||
|
>
|
||||||
|
<RefreshCw className="size-3.5" />
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
title="Ротировать ключ?"
|
||||||
|
description="Старый токен перестанет работать сразу."
|
||||||
|
confirmLabel="Ротировать"
|
||||||
|
onConfirm={() => handleRotated(k.id)}
|
||||||
|
/>
|
||||||
|
<ConfirmDialog
|
||||||
|
trigger={
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
className="text-destructive"
|
||||||
|
disabled={!!k.revoked_at || revoke.isPending}
|
||||||
|
title="Отозвать"
|
||||||
|
>
|
||||||
|
<Trash2 className="size-3.5" />
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
title="Отозвать API-ключ?"
|
||||||
|
description={`${k.name} (${k.prefix}…)`}
|
||||||
|
confirmLabel="Отозвать"
|
||||||
|
destructive
|
||||||
|
onConfirm={() => revoke.mutate(k.id)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
)}
|
||||||
|
</QueryState>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<ApiKeyCreateDialog
|
||||||
|
open={createOpen}
|
||||||
|
onOpenChange={setCreateOpen}
|
||||||
|
onCreated={showToken}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ApiKeyTokenDialog
|
||||||
|
open={tokenDialogOpen}
|
||||||
|
token={revealedToken}
|
||||||
|
onOpenChange={setTokenDialogOpen}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
|
||||||
|
import { Button } from '@evobgp/ui/components/button'
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@evobgp/ui/components/dialog'
|
||||||
|
import { Input } from '@evobgp/ui/components/input'
|
||||||
|
import { Label } from '@evobgp/ui/components/label'
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@evobgp/ui/components/select'
|
||||||
|
|
||||||
|
import { LoadingButton } from '@/components/loading-button'
|
||||||
|
import { API_KEY_ROLE_ITEMS } from '@/lib/access/api-key-labels'
|
||||||
|
import { useCreateApiKeyMutation } from '@/queries/api-keys'
|
||||||
|
import type { ApiKeyCreate, ApiKeyCreated, ApiKeyRole } from '@/types/api'
|
||||||
|
|
||||||
|
interface ApiKeyCreateDialogProps {
|
||||||
|
open: boolean
|
||||||
|
onOpenChange: (open: boolean) => void
|
||||||
|
onCreated: (created: ApiKeyCreated) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ApiKeyCreateDialog({ open, onOpenChange, onCreated }: ApiKeyCreateDialogProps) {
|
||||||
|
const createMutation = useCreateApiKeyMutation()
|
||||||
|
const [name, setName] = useState('')
|
||||||
|
const [role, setRole] = useState<ApiKeyRole>('editor')
|
||||||
|
const [expiresLocal, setExpiresLocal] = useState('')
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return
|
||||||
|
setName('')
|
||||||
|
setRole('editor')
|
||||||
|
setExpiresLocal('')
|
||||||
|
}, [open])
|
||||||
|
|
||||||
|
function handleOpenChange(next: boolean) {
|
||||||
|
onOpenChange(next)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
if (!name.trim()) {
|
||||||
|
toast.error('Укажите имя')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const body: ApiKeyCreate = {
|
||||||
|
name: name.trim(),
|
||||||
|
role,
|
||||||
|
}
|
||||||
|
if (expiresLocal.trim()) {
|
||||||
|
const d = new Date(expiresLocal)
|
||||||
|
if (Number.isNaN(d.getTime())) {
|
||||||
|
toast.error('Некорректная дата истечения')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
body.expires_at = d.toISOString()
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const created = await createMutation.mutateAsync(body)
|
||||||
|
onOpenChange(false)
|
||||||
|
onCreated(created)
|
||||||
|
} catch {
|
||||||
|
// toast handled in mutation
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||||
|
<DialogContent className="sm:max-w-sm">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Новый API-ключ</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="flex flex-col gap-4 py-2">
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label htmlFor="key-name">Имя</Label>
|
||||||
|
<Input
|
||||||
|
id="key-name"
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
placeholder="CI / оператор UI"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label htmlFor="key-role">Роль</Label>
|
||||||
|
<Select
|
||||||
|
items={[...API_KEY_ROLE_ITEMS]}
|
||||||
|
value={role}
|
||||||
|
onValueChange={(v) => v && setRole(v as ApiKeyRole)}
|
||||||
|
>
|
||||||
|
<SelectTrigger id="key-role" className="w-full">
|
||||||
|
<SelectValue placeholder="Выберите роль" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{API_KEY_ROLE_ITEMS.map((opt) => (
|
||||||
|
<SelectItem key={opt.value} value={opt.value}>
|
||||||
|
{opt.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label htmlFor="key-expires">Истекает (опционально)</Label>
|
||||||
|
<Input
|
||||||
|
id="key-expires"
|
||||||
|
type="datetime-local"
|
||||||
|
value={expiresLocal}
|
||||||
|
onChange={(e) => setExpiresLocal(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => handleOpenChange(false)}>
|
||||||
|
Отмена
|
||||||
|
</Button>
|
||||||
|
<LoadingButton onClick={save} loading={createMutation.isPending}>
|
||||||
|
Создать
|
||||||
|
</LoadingButton>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import { Copy } from 'lucide-react'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
|
||||||
|
import { Button } from '@evobgp/ui/components/button'
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@evobgp/ui/components/dialog'
|
||||||
|
|
||||||
|
interface ApiKeyTokenDialogProps {
|
||||||
|
open: boolean
|
||||||
|
token: string
|
||||||
|
onOpenChange: (open: boolean) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ApiKeyTokenDialog({ open, token, onOpenChange }: ApiKeyTokenDialogProps) {
|
||||||
|
async function copyToken() {
|
||||||
|
if (!token) return
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(token)
|
||||||
|
toast.success('Скопировано')
|
||||||
|
} catch {
|
||||||
|
toast.error('Не удалось скопировать')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="sm:max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Сохраните токен</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Он больше не будет показан. Скопируйте в безопасное хранилище.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="break-all rounded-md border bg-muted/40 p-3 font-mono text-xs">{token}</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={copyToken}>
|
||||||
|
<Copy />
|
||||||
|
Копировать
|
||||||
|
</Button>
|
||||||
|
<Button onClick={() => onOpenChange(false)}>Готово</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
BookText,
|
BookText,
|
||||||
KeyRound,
|
KeyRound,
|
||||||
ServerCog,
|
ServerCog,
|
||||||
|
Shield,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -69,6 +70,7 @@ const NAV_GROUPS: NavGroup[] = [
|
|||||||
label: 'Операции',
|
label: 'Операции',
|
||||||
items: [
|
items: [
|
||||||
{ to: '/operations', label: 'Операции', icon: Cog },
|
{ to: '/operations', label: 'Операции', icon: Cog },
|
||||||
|
{ to: '/firewall', label: 'Firewall', icon: Shield },
|
||||||
{ to: '/schedule', label: 'Задачи', icon: ListChecks },
|
{ to: '/schedule', label: 'Задачи', icon: ListChecks },
|
||||||
{ to: '/monitoring', label: 'Мониторинг', icon: Activity },
|
{ to: '/monitoring', label: 'Мониторинг', icon: Activity },
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import type { ApiKeyRole } from '@/types/api'
|
||||||
|
|
||||||
|
export const API_KEY_ROLE_ITEMS: ReadonlyArray<{ value: ApiKeyRole; label: string }> = [
|
||||||
|
{ value: 'viewer', label: 'viewer — только чтение' },
|
||||||
|
{ value: 'editor', label: 'editor — CRUD без apply' },
|
||||||
|
{ value: 'operator', label: 'operator — полный доступ' },
|
||||||
|
{ value: 'node', label: 'node — только API ноды' },
|
||||||
|
]
|
||||||
|
|
||||||
|
export function apiKeyRoleLabel(role: ApiKeyRole): string {
|
||||||
|
return API_KEY_ROLE_ITEMS.find((o) => o.value === role)?.label ?? role
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatApiKeyDate(iso: string | null | undefined): string {
|
||||||
|
if (!iso) return '—'
|
||||||
|
const d = new Date(iso)
|
||||||
|
if (Number.isNaN(d.getTime())) return '—'
|
||||||
|
return d.toLocaleString('ru-RU')
|
||||||
|
}
|
||||||
@@ -11,6 +11,9 @@ import type {
|
|||||||
|
|
||||||
export const TOKEN_STORAGE_KEY = 'evobgp_api_token'
|
export const TOKEN_STORAGE_KEY = 'evobgp_api_token'
|
||||||
|
|
||||||
|
/** Локальный demo-токен (operator) при включённом demo-seed — см. docs/access.md */
|
||||||
|
export const DEV_API_TOKEN = 'dev'
|
||||||
|
|
||||||
export type Problem = {
|
export type Problem = {
|
||||||
type?: string
|
type?: string
|
||||||
title?: string
|
title?: string
|
||||||
@@ -18,14 +21,31 @@ export type Problem = {
|
|||||||
detail?: string
|
detail?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Убирает пробелы и опциональный префикс Bearer (UI часто вставляет «Bearer dev»). */
|
||||||
|
export function normalizeApiToken(raw: string): string {
|
||||||
|
let t = raw.trim()
|
||||||
|
if (/^bearer\s+/i.test(t)) {
|
||||||
|
t = t.replace(/^bearer\s+/i, '').trim()
|
||||||
|
}
|
||||||
|
return t
|
||||||
|
}
|
||||||
|
|
||||||
function getToken(): string | null {
|
function getToken(): string | null {
|
||||||
if (typeof window === 'undefined') return null
|
if (typeof window === 'undefined') return null
|
||||||
return window.localStorage.getItem(TOKEN_STORAGE_KEY)
|
const raw = window.localStorage.getItem(TOKEN_STORAGE_KEY)
|
||||||
|
if (!raw) return null
|
||||||
|
const normalized = normalizeApiToken(raw)
|
||||||
|
return normalized || null
|
||||||
}
|
}
|
||||||
|
|
||||||
export function setToken(token: string | null): void {
|
export function setToken(token: string | null): void {
|
||||||
if (typeof window === 'undefined') return
|
if (typeof window === 'undefined') return
|
||||||
if (token) window.localStorage.setItem(TOKEN_STORAGE_KEY, token)
|
if (!token) {
|
||||||
|
window.localStorage.removeItem(TOKEN_STORAGE_KEY)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const normalized = normalizeApiToken(token)
|
||||||
|
if (normalized) window.localStorage.setItem(TOKEN_STORAGE_KEY, normalized)
|
||||||
else window.localStorage.removeItem(TOKEN_STORAGE_KEY)
|
else window.localStorage.removeItem(TOKEN_STORAGE_KEY)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { queryOptions } from '@tanstack/react-query'
|
import { queryOptions, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
import { apiJSON } from '@/lib/api-client'
|
import { toast } from 'sonner'
|
||||||
import type { ApiKey, ApiKeysResponse } from '@/types/api'
|
|
||||||
|
import { apiJSON, apiMutate } from '@/lib/api-client'
|
||||||
|
import type { ApiKey, ApiKeyCreate, ApiKeyCreated, ApiKeysResponse } from '@/types/api'
|
||||||
|
|
||||||
export const apiKeysKeys = {
|
export const apiKeysKeys = {
|
||||||
all: ['api-keys'] as const,
|
all: ['api-keys'] as const,
|
||||||
@@ -17,3 +19,48 @@ export function apiKeysQueryOptions() {
|
|||||||
staleTime: 60_000,
|
staleTime: 60_000,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function invalidateApiKeysList(qc: ReturnType<typeof useQueryClient>) {
|
||||||
|
void qc.invalidateQueries({ queryKey: apiKeysKeys.list() })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCreateApiKeyMutation() {
|
||||||
|
const qc = useQueryClient()
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (body: ApiKeyCreate) =>
|
||||||
|
apiMutate<ApiKeyCreated>('/v1/api-keys', 'POST', body, { idempotent: false }),
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success('Ключ создан')
|
||||||
|
invalidateApiKeysList(qc)
|
||||||
|
},
|
||||||
|
onError: (e) => toast.error(e instanceof Error ? e.message : 'Не удалось создать ключ'),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useRevokeApiKeyMutation() {
|
||||||
|
const qc = useQueryClient()
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (id: string) =>
|
||||||
|
apiMutate(`/v1/api-keys/${id}`, 'DELETE', undefined, { idempotent: false }),
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success('Ключ отозван')
|
||||||
|
invalidateApiKeysList(qc)
|
||||||
|
},
|
||||||
|
onError: (e) => toast.error(e instanceof Error ? e.message : 'Не удалось отозвать'),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useRotateApiKeyMutation() {
|
||||||
|
const qc = useQueryClient()
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (id: string) =>
|
||||||
|
apiMutate<ApiKeyCreated>(`/v1/api-keys/${id}/rotate`, 'POST', undefined, {
|
||||||
|
idempotent: false,
|
||||||
|
}),
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success('Ключ ротирован')
|
||||||
|
invalidateApiKeysList(qc)
|
||||||
|
},
|
||||||
|
onError: (e) => toast.error(e instanceof Error ? e.message : 'Не удалось ротировать'),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import { queryOptions, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import { apiJSON } from '@/lib/api-client'
|
||||||
|
import type {
|
||||||
|
FirewallClient,
|
||||||
|
FirewallClientsResponse,
|
||||||
|
FirewallInstallContext,
|
||||||
|
FirewallRule,
|
||||||
|
FirewallRulesResponse,
|
||||||
|
} from '@/types/api'
|
||||||
|
|
||||||
|
export const firewallKeys = {
|
||||||
|
all: ['firewall'] as const,
|
||||||
|
clients: () => [...firewallKeys.all, 'clients'] as const,
|
||||||
|
installContext: () => [...firewallKeys.all, 'install-context'] as const,
|
||||||
|
rules: (scope: string, clientId?: string) =>
|
||||||
|
[...firewallKeys.all, 'rules', scope, clientId ?? ''] as const,
|
||||||
|
}
|
||||||
|
|
||||||
|
export function firewallInstallContextQueryOptions() {
|
||||||
|
return queryOptions<FirewallInstallContext>({
|
||||||
|
queryKey: firewallKeys.installContext(),
|
||||||
|
queryFn: () => apiJSON<FirewallInstallContext>('/v1/firewall/install-context'),
|
||||||
|
staleTime: 60_000,
|
||||||
|
retry: false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function firewallClientsQueryOptions() {
|
||||||
|
return queryOptions<FirewallClientsResponse>({
|
||||||
|
queryKey: firewallKeys.clients(),
|
||||||
|
queryFn: () => apiJSON<FirewallClientsResponse>('/v1/firewall/clients'),
|
||||||
|
staleTime: 15_000,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function firewallRulesQueryOptions(scope: 'tenant' | 'client', clientId?: string) {
|
||||||
|
const qs =
|
||||||
|
scope === 'client' && clientId
|
||||||
|
? `?scope=client&client_id=${encodeURIComponent(clientId)}`
|
||||||
|
: '?scope=tenant'
|
||||||
|
return queryOptions<FirewallRulesResponse>({
|
||||||
|
queryKey: firewallKeys.rules(scope, clientId),
|
||||||
|
queryFn: () => apiJSON<FirewallRulesResponse>(`/v1/firewall/rules${qs}`),
|
||||||
|
staleTime: 15_000,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useApproveFirewallClient() {
|
||||||
|
const qc = useQueryClient()
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (id: string) =>
|
||||||
|
apiJSON<FirewallClient>(`/v1/firewall/clients/${id}/approve`, { method: 'POST' }),
|
||||||
|
onSuccess: () => {
|
||||||
|
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 })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -1,10 +1,14 @@
|
|||||||
import { createFileRoute, Outlet, redirect } from '@tanstack/react-router'
|
import { createFileRoute, Outlet, redirect } from '@tanstack/react-router'
|
||||||
|
|
||||||
|
import { normalizeApiToken, TOKEN_STORAGE_KEY } from '@/lib/api-client'
|
||||||
|
|
||||||
export const Route = createFileRoute('/_auth')({
|
export const Route = createFileRoute('/_auth')({
|
||||||
beforeLoad: () => {
|
beforeLoad: ({ location }) => {
|
||||||
const token =
|
// Настройки доступны без токена — сюда попадают при первом входе (в т.ч. для `dev`).
|
||||||
typeof window !== 'undefined' ? window.localStorage.getItem('evobgp_api_token') : null
|
if (location.pathname === '/settings') return
|
||||||
if (!token) {
|
const raw =
|
||||||
|
typeof window !== 'undefined' ? window.localStorage.getItem(TOKEN_STORAGE_KEY) : null
|
||||||
|
if (!raw || !normalizeApiToken(raw)) {
|
||||||
throw redirect({ to: '/settings' })
|
throw redirect({ to: '/settings' })
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,29 +1,18 @@
|
|||||||
import { createFileRoute } from '@tanstack/react-router'
|
import { createFileRoute, Link } from '@tanstack/react-router'
|
||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
import { Info, KeyRound, RefreshCw, ShieldCheck, ShieldOff } from 'lucide-react'
|
||||||
|
import { useMemo } from 'react'
|
||||||
|
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@evobgp/ui/components/card'
|
import { Alert, AlertDescription, AlertTitle } from '@evobgp/ui/components/alert'
|
||||||
import { Button } from '@evobgp/ui/components/button'
|
import { Button } from '@evobgp/ui/components/button'
|
||||||
import {
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@evobgp/ui/components/card'
|
||||||
Table,
|
|
||||||
TableBody,
|
|
||||||
TableCell,
|
|
||||||
TableHead,
|
|
||||||
TableHeader,
|
|
||||||
TableRow,
|
|
||||||
} from '@evobgp/ui/components/table'
|
|
||||||
import { RefreshCw } from 'lucide-react'
|
|
||||||
import { toast } from 'sonner'
|
|
||||||
|
|
||||||
|
import { AccessApiKeysCard } from '@/components/access/access-api-keys-card'
|
||||||
import { PageHeader } from '@/components/page-header'
|
import { PageHeader } from '@/components/page-header'
|
||||||
import { QueryState } from '@/components/query-state'
|
import { SectionCards, type SectionCardItem } from '@/components/section-cards'
|
||||||
import { ConfirmDialog } from '@/components/confirm-dialog'
|
import { SectionCardsSkeleton } from '@/components/skeletons'
|
||||||
import { authSessionQueryOptions } from '@/queries/auth'
|
import { authSessionQueryOptions } from '@/queries/auth'
|
||||||
import { apiKeysQueryOptions } from '@/queries/api-keys'
|
import { apiKeysQueryOptions } from '@/queries/api-keys'
|
||||||
import { apiMutate } from '@/lib/api-client'
|
|
||||||
import { useMutation } from '@tanstack/react-query'
|
|
||||||
import type { ApiKeyCreated } from '@/types/api'
|
|
||||||
import { useState } from 'react'
|
|
||||||
import { Copy } from 'lucide-react'
|
|
||||||
|
|
||||||
export const Route = createFileRoute('/_auth/access')({
|
export const Route = createFileRoute('/_auth/access')({
|
||||||
component: AccessComponent,
|
component: AccessComponent,
|
||||||
@@ -39,20 +28,80 @@ function AccessComponent() {
|
|||||||
enabled: isOperator,
|
enabled: isOperator,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const keys = keysQuery.data ?? []
|
||||||
|
const activeCount = keys.filter((k) => !k.revoked_at).length
|
||||||
|
const revokedCount = keys.filter((k) => k.revoked_at).length
|
||||||
|
|
||||||
|
const refreshing = sessionQuery.isFetching || keysQuery.isFetching
|
||||||
|
|
||||||
|
const kpiItems: SectionCardItem[] = useMemo(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
label: 'Всего ключей',
|
||||||
|
value: keys.length,
|
||||||
|
icon: <KeyRound className="size-4" />,
|
||||||
|
hint: 'в tenant',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Активных',
|
||||||
|
value: activeCount,
|
||||||
|
icon: <ShieldCheck className="size-4" />,
|
||||||
|
hint: 'не отозваны',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Отозванных',
|
||||||
|
value: revokedCount,
|
||||||
|
icon: <ShieldOff className="size-4" />,
|
||||||
|
hint: 'revoked',
|
||||||
|
variant: revokedCount > 0 ? 'warning' : 'default',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[keys.length, activeCount, revokedCount],
|
||||||
|
)
|
||||||
|
|
||||||
|
function refetchAll() {
|
||||||
|
void sessionQuery.refetch()
|
||||||
|
if (isOperator) void keysQuery.refetch()
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto flex max-w-4xl flex-col gap-6">
|
<div className="flex flex-col gap-6">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="Права доступа"
|
title="Права доступа"
|
||||||
description="API-ключи control plane и текущая сессия Bearer-токена."
|
description="API-ключи control plane и текущая сессия Bearer-токена."
|
||||||
|
actions={
|
||||||
|
isOperator ? (
|
||||||
|
<Button variant="outline" size="sm" onClick={refetchAll} disabled={refreshing}>
|
||||||
|
<RefreshCw className={refreshing ? 'animate-spin' : ''} />
|
||||||
|
Обновить
|
||||||
|
</Button>
|
||||||
|
) : undefined
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<Alert className="border-info/30 bg-info/5">
|
||||||
|
<Info className="text-info" />
|
||||||
|
<AlertTitle>О API-ключах</AlertTitle>
|
||||||
|
<AlertDescription>
|
||||||
|
Роли: <code className="text-xs">viewer</code> (чтение),{' '}
|
||||||
|
<code className="text-xs">editor</code> (CRUD), <code className="text-xs">operator</code>{' '}
|
||||||
|
(apply и настройки), <code className="text-xs">node</code> (API ноды). Полный токен
|
||||||
|
показывается один раз при создании и ротации. Токен браузера — в{' '}
|
||||||
|
<Link to="/settings" className="text-primary underline-offset-4 hover:underline">
|
||||||
|
настройках
|
||||||
|
</Link>
|
||||||
|
; для локальной разработки с demo-seed подойдёт <code className="text-xs">dev</code>{' '}
|
||||||
|
(роль operator).
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
|
||||||
{session ? (
|
{session ? (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader className="border-b py-3">
|
||||||
<CardTitle className="text-base">Текущая сессия</CardTitle>
|
<CardTitle className="text-base">Текущая сессия</CardTitle>
|
||||||
<CardDescription>Tenant и роль ключа, с которым открыта панель.</CardDescription>
|
<CardDescription>Tenant и роль ключа, с которым открыта панель.</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="grid gap-3 text-sm sm:grid-cols-2">
|
<CardContent className="grid gap-3 p-4 text-sm sm:grid-cols-2">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-muted-foreground">Tenant</p>
|
<p className="text-muted-foreground">Tenant</p>
|
||||||
<p className="break-all font-mono text-xs">{session.tenant_id}</p>
|
<p className="break-all font-mono text-xs">{session.tenant_id}</p>
|
||||||
@@ -63,183 +112,46 @@ function AccessComponent() {
|
|||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
) : null}
|
) : (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="py-6 text-sm text-muted-foreground">
|
||||||
|
Не удалось определить сессию. Укажите токен в{' '}
|
||||||
|
<Link to="/settings" className="text-primary underline-offset-4 hover:underline">
|
||||||
|
настройках
|
||||||
|
</Link>{' '}
|
||||||
|
(для dev-окружения — <code className="text-xs">dev</code> при включённом demo-seed).
|
||||||
|
{sessionQuery.isError && sessionQuery.error instanceof Error ? (
|
||||||
|
<span className="mt-2 block text-destructive">{sessionQuery.error.message}</span>
|
||||||
|
) : null}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
{isOperator ? (
|
{isOperator ? (
|
||||||
<ApiKeysCard
|
<>
|
||||||
items={keysQuery.data ?? []}
|
{keysQuery.isLoading ? (
|
||||||
isLoading={keysQuery.isLoading}
|
<SectionCardsSkeleton count={3} />
|
||||||
isError={keysQuery.isError}
|
) : (
|
||||||
error={keysQuery.error}
|
<SectionCards items={kpiItems} />
|
||||||
onRetry={() => keysQuery.refetch()}
|
)}
|
||||||
/>
|
<AccessApiKeysCard
|
||||||
|
items={keys}
|
||||||
|
isLoading={keysQuery.isLoading}
|
||||||
|
isError={keysQuery.isError}
|
||||||
|
error={keysQuery.error}
|
||||||
|
onRetry={() => keysQuery.refetch()}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
) : session ? (
|
) : session ? (
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="py-6 text-sm text-muted-foreground">
|
<CardContent className="py-6 text-sm text-muted-foreground">
|
||||||
Управление API-ключами доступно только роли <strong>operator</strong>. Текущая роль:{' '}
|
Управление API-ключами доступно только роли <strong>operator</strong>. Текущая роль:{' '}
|
||||||
<span className="font-mono">{session.role}</span>.
|
<span className="font-mono">{session.role}</span>. Для выдачи ключей войдите с
|
||||||
|
operator-ключом или создайте ключ через API / переменную{' '}
|
||||||
|
<code className="text-xs">EVOBGP_API_KEYS</code>.
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function ApiKeysCard({
|
|
||||||
items,
|
|
||||||
isLoading,
|
|
||||||
isError,
|
|
||||||
error,
|
|
||||||
onRetry,
|
|
||||||
}: {
|
|
||||||
items: import('@/types/api').ApiKey[]
|
|
||||||
isLoading: boolean
|
|
||||||
isError: boolean
|
|
||||||
error: unknown
|
|
||||||
onRetry: () => void
|
|
||||||
}) {
|
|
||||||
const [revealedToken, setRevealedToken] = useState<string | null>(null)
|
|
||||||
|
|
||||||
const revoke = useMutation({
|
|
||||||
mutationFn: (id: string) =>
|
|
||||||
apiMutate(`/v1/api-keys/${id}`, 'DELETE', undefined, { idempotent: false }),
|
|
||||||
onSuccess: () => toast.success('Ключ отозван'),
|
|
||||||
onError: (e) => toast.error(e instanceof Error ? e.message : 'Не удалось отозвать'),
|
|
||||||
})
|
|
||||||
|
|
||||||
const rotate = useMutation({
|
|
||||||
mutationFn: (id: string) =>
|
|
||||||
apiMutate<ApiKeyCreated>(`/v1/api-keys/${id}/rotate`, 'POST', undefined, {
|
|
||||||
idempotent: false,
|
|
||||||
}),
|
|
||||||
onSuccess: (created) => {
|
|
||||||
toast.success('Ключ ротирован')
|
|
||||||
setRevealedToken(created.token)
|
|
||||||
},
|
|
||||||
onError: (e) => toast.error(e instanceof Error ? e.message : 'Не удалось ротировать'),
|
|
||||||
})
|
|
||||||
|
|
||||||
async function copyToken() {
|
|
||||||
if (!revealedToken) return
|
|
||||||
try {
|
|
||||||
await navigator.clipboard.writeText(revealedToken)
|
|
||||||
toast.success('Скопировано')
|
|
||||||
} catch {
|
|
||||||
toast.error('Не удалось скопировать')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Card>
|
|
||||||
<CardHeader className="flex flex-col gap-3 border-b py-3 sm:flex-row sm:items-center sm:justify-between">
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<CardTitle className="text-base">API-ключи</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
Управление ключами tenant. Полный токен показывается только при создании и ротации.
|
|
||||||
</CardDescription>
|
|
||||||
</div>
|
|
||||||
<Button size="sm" variant="outline" onClick={onRetry} disabled={isLoading}>
|
|
||||||
<RefreshCw className={isLoading ? 'animate-spin' : ''} />
|
|
||||||
Обновить
|
|
||||||
</Button>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="p-0">
|
|
||||||
<QueryState
|
|
||||||
data={items}
|
|
||||||
isLoading={isLoading}
|
|
||||||
isError={isError}
|
|
||||||
error={error}
|
|
||||||
empty={items.length === 0}
|
|
||||||
emptyTitle="Нет ключей"
|
|
||||||
emptyDescription="Ключи можно создать через API."
|
|
||||||
onRetry={onRetry}
|
|
||||||
>
|
|
||||||
{(data) => (
|
|
||||||
<Table>
|
|
||||||
<TableHeader>
|
|
||||||
<TableRow>
|
|
||||||
<TableHead>Имя</TableHead>
|
|
||||||
<TableHead>Роль</TableHead>
|
|
||||||
<TableHead>Префикс</TableHead>
|
|
||||||
<TableHead>Статус</TableHead>
|
|
||||||
<TableHead className="w-24" />
|
|
||||||
</TableRow>
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody>
|
|
||||||
{data.map((k) => (
|
|
||||||
<TableRow key={k.id}>
|
|
||||||
<TableCell className="font-medium">{k.name}</TableCell>
|
|
||||||
<TableCell className="font-mono text-sm">{k.role}</TableCell>
|
|
||||||
<TableCell className="font-mono text-xs text-muted-foreground">{k.prefix}…</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
{k.revoked_at ? (
|
|
||||||
<span className="text-sm text-destructive">отозван</span>
|
|
||||||
) : (
|
|
||||||
<span className="text-sm text-muted-foreground">активен</span>
|
|
||||||
)}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<div className="flex gap-1">
|
|
||||||
<ConfirmDialog
|
|
||||||
trigger={
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon-sm"
|
|
||||||
title="Ротировать"
|
|
||||||
disabled={!!k.revoked_at}
|
|
||||||
>
|
|
||||||
<RefreshCw className="size-3.5" />
|
|
||||||
</Button>
|
|
||||||
}
|
|
||||||
title="Ротировать ключ?"
|
|
||||||
description="Старый токен перестанет работать сразу."
|
|
||||||
confirmLabel="Ротировать"
|
|
||||||
onConfirm={() => rotate.mutate(k.id)}
|
|
||||||
/>
|
|
||||||
<ConfirmDialog
|
|
||||||
trigger={
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon-sm"
|
|
||||||
className="text-destructive"
|
|
||||||
disabled={!!k.revoked_at}
|
|
||||||
title="Отозвать"
|
|
||||||
>
|
|
||||||
<Copy className="size-3.5" />
|
|
||||||
</Button>
|
|
||||||
}
|
|
||||||
title="Отозвать API-ключ?"
|
|
||||||
description={`${k.name} (${k.prefix}…)`}
|
|
||||||
confirmLabel="Отозвать"
|
|
||||||
destructive
|
|
||||||
onConfirm={() => revoke.mutate(k.id)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
))}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
)}
|
|
||||||
</QueryState>
|
|
||||||
</CardContent>
|
|
||||||
|
|
||||||
{revealedToken ? (
|
|
||||||
<div className="flex flex-col gap-3 border-t p-4">
|
|
||||||
<div className="text-sm font-medium">Новый токен (сохраните сейчас):</div>
|
|
||||||
<div className="break-all rounded-md border bg-muted/40 p-3 font-mono text-xs">
|
|
||||||
{revealedToken}
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Button variant="outline" size="sm" onClick={copyToken}>
|
|
||||||
<Copy /> Копировать
|
|
||||||
</Button>
|
|
||||||
<Button size="sm" onClick={() => setRevealedToken(null)}>
|
|
||||||
Готово
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</Card>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,361 @@
|
|||||||
|
import { createFileRoute } from '@tanstack/react-router'
|
||||||
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
import { Copy, Info, RefreshCw, Shield } from 'lucide-react'
|
||||||
|
import { useEffect, 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 { CommunitySelect } from '@/components/modules/community-select'
|
||||||
|
import { StatusBadge } from '@/components/status-badge'
|
||||||
|
import { communityLabel } from '@/lib/modules/helpers'
|
||||||
|
import { directoriesCommunitiesQueryOptions } from '@/queries/directories'
|
||||||
|
import {
|
||||||
|
firewallClientsQueryOptions,
|
||||||
|
firewallInstallContextQueryOptions,
|
||||||
|
firewallRulesQueryOptions,
|
||||||
|
useApproveFirewallClient,
|
||||||
|
useCreateFirewallRule,
|
||||||
|
useDeleteFirewallRule,
|
||||||
|
} from '@/queries/firewall'
|
||||||
|
import type { BgpCommunity, FirewallClient } from '@/types/api'
|
||||||
|
|
||||||
|
function httpsOrigin(origin: string): string {
|
||||||
|
try {
|
||||||
|
const u = new URL(origin)
|
||||||
|
u.protocol = 'https:'
|
||||||
|
return u.origin
|
||||||
|
} catch {
|
||||||
|
return origin.replace(/^http:/i, 'https:')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Route = createFileRoute('/_auth/firewall')({
|
||||||
|
component: FirewallPage,
|
||||||
|
})
|
||||||
|
|
||||||
|
function FirewallPage() {
|
||||||
|
const installCtxQ = useQuery(firewallInstallContextQueryOptions())
|
||||||
|
const communitiesQ = useQuery(directoriesCommunitiesQueryOptions())
|
||||||
|
const clientsQ = useQuery(firewallClientsQueryOptions())
|
||||||
|
const rulesQ = useQuery(firewallRulesQueryOptions('tenant'))
|
||||||
|
const approve = useApproveFirewallClient()
|
||||||
|
const createRule = useCreateFirewallRule()
|
||||||
|
const deleteRule = useDeleteFirewallRule()
|
||||||
|
|
||||||
|
const installCtx = installCtxQ.data
|
||||||
|
|
||||||
|
const [clientName, setClientName] = useState('web-01')
|
||||||
|
const [cpUrl, setCpUrl] = useState(() =>
|
||||||
|
typeof window !== 'undefined' ? httpsOrigin(window.location.origin) : 'https://api.example.com',
|
||||||
|
)
|
||||||
|
const [seed, setSeed] = useState('')
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (installCtx?.suggested_cp_url) {
|
||||||
|
setCpUrl(httpsOrigin(installCtx.suggested_cp_url))
|
||||||
|
}
|
||||||
|
if (installCtx?.bundle_seed) {
|
||||||
|
setSeed(installCtx.bundle_seed)
|
||||||
|
}
|
||||||
|
}, [installCtx?.bundle_seed, installCtx?.suggested_cp_url])
|
||||||
|
const [ruleAction, setRuleAction] = useState<'block' | 'accept'>('block')
|
||||||
|
const [ruleCommunityId, setRuleCommunityId] = useState<string | null>(null)
|
||||||
|
const [ruleComment, setRuleComment] = useState('')
|
||||||
|
|
||||||
|
const communities = communitiesQ.data?.items ?? []
|
||||||
|
|
||||||
|
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(
|
||||||
|
installCtx?.bundle_seed_configured === false
|
||||||
|
? 'На CP не задан EVOBGP_BUNDLE_SEED_HEX'
|
||||||
|
: 'Bundle seed недоступен (нужна роль operator)',
|
||||||
|
)
|
||||||
|
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>BGP community</strong> префиксов опубликованной revision.{' '}
|
||||||
|
<strong>block</strong> добавляет префиксы community в kernel; <strong>accept</strong> — не блокирует.
|
||||||
|
Community «Все» — правило для любого community. 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"
|
||||||
|
readOnly
|
||||||
|
placeholder="EVOBGP_BUNDLE_SEED_HEX"
|
||||||
|
value={seed}
|
||||||
|
className="font-mono text-xs"
|
||||||
|
/>
|
||||||
|
<p className="text-muted-foreground text-xs">
|
||||||
|
{installCtxQ.isLoading
|
||||||
|
? 'Загрузка из control plane…'
|
||||||
|
: installCtx?.bundle_seed_configured
|
||||||
|
? 'Из переменной EVOBGP_BUNDLE_SEED_HEX на CP (docker compose / .env)'
|
||||||
|
: 'На CP не задан EVOBGP_BUNDLE_SEED_HEX — enroll невозможен'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<pre className="overflow-x-auto rounded-md border bg-muted/40 p-3 text-xs">{installCmd}</pre>
|
||||||
|
<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-3">
|
||||||
|
<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>
|
||||||
|
<CommunitySelect
|
||||||
|
id="fw-rule-community"
|
||||||
|
label="Community"
|
||||||
|
value={ruleCommunityId}
|
||||||
|
onValueChange={setRuleCommunityId}
|
||||||
|
communities={communities}
|
||||||
|
nullable
|
||||||
|
placeholder="Все communities"
|
||||||
|
/>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label htmlFor="fw-rule-comment">Комментарий</Label>
|
||||||
|
<Input
|
||||||
|
id="fw-rule-comment"
|
||||||
|
className="max-w-xs"
|
||||||
|
placeholder="Комментарий"
|
||||||
|
value={ruleComment}
|
||||||
|
onChange={(e) => setRuleComment(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
className="mb-0.5"
|
||||||
|
onClick={() =>
|
||||||
|
createRule.mutate({
|
||||||
|
scope: 'tenant',
|
||||||
|
action: ruleAction,
|
||||||
|
community_id: ruleCommunityId,
|
||||||
|
comment: ruleComment,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Добавить правило
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<RulesTable
|
||||||
|
rules={rules}
|
||||||
|
communities={communities}
|
||||||
|
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,
|
||||||
|
communities,
|
||||||
|
onDelete,
|
||||||
|
}: {
|
||||||
|
rules: { id: string; priority: number; action: string; community_id?: string | null; comment?: string }[]
|
||||||
|
communities: BgpCommunity[]
|
||||||
|
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>Community</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 className="text-sm">
|
||||||
|
{r.community_id ? communityLabel(r.community_id, communities) : 'Все'}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{r.comment || '—'}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Button size="sm" variant="ghost" onClick={() => onDelete(r.id)}>
|
||||||
|
Удалить
|
||||||
|
</Button>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
import { createFileRoute } from '@tanstack/react-router'
|
import { createFileRoute, useNavigate } from '@tanstack/react-router'
|
||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
|
|
||||||
|
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 { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@evobgp/ui/components/card'
|
||||||
import { Input } from '@evobgp/ui/components/input'
|
import { Input } from '@evobgp/ui/components/input'
|
||||||
import { Label } from '@evobgp/ui/components/label'
|
import { Label } from '@evobgp/ui/components/label'
|
||||||
@@ -14,10 +16,10 @@ import {
|
|||||||
|
|
||||||
import { PageHeader } from '@/components/page-header'
|
import { PageHeader } from '@/components/page-header'
|
||||||
import { LoadingButton } from '@/components/loading-button'
|
import { LoadingButton } from '@/components/loading-button'
|
||||||
import { setToken, TOKEN_STORAGE_KEY } from '@/lib/api-client'
|
import { DEV_API_TOKEN, normalizeApiToken, setToken, TOKEN_STORAGE_KEY } from '@/lib/api-client'
|
||||||
import { authSessionQueryOptions } from '@/queries/auth'
|
import { authKeys, authSessionQueryOptions } from '@/queries/auth'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import { Save } from 'lucide-react'
|
import { Info, Save } from 'lucide-react'
|
||||||
import { useTheme } from 'next-themes'
|
import { useTheme } from 'next-themes'
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
|
|
||||||
@@ -32,7 +34,14 @@ const THEME_SELECT_ITEMS = [
|
|||||||
] as const
|
] as const
|
||||||
|
|
||||||
function SettingsComponent() {
|
function SettingsComponent() {
|
||||||
const { data: session } = useQuery(authSessionQueryOptions())
|
const navigate = useNavigate()
|
||||||
|
const qc = useQueryClient()
|
||||||
|
const { data: session, isError: sessionError, error: sessionQueryError } = useQuery({
|
||||||
|
...authSessionQueryOptions(),
|
||||||
|
enabled: Boolean(
|
||||||
|
typeof window !== 'undefined' && window.localStorage.getItem(TOKEN_STORAGE_KEY)?.trim(),
|
||||||
|
),
|
||||||
|
})
|
||||||
const { theme, setTheme } = useTheme()
|
const { theme, setTheme } = useTheme()
|
||||||
const [token, setTokenValue] = useState('')
|
const [token, setTokenValue] = useState('')
|
||||||
|
|
||||||
@@ -41,10 +50,23 @@ function SettingsComponent() {
|
|||||||
setTokenValue(t)
|
setTokenValue(t)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
function saveTokenHandler() {
|
async function applyToken(raw: string) {
|
||||||
const t = token.trim()
|
const normalized = normalizeApiToken(raw)
|
||||||
setToken(t || null)
|
setToken(normalized || null)
|
||||||
|
setTokenValue(normalized)
|
||||||
|
await qc.invalidateQueries({ queryKey: authKeys.all })
|
||||||
toast.success('Токен сохранён')
|
toast.success('Токен сохранён')
|
||||||
|
if (normalized) {
|
||||||
|
void navigate({ to: '/dashboard' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveTokenHandler() {
|
||||||
|
void applyToken(token)
|
||||||
|
}
|
||||||
|
|
||||||
|
function useDevToken() {
|
||||||
|
void applyToken(DEV_API_TOKEN)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -54,11 +76,21 @@ function SettingsComponent() {
|
|||||||
description="Параметры интерфейса и подключения браузера к API."
|
description="Параметры интерфейса и подключения браузера к API."
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<Alert className="border-info/30 bg-info/5">
|
||||||
|
<Info className="text-info" />
|
||||||
|
<AlertTitle>Локальная разработка</AlertTitle>
|
||||||
|
<AlertDescription>
|
||||||
|
При включённом demo-seed API принимает токен <code className="text-xs">dev</code> (роль{' '}
|
||||||
|
<code className="text-xs">operator</code>). Вводите только значение токена, без префикса{' '}
|
||||||
|
<code className="text-xs">Bearer</code> — он добавляется автоматически.
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Подключение к API</CardTitle>
|
<CardTitle>Подключение к API</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
Bearer-токен хранится только в этом браузере (localStorage). Управление ключами tenant — в
|
Токен хранится только в этом браузере (localStorage). Управление ключами tenant — в
|
||||||
разделе «Права доступа».
|
разделе «Права доступа».
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
@@ -71,19 +103,33 @@ function SettingsComponent() {
|
|||||||
autoComplete="off"
|
autoComplete="off"
|
||||||
value={token}
|
value={token}
|
||||||
onChange={(e) => setTokenValue(e.target.value)}
|
onChange={(e) => setTokenValue(e.target.value)}
|
||||||
placeholder="Bearer …"
|
placeholder="dev или API-ключ"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<LoadingButton onClick={saveTokenHandler}>
|
<div className="flex flex-wrap gap-2">
|
||||||
<Save />
|
<LoadingButton onClick={saveTokenHandler}>
|
||||||
Сохранить токен
|
<Save />
|
||||||
</LoadingButton>
|
Сохранить токен
|
||||||
|
</LoadingButton>
|
||||||
|
<Button type="button" variant="outline" onClick={useDevToken}>
|
||||||
|
Использовать dev
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
{session ? (
|
{session ? (
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
Активная сессия: tenant <code className="font-mono">{session.tenant_id}</code>, роль{' '}
|
Активная сессия: tenant <code className="font-mono">{session.tenant_id}</code>, роль{' '}
|
||||||
<code className="font-mono">{session.role}</code>.
|
<code className="font-mono">{session.role}</code>.
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
|
{sessionError ? (
|
||||||
|
<p className="text-xs text-destructive">
|
||||||
|
{sessionQueryError instanceof Error
|
||||||
|
? sessionQueryError.message
|
||||||
|
: 'Не удалось проверить сессию'}
|
||||||
|
. Для токена <code className="font-mono">dev</code> нужен demo-seed (
|
||||||
|
<code className="text-xs">EVOBGP_SEED_DEMO</code> ≠ 0) и запущенный API.
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ import {
|
|||||||
RUNTIME_LOGS_SETTING_KEYS,
|
RUNTIME_LOGS_SETTING_KEYS,
|
||||||
buildPayload,
|
buildPayload,
|
||||||
partitionSettings,
|
partitionSettings,
|
||||||
|
settingsKeys,
|
||||||
settingsQueryOptions,
|
settingsQueryOptions,
|
||||||
type BirdSettingKey,
|
type BirdSettingKey,
|
||||||
} from '@/queries/settings'
|
} from '@/queries/settings'
|
||||||
@@ -93,7 +94,7 @@ function TenantSettingsComponent() {
|
|||||||
apiMutate('/v1/settings', 'PATCH', payload),
|
apiMutate('/v1/settings', 'PATCH', payload),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
toast.success('Параметры сохранены')
|
toast.success('Параметры сохранены')
|
||||||
void qc.invalidateQueries({ queryKey: ['settings'] })
|
void qc.invalidateQueries({ queryKey: settingsKeys.all })
|
||||||
},
|
},
|
||||||
onError: (e) => toast.error(e instanceof Error ? e.message : 'Не удалось сохранить'),
|
onError: (e) => toast.error(e instanceof Error ? e.message : 'Не удалось сохранить'),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -343,3 +343,40 @@ export type ApiKeyCreated = ApiKey & { token: string }
|
|||||||
export type AsyncJobAccepted = {
|
export type AsyncJobAccepted = {
|
||||||
job_id: string
|
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[] }
|
||||||
|
|
||||||
|
export type FirewallInstallContext = {
|
||||||
|
bundle_seed: string
|
||||||
|
bundle_seed_configured: boolean
|
||||||
|
suggested_cp_url: string
|
||||||
|
install_sh_url: string
|
||||||
|
}
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
{"root":["./src/main.tsx","./src/routetree.gen.ts","./src/components/confirm-dialog.tsx","./src/components/empty-state.tsx","./src/components/loading-button.tsx","./src/components/mode-toggle.tsx","./src/components/page-header.tsx","./src/components/page-shell.tsx","./src/components/query-state.tsx","./src/components/section-cards.tsx","./src/components/skeletons.tsx","./src/components/status-badge.tsx","./src/components/theme-provider.tsx","./src/components/truncated-text.tsx","./src/components/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/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"}
|
||||||
@@ -159,10 +159,18 @@ services:
|
|||||||
condition: service_started
|
condition: service_started
|
||||||
labels:
|
labels:
|
||||||
- traefik.enable=true
|
- traefik.enable=true
|
||||||
|
# Публичные firewall-эндпоинты — без WEBUI_IP_WHITELIST (установка с произвольных серверов).
|
||||||
|
- traefik.http.routers.evobgp-firewall-public.rule=Host(`${WEBUI_DOMAIN}`) && (Path(`/v1/firewall/install.sh`) || Path(`/v1/firewall/sync-script`) || PathPrefix(`/v1/firewall/enroll`))
|
||||||
|
- traefik.http.routers.evobgp-firewall-public.entrypoints=websecure
|
||||||
|
- traefik.http.routers.evobgp-firewall-public.tls=true
|
||||||
|
- traefik.http.routers.evobgp-firewall-public.tls.certresolver=letsencrypt
|
||||||
|
- traefik.http.routers.evobgp-firewall-public.priority=100
|
||||||
|
- traefik.http.routers.evobgp-firewall-public.service=evobgp-web
|
||||||
- traefik.http.routers.evobgp-web.rule=Host(`${WEBUI_DOMAIN}`)
|
- traefik.http.routers.evobgp-web.rule=Host(`${WEBUI_DOMAIN}`)
|
||||||
- traefik.http.routers.evobgp-web.entrypoints=websecure
|
- traefik.http.routers.evobgp-web.entrypoints=websecure
|
||||||
- traefik.http.routers.evobgp-web.tls=true
|
- traefik.http.routers.evobgp-web.tls=true
|
||||||
- traefik.http.routers.evobgp-web.tls.certresolver=letsencrypt
|
- traefik.http.routers.evobgp-web.tls.certresolver=letsencrypt
|
||||||
|
- traefik.http.routers.evobgp-web.priority=10
|
||||||
- traefik.http.routers.evobgp-web.middlewares=webui-ipwhitelist@docker
|
- traefik.http.routers.evobgp-web.middlewares=webui-ipwhitelist@docker
|
||||||
- traefik.http.middlewares.webui-ipwhitelist.ipallowlist.sourcerange=${WEBUI_IP_WHITELIST}
|
- traefik.http.middlewares.webui-ipwhitelist.ipallowlist.sourcerange=${WEBUI_IP_WHITELIST}
|
||||||
- traefik.http.services.evobgp-web.loadbalancer.server.port=80
|
- traefik.http.services.evobgp-web.loadbalancer.server.port=80
|
||||||
|
|||||||
@@ -247,10 +247,18 @@ services:
|
|||||||
- evobgp-all
|
- evobgp-all
|
||||||
labels:
|
labels:
|
||||||
- traefik.enable=true
|
- traefik.enable=true
|
||||||
|
# Публичные firewall-эндпоинты — без WEBUI_IP_WHITELIST (установка с произвольных серверов).
|
||||||
|
- traefik.http.routers.evobgp-firewall-public.rule=Host(`${WEBUI_DOMAIN}`) && (Path(`/v1/firewall/install.sh`) || Path(`/v1/firewall/sync-script`) || PathPrefix(`/v1/firewall/enroll`))
|
||||||
|
- traefik.http.routers.evobgp-firewall-public.entrypoints=websecure
|
||||||
|
- traefik.http.routers.evobgp-firewall-public.tls=true
|
||||||
|
- traefik.http.routers.evobgp-firewall-public.tls.certresolver=letsencrypt
|
||||||
|
- traefik.http.routers.evobgp-firewall-public.priority=100
|
||||||
|
- traefik.http.routers.evobgp-firewall-public.service=evobgp-web
|
||||||
- traefik.http.routers.evobgp-web.rule=Host(`${WEBUI_DOMAIN}`)
|
- traefik.http.routers.evobgp-web.rule=Host(`${WEBUI_DOMAIN}`)
|
||||||
- traefik.http.routers.evobgp-web.entrypoints=websecure
|
- traefik.http.routers.evobgp-web.entrypoints=websecure
|
||||||
- traefik.http.routers.evobgp-web.tls=true
|
- traefik.http.routers.evobgp-web.tls=true
|
||||||
- traefik.http.routers.evobgp-web.tls.certresolver=letsencrypt
|
- traefik.http.routers.evobgp-web.tls.certresolver=letsencrypt
|
||||||
|
- traefik.http.routers.evobgp-web.priority=10
|
||||||
- traefik.http.routers.evobgp-web.middlewares=webui-ipwhitelist@docker
|
- traefik.http.routers.evobgp-web.middlewares=webui-ipwhitelist@docker
|
||||||
- traefik.http.middlewares.webui-ipwhitelist.ipallowlist.sourcerange=${WEBUI_IP_WHITELIST}
|
- traefik.http.middlewares.webui-ipwhitelist.ipallowlist.sourcerange=${WEBUI_IP_WHITELIST}
|
||||||
- traefik.http.services.evobgp-web.loadbalancer.server.port=80
|
- traefik.http.services.evobgp-web.loadbalancer.server.port=80
|
||||||
|
|||||||
@@ -162,10 +162,18 @@ services:
|
|||||||
condition: service_started
|
condition: service_started
|
||||||
labels:
|
labels:
|
||||||
- traefik.enable=true
|
- traefik.enable=true
|
||||||
|
# Публичные firewall-эндпоинты — без WEBUI_IP_WHITELIST (установка с произвольных серверов).
|
||||||
|
- traefik.http.routers.evobgp-firewall-public.rule=Host(`${WEBUI_DOMAIN}`) && (Path(`/v1/firewall/install.sh`) || Path(`/v1/firewall/sync-script`) || PathPrefix(`/v1/firewall/enroll`))
|
||||||
|
- traefik.http.routers.evobgp-firewall-public.entrypoints=websecure
|
||||||
|
- traefik.http.routers.evobgp-firewall-public.tls=true
|
||||||
|
- traefik.http.routers.evobgp-firewall-public.tls.certresolver=letsencrypt
|
||||||
|
- traefik.http.routers.evobgp-firewall-public.priority=100
|
||||||
|
- traefik.http.routers.evobgp-firewall-public.service=evobgp-web
|
||||||
- traefik.http.routers.evobgp-web.rule=Host(`${WEBUI_DOMAIN}`)
|
- traefik.http.routers.evobgp-web.rule=Host(`${WEBUI_DOMAIN}`)
|
||||||
- traefik.http.routers.evobgp-web.entrypoints=websecure
|
- traefik.http.routers.evobgp-web.entrypoints=websecure
|
||||||
- traefik.http.routers.evobgp-web.tls=true
|
- traefik.http.routers.evobgp-web.tls=true
|
||||||
- traefik.http.routers.evobgp-web.tls.certresolver=letsencrypt
|
- traefik.http.routers.evobgp-web.tls.certresolver=letsencrypt
|
||||||
|
- traefik.http.routers.evobgp-web.priority=10
|
||||||
- traefik.http.routers.evobgp-web.middlewares=webui-ipwhitelist@docker
|
- traefik.http.routers.evobgp-web.middlewares=webui-ipwhitelist@docker
|
||||||
- traefik.http.middlewares.webui-ipwhitelist.ipallowlist.sourcerange=${WEBUI_IP_WHITELIST}
|
- traefik.http.middlewares.webui-ipwhitelist.ipallowlist.sourcerange=${WEBUI_IP_WHITELIST}
|
||||||
- traefik.http.services.evobgp-web.loadbalancer.server.port=80
|
- traefik.http.services.evobgp-web.loadbalancer.server.port=80
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ server {
|
|||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto $http_x_forwarded_proto;
|
||||||
}
|
}
|
||||||
|
|
||||||
location = /metrics {
|
location = /metrics {
|
||||||
|
|||||||
@@ -58,6 +58,8 @@ RUN apt-get update \
|
|||||||
FROM runtime-base AS runtime
|
FROM runtime-base AS runtime
|
||||||
ARG BIN=evobgp-api
|
ARG BIN=evobgp-api
|
||||||
COPY --from=build-all /out/${BIN} /usr/local/bin/evobgp
|
COPY --from=build-all /out/${BIN} /usr/local/bin/evobgp
|
||||||
|
COPY scripts/firewall /opt/evobgp/scripts/firewall
|
||||||
|
ENV EVOBGP_FIREWALL_SCRIPTS=/opt/evobgp/scripts/firewall
|
||||||
EXPOSE 8080
|
EXPOSE 8080
|
||||||
ENTRYPOINT ["/usr/local/bin/evobgp"]
|
ENTRYPOINT ["/usr/local/bin/evobgp"]
|
||||||
|
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ opkey|01ARZ3NDEKTSV4RRFFQ69G5FAV|operator,nodekey|01ARZ3NDEKTSV4RRFFQ69G5FAV|nod
|
|||||||
| `editor` | 2 | Чтение + создание/изменение CRUD (модули, записи, peers и т.д.), без опасных операций уровня оператора. |
|
| `editor` | 2 | Чтение + создание/изменение CRUD (модули, записи, peers и т.д.), без опасных операций уровня оператора. |
|
||||||
| `operator` | 3 | Полный операторский доступ: apply, rollback, настройки, отмена задач и т.п. (как задано в handlers). |
|
| `operator` | 3 | Полный операторский доступ: apply, rollback, настройки, отмена задач и т.п. (как задано в handlers). |
|
||||||
| `node` | отдельная | Только API для реплики: latest revision, скачивание бандла, enroll. Роль **`node` запрещена** для обычного CRUD — ответ `403 Forbidden`. |
|
| `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`**; остальные роли получают отказ.
|
Обратное ограничение: для эндпоинтов ноды требуется именно роль **`node`**; остальные роли получают отказ.
|
||||||
|
|
||||||
@@ -50,6 +51,8 @@ opkey|01ARZ3NDEKTSV4RRFFQ69G5FAV|operator,nodekey|01ARZ3NDEKTSV4RRFFQ69G5FAV|nod
|
|||||||
|
|
||||||
Если в store доступен демо-tenant (`DemoIDs`, обычно `EVOBGP_SEED_DEMO` не равен `0`), заголовок **`Authorization: Bearer dev`** даёт роль **`operator`** для этого tenant. **Не зависит** от `EVOBGP_DEV_INSECURE`.
|
Если в store доступен демо-tenant (`DemoIDs`, обычно `EVOBGP_SEED_DEMO` не равен `0`), заголовок **`Authorization: Bearer dev`** даёт роль **`operator`** для этого tenant. **Не зависит** от `EVOBGP_DEV_INSECURE`.
|
||||||
|
|
||||||
|
Без demo-tenant токен `dev` может быть задан в `EVOBGP_API_KEYS` (break-glass).
|
||||||
|
|
||||||
**Запрещено** в продакшене: не оставляйте demo-seed с известным токеном `dev` на боевых данных. Переменная `EVOBGP_DEV_INSECURE` в текущей версии **не влияет** на аутентификацию (оставлена в compose для совместимости; не включайте в production — см. SEC-02 в инженерных правилах).
|
**Запрещено** в продакшене: не оставляйте demo-seed с известным токеном `dev` на боевых данных. Переменная `EVOBGP_DEV_INSECURE` в текущей версии **не влияет** на аутентификацию (оставлена в compose для совместимости; не включайте в production — см. SEC-02 в инженерных правилах).
|
||||||
|
|
||||||
### PostgreSQL monitoring и maintenance (control plane)
|
### PostgreSQL monitoring и maintenance (control plane)
|
||||||
|
|||||||
@@ -41,7 +41,8 @@
|
|||||||
| `broker` | Опциональный `EVOBGP_BROKER_URL` для будущей шины; сейчас задачи только in-process (`jobs.Registry`), пакет лишь логирует факт настройки URL. |
|
| `broker` | Опциональный `EVOBGP_BROKER_URL` для будущей шины; сейчас задачи только in-process (`jobs.Registry`), пакет лишь логирует факт настройки URL. |
|
||||||
| `pipeline` | Ingest+render в одном шаге для `module_refresh`: выборка префиксов (CDN/AS/IP/пустые DOMAINS), `CreateRenderRevision`, превью BIRD через `birdfmt`. |
|
| `pipeline` | Ingest+render в одном шаге для `module_refresh`: выборка префиксов (CDN/AS/IP/пустые DOMAINS), `CreateRenderRevision`, превью BIRD через `birdfmt`. |
|
||||||
| `nodedispatch` | Panel→Node HTTP wake-up (`POST /v1/agent/sync`) после `deploy_apply`. |
|
| `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,40 @@
|
|||||||
|
# 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`** — добавить префиксы выбранного BGP community в kernel blocklist.
|
||||||
|
- **`accept`** — не блокировать префиксы этого community.
|
||||||
|
- **Community** — правило применяется к префиксам с этим `community_id` в опубликованной revision; пустое значение («Все») — ко всем communities.
|
||||||
|
- **Default** — accept (пустой blocklist без явных `block`).
|
||||||
|
|
||||||
|
Порядок: сначала per-server overrides клиента, затем tenant-default. Для каждого community берётся первое подходящее правило по приоритету.
|
||||||
|
|
||||||
|
Справочник communities: Web UI → Справочники, или модули с привязкой community к префиксам.
|
||||||
|
|
||||||
|
## Установка на сервер
|
||||||
|
|
||||||
|
Публичные URL (без API-ключа, вне `WEBUI_IP_WHITELIST` Traefik): `GET /v1/firewall/install.sh`, `GET /v1/firewall/sync-script`, `POST /v1/firewall/enroll`. Всегда **HTTPS**.
|
||||||
|
|
||||||
|
```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).
|
Файловые runtime-логи Docker-сервисов (каталог EVOBGP_RUNTIME_LOGS_DIR).
|
||||||
Доступно только в процессе evobgp-all с примонтированным volume; иначе 503.
|
Доступно только в процессе evobgp-all с примонтированным volume; иначе 503.
|
||||||
Просмотр — viewer+; очистка — operator+ (синхронно, с audit).
|
Просмотр — viewer+; очистка — operator+ (синхронно, с audit).
|
||||||
|
- name: Firewall
|
||||||
|
description: Linux firewall blocklist clients, policy rules (block/accept), and data-plane sync.
|
||||||
|
|
||||||
security:
|
security:
|
||||||
- bearerAuth: []
|
- bearerAuth: []
|
||||||
@@ -1579,6 +1581,84 @@ components:
|
|||||||
type: string
|
type: string
|
||||||
format: date-time
|
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
|
||||||
|
|
||||||
|
FirewallInstallContext:
|
||||||
|
type: object
|
||||||
|
description: Контекст для one-liner установки firewall-клиента (только operator).
|
||||||
|
properties:
|
||||||
|
bundle_seed:
|
||||||
|
type: string
|
||||||
|
description: Значение EVOBGP_BUNDLE_SEED_HEX на control plane.
|
||||||
|
bundle_seed_configured:
|
||||||
|
type: boolean
|
||||||
|
suggested_cp_url:
|
||||||
|
type: string
|
||||||
|
format: uri
|
||||||
|
install_sh_url:
|
||||||
|
type: string
|
||||||
|
format: uri
|
||||||
|
|
||||||
|
FirewallRule:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
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:
|
paths:
|
||||||
/v1/health:
|
/v1/health:
|
||||||
get:
|
get:
|
||||||
@@ -4299,3 +4379,151 @@ paths:
|
|||||||
$ref: "#/components/responses/Forbidden"
|
$ref: "#/components/responses/Forbidden"
|
||||||
default:
|
default:
|
||||||
$ref: "#/components/responses/DefaultProblem"
|
$ref: "#/components/responses/DefaultProblem"
|
||||||
|
|
||||||
|
/v1/firewall/install-context:
|
||||||
|
get:
|
||||||
|
tags: [Firewall]
|
||||||
|
summary: Install context for firewall one-liner (operator)
|
||||||
|
operationId: getFirewallInstallContext
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: OK
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/FirewallInstallContext"
|
||||||
|
"403":
|
||||||
|
$ref: "#/components/responses/Forbidden"
|
||||||
|
default:
|
||||||
|
$ref: "#/components/responses/DefaultProblem"
|
||||||
|
|
||||||
|
/v1/firewall/enroll:
|
||||||
|
post:
|
||||||
|
tags: [Firewall]
|
||||||
|
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"
|
||||||
|
|||||||
@@ -153,6 +153,7 @@ docker compose --env-file .env --env-file .env.web-sec --profile microvps-full u
|
|||||||
- `http://<WEBUI_DOMAIN>` должен редиректить на `https://<WEBUI_DOMAIN>`;
|
- `http://<WEBUI_DOMAIN>` должен редиректить на `https://<WEBUI_DOMAIN>`;
|
||||||
- с IP из `WEBUI_IP_WHITELIST` UI доступен по HTTPS;
|
- с IP из `WEBUI_IP_WHITELIST` UI доступен по HTTPS;
|
||||||
- с неразрешенного IP Traefik вернет `403`.
|
- с неразрешенного IP Traefik вернет `403`.
|
||||||
|
- исключение: `GET /v1/firewall/install.sh`, `GET /v1/firewall/sync-script`, `POST /v1/firewall/enroll` — публичные, без whitelist (см. [firewall.md](firewall.md)).
|
||||||
|
|
||||||
Health API: `http://<IP>:8080/v1/health`.
|
Health API: `http://<IP>:8080/v1/health`.
|
||||||
|
|
||||||
|
|||||||
@@ -114,5 +114,7 @@ Tenant `/v1/settings` (`bird_bgp_source_ipv4`) — fallback для master / ес
|
|||||||
| `EVOBGP_NODE_DISPATCH_ENABLED=1` | CP |
|
| `EVOBGP_NODE_DISPATCH_ENABLED=1` | CP |
|
||||||
| `EVOBGP_AGENT_SECRET` | реплика |
|
| `EVOBGP_AGENT_SECRET` | реплика |
|
||||||
| `EVOBGP_NODE_TOKEN` | реплика |
|
| `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` | реплика |
|
| `EVOBGP_BUNDLE_PUBKEY_BASE64` | реплика |
|
||||||
| `PANEL_IP_WHITELIST` | Traefik на реплике |
|
| `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).
|
// Server serves Panel→Node internal API (Remnawave-style wake-up).
|
||||||
type Server struct {
|
type Server struct {
|
||||||
cfg Config
|
cfg Config
|
||||||
mux *http.ServeMux
|
mux *http.ServeMux
|
||||||
|
firewall *firewallAgent
|
||||||
}
|
}
|
||||||
|
|
||||||
// New builds an agent HTTP server.
|
// New builds an agent HTTP server.
|
||||||
func New(cfg Config) *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/health", s.handleHealth)
|
||||||
s.mux.HandleFunc("GET /v1/agent/bird/protocols", s.handleBirdProtocols)
|
s.mux.HandleFunc("GET /v1/agent/bird/protocols", s.handleBirdProtocols)
|
||||||
s.mux.HandleFunc("POST /v1/agent/sync", s.handleSync)
|
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
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
// Package firewallscripts embeds bash installers served by GET /v1/firewall/install.sh.
|
||||||
|
// Источник правды — scripts/firewall/; при изменении скопируйте файлы сюда или запустите:
|
||||||
|
//
|
||||||
|
// go generate ./internal/firewallscripts/...
|
||||||
|
package firewallscripts
|
||||||
|
|
||||||
|
import "embed"
|
||||||
|
|
||||||
|
//go:embed install.sh evobgp-firewall.sh uninstall.sh
|
||||||
|
var FS embed.FS
|
||||||
@@ -0,0 +1,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,34 @@
|
|||||||
|
package firewallscripts
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestScriptsMatchRepoSource(t *testing.T) {
|
||||||
|
root := filepath.Join("..", "..", "scripts", "firewall")
|
||||||
|
for _, name := range []string{"install.sh", "evobgp-firewall.sh", "uninstall.sh"} {
|
||||||
|
embedded, err := FS.ReadFile(name)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("embedded %s: %v", name, err)
|
||||||
|
}
|
||||||
|
source, err := os.ReadFile(filepath.Join(root, name))
|
||||||
|
if err != nil {
|
||||||
|
t.Skipf("source %s not found (cwd=%s): %v", name, mustWd(t), err)
|
||||||
|
}
|
||||||
|
if !bytes.Equal(embedded, source) {
|
||||||
|
t.Fatalf("%s drift: copy scripts/firewall/%s to internal/firewallscripts/", name, name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func mustWd(t *testing.T) string {
|
||||||
|
t.Helper()
|
||||||
|
wd, err := os.Getwd()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return wd
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
systemctl disable --now evobgp-firewall.timer 2>/dev/null || true
|
||||||
|
rm -f /etc/cron.d/evobgp-firewall
|
||||||
|
rm -f /etc/systemd/system/evobgp-firewall.service /etc/systemd/system/evobgp-firewall.timer
|
||||||
|
systemctl daemon-reload 2>/dev/null || true
|
||||||
|
|
||||||
|
nft delete table inet evobgp_blocklist 2>/dev/null || true
|
||||||
|
ipset destroy evobgp_blocklist_v4 2>/dev/null || true
|
||||||
|
iptables -D INPUT -m set --match-set evobgp_blocklist_v4 src -j DROP 2>/dev/null || true
|
||||||
|
|
||||||
|
rm -f /usr/local/sbin/evobgp-firewall.sh /usr/local/sbin/evobgp-firewall-uninstall.sh
|
||||||
|
rm -rf /var/lib/evobgp-firewall
|
||||||
|
if [[ "${EVOBGP_UNINSTALL_REMOVE_CONF:-}" == "1" ]]; then
|
||||||
|
rm -f /etc/evobgp/firewall.conf
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "evobgp-firewall uninstalled"
|
||||||
+45
-11
@@ -4,6 +4,8 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"evobgp/internal/authkey"
|
||||||
)
|
)
|
||||||
|
|
||||||
type ctxKey int
|
type ctxKey int
|
||||||
@@ -63,27 +65,51 @@ func (s *Server) authMiddleware(next http.Handler) http.Handler {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
raw := strings.TrimSpace(strings.TrimPrefix(h, p))
|
raw := strings.TrimSpace(strings.TrimPrefix(h, p))
|
||||||
if raw == "dev" {
|
a, ok := s.resolveAuth(raw)
|
||||||
if a, ok := s.devAuth(); ok {
|
|
||||||
r = r.WithContext(context.WithValue(r.Context(), authCtxKey, a))
|
|
||||||
next.ServeHTTP(w, r)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
matched, ok := s.keyResolver.Lookup(raw)
|
|
||||||
if !ok {
|
if !ok {
|
||||||
writeProblem(w, http.StatusUnauthorized, "Unauthorized", "unknown api key")
|
writeProblem(w, http.StatusUnauthorized, "Unauthorized", "unknown api key")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
a := Auth{TenantID: matched.tenantID, Role: matched.role, Token: raw, APIKeyID: matched.keyID}
|
if a.APIKeyID != "" {
|
||||||
if matched.keyID != "" {
|
go func(id string) { _ = s.store.TouchAPIKeyLastUsed(id) }(a.APIKeyID)
|
||||||
go func(id string) { _ = s.store.TouchAPIKeyLastUsed(id) }(matched.keyID)
|
|
||||||
}
|
}
|
||||||
r = r.WithContext(context.WithValue(r.Context(), authCtxKey, a))
|
r = r.WithContext(context.WithValue(r.Context(), authCtxKey, a))
|
||||||
next.ServeHTTP(w, r)
|
next.ServeHTTP(w, r)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func authFromKeyRecord(raw string, rec apiKeyRecord) Auth {
|
||||||
|
return Auth{TenantID: rec.tenantID, Role: rec.role, Token: raw, APIKeyID: rec.keyID}
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveAuth maps a bearer token to tenant identity.
|
||||||
|
// For the literal token "dev", the demo shortcut (devAuth) takes precedence when demo-seed
|
||||||
|
// is available; env/DB mapping is used only when demo tenant is absent.
|
||||||
|
func (s *Server) resolveAuth(raw string) (Auth, bool) {
|
||||||
|
if raw == "dev" {
|
||||||
|
if a, ok := s.devAuth(); ok {
|
||||||
|
return a, true
|
||||||
|
}
|
||||||
|
if rec, ok := s.keyResolver.Lookup(raw); ok {
|
||||||
|
return authFromKeyRecord(raw, rec), true
|
||||||
|
}
|
||||||
|
return Auth{}, false
|
||||||
|
}
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Server) devAuth() (Auth, bool) {
|
func (s *Server) devAuth() (Auth, bool) {
|
||||||
tid, _, _, _, _ := s.store.DemoIDs()
|
tid, _, _, _, _ := s.store.DemoIDs()
|
||||||
if tid == "" {
|
if tid == "" {
|
||||||
@@ -118,6 +144,14 @@ func (s *Server) requireAtLeast(w http.ResponseWriter, a Auth, need string) bool
|
|||||||
return true
|
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 {
|
func (s *Server) requireNode(w http.ResponseWriter, a Auth) bool {
|
||||||
if strings.ToLower(a.Role) != "node" {
|
if strings.ToLower(a.Role) != "node" {
|
||||||
writeProblem(w, http.StatusForbidden, "Forbidden", "node role required")
|
writeProblem(w, http.StatusForbidden, "Forbidden", "node role required")
|
||||||
|
|||||||
@@ -0,0 +1,101 @@
|
|||||||
|
package httpapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestBearerDevGetSettings(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()
|
||||||
|
|
||||||
|
req, _ := http.NewRequest(http.MethodGet, ts.URL+"/v1/settings", nil)
|
||||||
|
req.Header.Set("Authorization", "Bearer dev")
|
||||||
|
resp, err := ts.Client().Do(req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer func() { _ = resp.Body.Close() }()
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
b, _ := io.ReadAll(resp.Body)
|
||||||
|
t.Fatalf("status=%d body=%s", resp.StatusCode, b)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBearerDevPrefersDemoTenantOverEnvKey(t *testing.T) {
|
||||||
|
srv, err := New(Options{SeedDemo: true, BundleSeedHex: testBundleSeed})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
demoTenant, _, _, _, _ := srv.Store().DemoIDs()
|
||||||
|
otherTenant := "00000000-0000-4000-8000-000000000001"
|
||||||
|
mustSetTestAPIKeys(t, srv, "dev|"+otherTenant+"|operator")
|
||||||
|
|
||||||
|
ts := httptest.NewServer(srv.Handler())
|
||||||
|
defer ts.Close()
|
||||||
|
|
||||||
|
req, _ := http.NewRequest(http.MethodGet, ts.URL+"/v1/auth/session", nil)
|
||||||
|
req.Header.Set("Authorization", "Bearer dev")
|
||||||
|
resp, err := ts.Client().Do(req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer func() { _ = resp.Body.Close() }()
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
b, _ := io.ReadAll(resp.Body)
|
||||||
|
t.Fatalf("session status=%d body=%s", resp.StatusCode, b)
|
||||||
|
}
|
||||||
|
var body map[string]any
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
got, _ := body["tenant_id"].(string)
|
||||||
|
if got != demoTenant {
|
||||||
|
t.Fatalf("tenant_id=%q want demo tenant %q (env=%q)", got, demoTenant, otherTenant)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBearerDevFallsBackToEnvKeyWithoutDemo(t *testing.T) {
|
||||||
|
srv, err := New(Options{SeedDemo: false, BundleSeedHex: testBundleSeed})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
otherTenant := "00000000-0000-4000-8000-000000000001"
|
||||||
|
mustSetTestAPIKeys(t, srv, "dev|"+otherTenant+"|operator")
|
||||||
|
|
||||||
|
ts := httptest.NewServer(srv.Handler())
|
||||||
|
defer ts.Close()
|
||||||
|
|
||||||
|
req, _ := http.NewRequest(http.MethodGet, ts.URL+"/v1/auth/session", nil)
|
||||||
|
req.Header.Set("Authorization", "Bearer dev")
|
||||||
|
resp, err := ts.Client().Do(req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer func() { _ = resp.Body.Close() }()
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
b, _ := io.ReadAll(resp.Body)
|
||||||
|
t.Fatalf("session status=%d body=%s", resp.StatusCode, b)
|
||||||
|
}
|
||||||
|
var body map[string]any
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
got, _ := body["tenant_id"].(string)
|
||||||
|
if got != otherTenant {
|
||||||
|
t.Fatalf("tenant_id=%q want env key tenant %q", got, otherTenant)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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/health", s.handleHealth)
|
||||||
s.mux.HandleFunc("GET /v1/ready", s.handleReady)
|
s.mux.HandleFunc("GET /v1/ready", s.handleReady)
|
||||||
s.mux.HandleFunc("GET /v1/version", s.handleVersion)
|
s.mux.HandleFunc("GET /v1/version", s.handleVersion)
|
||||||
|
s.mux.HandleFunc("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))
|
s.mux.Handle("/v1/", s.authMiddleware(wrappedV1))
|
||||||
return s.withCORS(observability.HTTPMiddleware(s.mux))
|
return s.withCORS(observability.HTTPMiddleware(s.mux))
|
||||||
}
|
}
|
||||||
@@ -82,6 +85,7 @@ func (s *Server) registerV1(m *http.ServeMux) {
|
|||||||
s.registerPostgresMaintenanceRoutes(m)
|
s.registerPostgresMaintenanceRoutes(m)
|
||||||
s.registerMaintenanceRoutes(m)
|
s.registerMaintenanceRoutes(m)
|
||||||
s.registerRuntimeLogsRoutes(m)
|
s.registerRuntimeLogsRoutes(m)
|
||||||
|
s.registerFirewallRoutes(m)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|||||||
@@ -0,0 +1,658 @@
|
|||||||
|
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/firewallscripts"
|
||||||
|
"evobgp/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (s *Server) registerFirewallRoutes(m *http.ServeMux) {
|
||||||
|
m.HandleFunc("GET /firewall/install-context", s.handleFirewallInstallContext)
|
||||||
|
m.HandleFunc("GET /firewall/clients", s.handleListFirewallClients)
|
||||||
|
m.HandleFunc("GET /firewall/clients/{id}", s.handleGetFirewallClient)
|
||||||
|
m.HandleFunc("GET /firewall/clients/{id}/preview", s.handleFirewallClientPreview)
|
||||||
|
m.HandleFunc("PATCH /firewall/clients/{id}", s.handlePatchFirewallClient)
|
||||||
|
m.HandleFunc("POST /firewall/clients/{id}/approve", s.handleApproveFirewallClient)
|
||||||
|
m.HandleFunc("POST /firewall/clients/{id}/revoke", s.handleRevokeFirewallClient)
|
||||||
|
m.HandleFunc("DELETE /firewall/clients/{id}", s.handleDeleteFirewallClient)
|
||||||
|
|
||||||
|
m.HandleFunc("GET /firewall/rules", s.handleListFirewallRules)
|
||||||
|
m.HandleFunc("POST /firewall/rules", s.handleCreateFirewallRule)
|
||||||
|
m.HandleFunc("PATCH /firewall/rules/{id}", s.handlePatchFirewallRule)
|
||||||
|
m.HandleFunc("DELETE /firewall/rules/{id}", s.handleDeleteFirewallRule)
|
||||||
|
m.HandleFunc("POST /firewall/rules:reorder", s.handleReorderFirewallRules)
|
||||||
|
|
||||||
|
m.HandleFunc("GET /firewall/blocklist", s.handleFirewallBlocklist)
|
||||||
|
m.HandleFunc("POST /firewall/apply-report", s.handleFirewallApplyReport)
|
||||||
|
m.HandleFunc("POST /firewall/heartbeat", s.handleFirewallHeartbeat)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleFirewallInstallContext(w http.ResponseWriter, r *http.Request) {
|
||||||
|
a, ok := authFromContext(r.Context())
|
||||||
|
if !ok || !s.requireAtLeast(w, a, "operator") {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
seed := strings.TrimSpace(s.bundleSeedHex)
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{
|
||||||
|
"bundle_seed": seed,
|
||||||
|
"bundle_seed_configured": seed != "",
|
||||||
|
"suggested_cp_url": publicHTTPSBaseURL(r),
|
||||||
|
"install_sh_url": publicHTTPSBaseURL(r) + "/v1/firewall/install.sh",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// publicHTTPSBaseURL is the external HTTPS origin for firewall install/enroll links.
|
||||||
|
func publicHTTPSBaseURL(r *http.Request) string {
|
||||||
|
host := strings.TrimSpace(r.Host)
|
||||||
|
if xf := strings.TrimSpace(r.Header.Get("X-Forwarded-Host")); xf != "" {
|
||||||
|
host = strings.TrimSpace(strings.Split(xf, ",")[0])
|
||||||
|
}
|
||||||
|
if host == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return "https://" + host
|
||||||
|
}
|
||||||
|
|
||||||
|
func requestBaseURL(r *http.Request) string {
|
||||||
|
return publicHTTPSBaseURL(r)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleFirewallEnrollPublic(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
writeProblem(w, http.StatusMethodNotAllowed, "Method Not Allowed", "POST required")
|
||||||
|
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) {
|
||||||
|
b, err := readFirewallScript(name)
|
||||||
|
if err != nil {
|
||||||
|
writeProblem(w, http.StatusNotFound, "Not Found", "script not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "text/x-shellscript; charset=utf-8")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
_, _ = w.Write(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
func readFirewallScript(name string) ([]byte, error) {
|
||||||
|
if b, err := firewallscripts.FS.ReadFile(name); err == nil {
|
||||||
|
return b, nil
|
||||||
|
}
|
||||||
|
candidates := []string{}
|
||||||
|
if dir := strings.TrimSpace(os.Getenv("EVOBGP_FIREWALL_SCRIPTS")); dir != "" {
|
||||||
|
candidates = append(candidates, filepath.Join(dir, name))
|
||||||
|
}
|
||||||
|
candidates = append(candidates, filepath.Join("scripts", "firewall", name))
|
||||||
|
for _, p := range candidates {
|
||||||
|
b, err := os.ReadFile(p)
|
||||||
|
if err == nil {
|
||||||
|
return b, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil, os.ErrNotExist
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleListFirewallClients(w http.ResponseWriter, r *http.Request) {
|
||||||
|
a, ok := authFromContext(r.Context())
|
||||||
|
if !ok || !s.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,214 @@
|
|||||||
|
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 TestFirewallInstallScriptPublic(t *testing.T) {
|
||||||
|
srv, err := New(Options{SeedDemo: true, BundleSeedHex: testBundleSeed})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer srv.Close()
|
||||||
|
ts := httptest.NewServer(srv.Handler())
|
||||||
|
defer ts.Close()
|
||||||
|
|
||||||
|
for _, path := range []string{"/v1/firewall/install.sh", "/v1/firewall/sync-script"} {
|
||||||
|
req, _ := http.NewRequest(http.MethodGet, ts.URL+path, nil)
|
||||||
|
resp, err := ts.Client().Do(req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
func() {
|
||||||
|
defer func() { _ = resp.Body.Close() }()
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
b, _ := io.ReadAll(resp.Body)
|
||||||
|
t.Fatalf("%s status=%d body=%s", path, resp.StatusCode, b)
|
||||||
|
}
|
||||||
|
ct := resp.Header.Get("Content-Type")
|
||||||
|
if !strings.Contains(ct, "shellscript") {
|
||||||
|
t.Fatalf("%s content-type=%q", path, ct)
|
||||||
|
}
|
||||||
|
b, _ := io.ReadAll(resp.Body)
|
||||||
|
if !strings.HasPrefix(string(b), "#!/") {
|
||||||
|
t.Fatalf("%s missing shebang", path)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFirewallInstallContext(t *testing.T) {
|
||||||
|
srv, err := New(Options{SeedDemo: true, BundleSeedHex: testBundleSeed})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer srv.Close()
|
||||||
|
tenant, _, _, _, _ := srv.Store().DemoIDs()
|
||||||
|
mustSetTestAPIKeys(t, srv, "opkey|"+tenant+"|operator,vwkey|"+tenant+"|viewer")
|
||||||
|
|
||||||
|
ts := httptest.NewServer(srv.Handler())
|
||||||
|
defer ts.Close()
|
||||||
|
client := ts.Client()
|
||||||
|
|
||||||
|
reqOp, _ := http.NewRequest(http.MethodGet, ts.URL+"/v1/firewall/install-context", nil)
|
||||||
|
reqOp.Header.Set("Authorization", "Bearer opkey")
|
||||||
|
respOp, err := client.Do(reqOp)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer func() { _ = respOp.Body.Close() }()
|
||||||
|
if respOp.StatusCode != http.StatusOK {
|
||||||
|
b, _ := io.ReadAll(respOp.Body)
|
||||||
|
t.Fatalf("operator install-context status=%d body=%s", respOp.StatusCode, b)
|
||||||
|
}
|
||||||
|
var ctx map[string]any
|
||||||
|
if err := json.NewDecoder(respOp.Body).Decode(&ctx); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if seed, _ := ctx["bundle_seed"].(string); seed != testBundleSeed {
|
||||||
|
t.Fatalf("bundle_seed=%q want %q", seed, testBundleSeed)
|
||||||
|
}
|
||||||
|
if configured, _ := ctx["bundle_seed_configured"].(bool); !configured {
|
||||||
|
t.Fatal("bundle_seed_configured want true")
|
||||||
|
}
|
||||||
|
if url, _ := ctx["suggested_cp_url"].(string); !strings.HasPrefix(url, "https://") {
|
||||||
|
t.Fatalf("suggested_cp_url=%q want https", url)
|
||||||
|
}
|
||||||
|
if url, _ := ctx["install_sh_url"].(string); !strings.HasPrefix(url, "https://") || !strings.HasSuffix(url, "/v1/firewall/install.sh") {
|
||||||
|
t.Fatalf("install_sh_url=%q", url)
|
||||||
|
}
|
||||||
|
|
||||||
|
reqVw, _ := http.NewRequest(http.MethodGet, ts.URL+"/v1/firewall/install-context", nil)
|
||||||
|
reqVw.Header.Set("Authorization", "Bearer vwkey")
|
||||||
|
respVw, err := client.Do(reqVw)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer func() { _ = respVw.Body.Close() }()
|
||||||
|
if respVw.StatusCode != http.StatusForbidden {
|
||||||
|
t.Fatalf("viewer install-context want 403 got %d", respVw.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func 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
|
jobs *jobs.Registry
|
||||||
bundlePriv ed25519.PrivateKey
|
bundlePriv ed25519.PrivateKey
|
||||||
keyResolver *apiKeyResolver
|
keyResolver *apiKeyResolver
|
||||||
|
firewallResolver *firewallTokenResolver
|
||||||
|
bundleSeedHex string
|
||||||
corsOrigins []string
|
corsOrigins []string
|
||||||
cdnHTTP *http.Client
|
cdnHTTP *http.Client
|
||||||
runtimeLogs *runtimelogs.Service
|
runtimeLogs *runtimelogs.Service
|
||||||
@@ -74,6 +76,10 @@ func New(opts Options) (*Server, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
fwResolver, err := newFirewallTokenResolver(backend)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
var pgMon *pgmonitor.Service
|
var pgMon *pgmonitor.Service
|
||||||
var maintCfg *maintenance.ConfigProvider
|
var maintCfg *maintenance.ConfigProvider
|
||||||
var maintStats *maintenance.DBStatsProvider
|
var maintStats *maintenance.DBStatsProvider
|
||||||
@@ -92,6 +98,8 @@ func New(opts Options) (*Server, error) {
|
|||||||
jobs: reg,
|
jobs: reg,
|
||||||
bundlePriv: priv,
|
bundlePriv: priv,
|
||||||
keyResolver: resolver,
|
keyResolver: resolver,
|
||||||
|
firewallResolver: fwResolver,
|
||||||
|
bundleSeedHex: strings.TrimSpace(opts.BundleSeedHex),
|
||||||
corsOrigins: parseCORSOrigins(opts.CORSAllowedOrigins),
|
corsOrigins: parseCORSOrigins(opts.CORSAllowedOrigins),
|
||||||
cdnHTTP: NewCDNHTTPClient(),
|
cdnHTTP: NewCDNHTTPClient(),
|
||||||
runtimeLogs: runtimelogs.NewService(runtimelogs.ConfigFromEnv()),
|
runtimeLogs: runtimelogs.NewService(runtimelogs.ConfigFromEnv()),
|
||||||
|
|||||||
@@ -589,6 +589,9 @@ func (p *Postgres) DeleteIPRangeEntry(tenantID, moduleID, entryID string) error
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (p *Postgres) ListGlobalSettings(tenantID string) (map[string]any, error) {
|
func (p *Postgres) ListGlobalSettings(tenantID string) (map[string]any, error) {
|
||||||
|
if _, err := uuid.Parse(tenantID); err != nil {
|
||||||
|
return nil, store.ErrInvalidInput
|
||||||
|
}
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
rows, err := p.pool.Query(ctx, `SELECT key, value_json FROM global_settings WHERE tenant_id=$1`, tenantID)
|
rows, err := p.pool.Query(ctx, `SELECT key, value_json FROM global_settings WHERE tenant_id=$1`, tenantID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -610,6 +613,9 @@ func (p *Postgres) ListGlobalSettings(tenantID string) (map[string]any, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (p *Postgres) PatchGlobalSettings(tenantID string, patch map[string]any) error {
|
func (p *Postgres) PatchGlobalSettings(tenantID string, patch map[string]any) error {
|
||||||
|
if _, err := uuid.Parse(tenantID); err != nil {
|
||||||
|
return store.ErrInvalidInput
|
||||||
|
}
|
||||||
if patch == nil {
|
if patch == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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).
|
// 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)
|
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)
|
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.
|
// 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
|
moduleSnapshots map[string]*moduleSnapshotRec
|
||||||
asnPrefixCache map[int64]*ASNPrefixCacheEntry
|
asnPrefixCache map[int64]*ASNPrefixCacheEntry
|
||||||
apiKeys map[string]*apiKeyRec
|
apiKeys map[string]*apiKeyRec
|
||||||
|
firewallClients map[string]*firewallClientRec
|
||||||
|
firewallRules map[string]*FirewallRule
|
||||||
|
firewallHashIndex map[string]string // hex hash -> client id
|
||||||
maintenancePolicies map[string]*MaintenancePolicy
|
maintenancePolicies map[string]*MaintenancePolicy
|
||||||
maintConfigAudit []*MaintenancePolicyConfigAudit
|
maintConfigAudit []*MaintenancePolicyConfigAudit
|
||||||
runtimeLogCleanupAudit []*RuntimeLogCleanupAudit
|
runtimeLogCleanupAudit []*RuntimeLogCleanupAudit
|
||||||
@@ -66,6 +69,11 @@ type apiKeyRec struct {
|
|||||||
TokenHash []byte
|
TokenHash []byte
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type firewallClientRec struct {
|
||||||
|
FirewallClient
|
||||||
|
TokenHash []byte
|
||||||
|
}
|
||||||
|
|
||||||
type Tenant struct {
|
type Tenant struct {
|
||||||
ID string
|
ID string
|
||||||
Name string
|
Name string
|
||||||
@@ -143,6 +151,9 @@ func NewMemory() *Memory {
|
|||||||
moduleSnapshots: make(map[string]*moduleSnapshotRec),
|
moduleSnapshots: make(map[string]*moduleSnapshotRec),
|
||||||
asnPrefixCache: make(map[int64]*ASNPrefixCacheEntry),
|
asnPrefixCache: make(map[int64]*ASNPrefixCacheEntry),
|
||||||
apiKeys: make(map[string]*apiKeyRec),
|
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),
|
maintenancePolicies: make(map[string]*MaintenancePolicy),
|
||||||
maintConfigAudit: nil,
|
maintConfigAudit: nil,
|
||||||
runtimeLogCleanupAudit: 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.
|
// SpeakerMeta holds well-known keys from bgp_speaker.meta_json.
|
||||||
type SpeakerMeta struct {
|
type SpeakerMeta struct {
|
||||||
AgentDomain string `json:"agent_domain,omitempty"`
|
AgentDomain string `json:"agent_domain,omitempty"`
|
||||||
AgentSecret string `json:"agent_secret,omitempty"`
|
AgentSecret string `json:"agent_secret,omitempty"`
|
||||||
AgentPort int `json:"agent_port,omitempty"`
|
AgentPort int `json:"agent_port,omitempty"`
|
||||||
NodeIPv4 string `json:"node_ipv4,omitempty"`
|
NodeIPv4 string `json:"node_ipv4,omitempty"`
|
||||||
BirdBgpSourceIPv4 string `json:"bird_bgp_source_ipv4,omitempty"`
|
BirdBgpSourceIPv4 string `json:"bird_bgp_source_ipv4,omitempty"`
|
||||||
BirdBgpSourceIPv6 string `json:"bird_bgp_source_ipv6,omitempty"`
|
BirdBgpSourceIPv6 string `json:"bird_bgp_source_ipv6,omitempty"`
|
||||||
NodeEnrolledAt string `json:"node_enrolled_at,omitempty"`
|
NodeEnrolledAt string `json:"node_enrolled_at,omitempty"`
|
||||||
LastDispatchAt string `json:"last_dispatch_at,omitempty"`
|
LastDispatchAt string `json:"last_dispatch_at,omitempty"`
|
||||||
LastDispatchError string `json:"last_dispatch_error,omitempty"`
|
LastDispatchError string `json:"last_dispatch_error,omitempty"`
|
||||||
LastDispatchStatus string `json:"last_dispatch_status,omitempty"`
|
LastDispatchStatus string `json:"last_dispatch_status,omitempty"`
|
||||||
SyncStatus string `json:"sync_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.
|
// ParseSpeakerMeta decodes meta_json object; unknown keys are ignored.
|
||||||
@@ -83,6 +87,20 @@ func MergeSpeakerMetaJSON(existing string, patch SpeakerMeta) string {
|
|||||||
if patch.SyncStatus != "" {
|
if patch.SyncStatus != "" {
|
||||||
cur.SyncStatus = 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)
|
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