feat(httpapi): return replica docker install commands on speaker create
quality / commitlint (push) Skipped
quality / changes (push) Successful in 8s
quality / docker-check (push) Skipped
quality / openapi (push) Failing after 21s
quality / web (push) Successful in 55s
quality / go (push) Successful in 1m2s
quality / bird2 (push) Successful in 16s
CD / quality (push) Failing after 2m49s
CD / publish (push) Skipped
quality / commitlint (push) Skipped
quality / changes (push) Successful in 8s
quality / docker-check (push) Skipped
quality / openapi (push) Failing after 21s
quality / web (push) Successful in 55s
quality / go (push) Successful in 1m2s
quality / bird2 (push) Successful in 16s
CD / quality (push) Failing after 2m49s
CD / publish (push) Skipped
После создания реплики 201 отдаёт agent_secret, node_token и install.docker_commands (bird2 + agent + Traefik DNS-01). UI показывает шаг установки вместо закрытия диалога, чтобы секрет больше не терялся. Co-authored-by: Cursor <[email protected]>
This commit is contained in:
@@ -36,9 +36,8 @@ const SPEAKER_TABS = [
|
||||
]
|
||||
|
||||
const ROLE_OPTIONS = [
|
||||
{ value: 'primary', label: 'Основной' },
|
||||
{ value: 'secondary', label: 'Резервный' },
|
||||
{ value: 'speaker', label: 'Спикер' },
|
||||
{ value: 'replica', label: 'Реплика' },
|
||||
{ value: 'master', label: 'Мастер' },
|
||||
]
|
||||
|
||||
function createDefaultSpeakerFilters(): Filter[] {
|
||||
|
||||
@@ -1,15 +1,19 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Copy, TriangleAlert } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { Button } from '@evobgp/ui/components/button'
|
||||
import { Input } from '@evobgp/ui/components/input'
|
||||
import { Label } from '@evobgp/ui/components/label'
|
||||
import { Textarea } from '@evobgp/ui/components/textarea'
|
||||
|
||||
import { FormDrawer } from '@/components/form-drawer'
|
||||
import { LoadingButton } from '@/components/loading-button'
|
||||
import { SelectField } from '@/components/select-field'
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/reui/alert'
|
||||
import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard'
|
||||
import { useCreateSpeakerMutation } from '@/queries/network'
|
||||
import type { BgpSpeakerCreate } from '@/types/api'
|
||||
import type { BgpSpeakerCreate, SpeakerRow } from '@/types/api'
|
||||
|
||||
interface SpeakerFormDialogProps {
|
||||
open: boolean
|
||||
@@ -35,8 +39,20 @@ function buildMetaJson(agentDomain: string, nodeIpv4: string, bgpSource: string)
|
||||
return JSON.stringify(meta)
|
||||
}
|
||||
|
||||
function tlsIncomplete(
|
||||
agentDomain: string,
|
||||
letsencryptEmail: string,
|
||||
cfToken: string,
|
||||
panelIP: string,
|
||||
): boolean {
|
||||
return !agentDomain.trim() || !letsencryptEmail.trim() || !cfToken.trim() || !panelIP.trim()
|
||||
}
|
||||
|
||||
export function SpeakerFormDialog({ open, onOpenChange }: SpeakerFormDialogProps) {
|
||||
const createMutation = useCreateSpeakerMutation()
|
||||
const { isCopied, copyToClipboard } = useCopyToClipboard({
|
||||
onCopy: () => toast.success('Команда скопирована'),
|
||||
})
|
||||
|
||||
const [endpoint, setEndpoint] = useState('')
|
||||
const [role, setRole] = useState('replica')
|
||||
@@ -44,6 +60,13 @@ export function SpeakerFormDialog({ open, onOpenChange }: SpeakerFormDialogProps
|
||||
const [nodeIpv4, setNodeIpv4] = useState('')
|
||||
const [bgpSourceIpv4, setBgpSourceIpv4] = useState('')
|
||||
const [bgpSourceManual, setBgpSourceManual] = useState(false)
|
||||
const [letsencryptEmail, setLetsencryptEmail] = useState('')
|
||||
const [cfDnsToken, setCfDnsToken] = useState('')
|
||||
const [panelIP, setPanelIP] = useState('')
|
||||
const [created, setCreated] = useState<SpeakerRow | null>(null)
|
||||
|
||||
const isReplica = role === 'replica'
|
||||
const installCommands = created?.install?.docker_commands ?? ''
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
@@ -53,6 +76,10 @@ export function SpeakerFormDialog({ open, onOpenChange }: SpeakerFormDialogProps
|
||||
setNodeIpv4('')
|
||||
setBgpSourceIpv4('')
|
||||
setBgpSourceManual(false)
|
||||
setLetsencryptEmail('')
|
||||
setCfDnsToken('')
|
||||
setPanelIP('')
|
||||
setCreated(null)
|
||||
}, [open])
|
||||
|
||||
function handleEndpointChange(value: string) {
|
||||
@@ -81,82 +108,210 @@ export function SpeakerFormDialog({ open, onOpenChange }: SpeakerFormDialogProps
|
||||
endpoint: ep,
|
||||
role: role.trim() || 'replica',
|
||||
meta_json: buildMetaJson(agentDomain, nodeIpv4, bgpSourceIpv4),
|
||||
control_plane_url: window.location.origin,
|
||||
}
|
||||
if (isReplica) {
|
||||
if (letsencryptEmail.trim()) body.letsencrypt_email = letsencryptEmail.trim()
|
||||
if (cfDnsToken.trim()) body.cf_dns_api_token = cfDnsToken.trim()
|
||||
if (panelIP.trim()) body.panel_ip_whitelist = panelIP.trim()
|
||||
}
|
||||
try {
|
||||
await createMutation.mutateAsync(body)
|
||||
const row = await createMutation.mutateAsync(body)
|
||||
if (row.install?.docker_commands) {
|
||||
setCreated(row)
|
||||
return
|
||||
}
|
||||
onOpenChange(false)
|
||||
} catch {
|
||||
// toast in mutation
|
||||
}
|
||||
}
|
||||
|
||||
const showingInstall = created !== null && Boolean(installCommands)
|
||||
|
||||
return (
|
||||
<FormDrawer
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
title="Новый спикер"
|
||||
description="BIRD-агент на ноде реплики или плоскости управления"
|
||||
className="sm:max-w-md"
|
||||
title={showingInstall ? 'Установка на ноду' : 'Новый спикер'}
|
||||
description={
|
||||
showingInstall
|
||||
? 'Секреты показываются один раз. Скопируйте команду на VPS реплики.'
|
||||
: 'BIRD-агент на ноде реплики или плоскости управления'
|
||||
}
|
||||
className={showingInstall ? 'sm:max-w-2xl' : 'sm:max-w-md'}
|
||||
footer={
|
||||
<>
|
||||
<Button variant="outline" type="button" onClick={() => onOpenChange(false)}>
|
||||
Отмена
|
||||
</Button>
|
||||
<LoadingButton type="button" loading={createMutation.isPending} onClick={save}>
|
||||
Создать
|
||||
</LoadingButton>
|
||||
</>
|
||||
showingInstall ? (
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
type="button"
|
||||
onClick={() => copyToClipboard(installCommands)}
|
||||
>
|
||||
<Copy />
|
||||
{isCopied ? 'Скопировано' : 'Копировать команду'}
|
||||
</Button>
|
||||
<Button type="button" onClick={() => onOpenChange(false)}>
|
||||
Готово
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Button variant="outline" type="button" onClick={() => onOpenChange(false)}>
|
||||
Отмена
|
||||
</Button>
|
||||
<LoadingButton type="button" loading={createMutation.isPending} onClick={save}>
|
||||
Создать
|
||||
</LoadingButton>
|
||||
</>
|
||||
)
|
||||
}
|
||||
>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="speaker-endpoint">Конечная точка</Label>
|
||||
<Input
|
||||
id="speaker-endpoint"
|
||||
placeholder="https://node.example.com:8443"
|
||||
value={endpoint}
|
||||
onChange={(e) => handleEndpointChange(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<SelectField
|
||||
id="speaker-role"
|
||||
label="Роль"
|
||||
items={[
|
||||
{ value: 'replica', label: 'Реплика' },
|
||||
{ value: 'master', label: 'Мастер (плоскость)' },
|
||||
]}
|
||||
value={role}
|
||||
onValueChange={(v) => setRole(v ?? 'replica')}
|
||||
/>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="speaker-agent-domain">Домен агента</Label>
|
||||
<Input
|
||||
id="speaker-agent-domain"
|
||||
placeholder="bird-agent.example.com"
|
||||
value={agentDomain}
|
||||
onChange={(e) => setAgentDomain(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="speaker-node-ipv4">IPv4 ноды</Label>
|
||||
<Input
|
||||
id="speaker-node-ipv4"
|
||||
placeholder="203.0.113.10"
|
||||
value={nodeIpv4}
|
||||
onChange={(e) => handleNodeIpv4Change(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="speaker-bgp-source">Исходный IPv4 BGP</Label>
|
||||
<Input
|
||||
id="speaker-bgp-source"
|
||||
placeholder="203.0.113.10"
|
||||
value={bgpSourceIpv4}
|
||||
onChange={(e) => {
|
||||
setBgpSourceManual(true)
|
||||
setBgpSourceIpv4(e.target.value)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{created && installCommands ? (
|
||||
<SpeakerInstallStep created={created} commands={installCommands} />
|
||||
) : (
|
||||
<>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="speaker-endpoint">Конечная точка</Label>
|
||||
<Input
|
||||
id="speaker-endpoint"
|
||||
placeholder="https://node.example.com"
|
||||
value={endpoint}
|
||||
onChange={(e) => handleEndpointChange(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<SelectField
|
||||
id="speaker-role"
|
||||
label="Роль"
|
||||
items={[
|
||||
{ value: 'replica', label: 'Реплика' },
|
||||
{ value: 'master', label: 'Мастер (плоскость)' },
|
||||
]}
|
||||
value={role}
|
||||
onValueChange={(v) => setRole(v ?? 'replica')}
|
||||
/>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="speaker-agent-domain">Домен агента</Label>
|
||||
<Input
|
||||
id="speaker-agent-domain"
|
||||
placeholder="bird-agent.example.com"
|
||||
value={agentDomain}
|
||||
onChange={(e) => setAgentDomain(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="speaker-node-ipv4">IPv4 ноды</Label>
|
||||
<Input
|
||||
id="speaker-node-ipv4"
|
||||
placeholder="203.0.113.10"
|
||||
value={nodeIpv4}
|
||||
onChange={(e) => handleNodeIpv4Change(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="speaker-bgp-source">Исходный IPv4 BGP</Label>
|
||||
<Input
|
||||
id="speaker-bgp-source"
|
||||
placeholder="203.0.113.10"
|
||||
value={bgpSourceIpv4}
|
||||
onChange={(e) => {
|
||||
setBgpSourceManual(true)
|
||||
setBgpSourceIpv4(e.target.value)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{isReplica ? (
|
||||
<>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="speaker-le-email">Email Let's Encrypt</Label>
|
||||
<Input
|
||||
id="speaker-le-email"
|
||||
type="email"
|
||||
placeholder="[email protected]"
|
||||
value={letsencryptEmail}
|
||||
onChange={(e) => setLetsencryptEmail(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="speaker-cf-token">Cloudflare DNS API token</Label>
|
||||
<Input
|
||||
id="speaker-cf-token"
|
||||
type="password"
|
||||
autoComplete="off"
|
||||
placeholder="Zone:DNS:Edit"
|
||||
value={cfDnsToken}
|
||||
onChange={(e) => setCfDnsToken(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="speaker-panel-ip">IP панели (whitelist)</Label>
|
||||
<Input
|
||||
id="speaker-panel-ip"
|
||||
placeholder="203.0.113.1/32"
|
||||
value={panelIP}
|
||||
onChange={(e) => setPanelIP(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
{tlsIncomplete(agentDomain, letsencryptEmail, cfDnsToken, panelIP) ? (
|
||||
<Alert variant="warning">
|
||||
<TriangleAlert />
|
||||
<AlertTitle>Traefik не выпустит сертификат</AlertTitle>
|
||||
<AlertDescription>
|
||||
Нужны домен агента, email LE, Cloudflare token и IP панели. Иначе в
|
||||
команде останутся плейсхолдеры CHANGE_ME_*.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
</>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</FormDrawer>
|
||||
)
|
||||
}
|
||||
|
||||
function SpeakerInstallStep({
|
||||
created,
|
||||
commands,
|
||||
}: {
|
||||
created: SpeakerRow
|
||||
commands: string
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<Alert variant="warning">
|
||||
<TriangleAlert />
|
||||
<AlertTitle>Сохраните сейчас</AlertTitle>
|
||||
<AlertDescription>
|
||||
agent_secret и node_token больше не будут показаны. Traefik на ноде выпускает
|
||||
сертификат через DNS-01 (Cloudflare).
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
<div className="flex flex-col gap-1">
|
||||
<Label>ID спикера</Label>
|
||||
<code className="break-all font-mono text-xs">{created.id}</code>
|
||||
</div>
|
||||
{created.agent_secret ? (
|
||||
<div className="flex flex-col gap-1">
|
||||
<Label>agent_secret</Label>
|
||||
<code className="break-all font-mono text-xs">{created.agent_secret}</code>
|
||||
</div>
|
||||
) : null}
|
||||
{created.node_token ? (
|
||||
<div className="flex flex-col gap-1">
|
||||
<Label>node_token</Label>
|
||||
<code className="break-all font-mono text-xs">{created.node_token}</code>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="speaker-docker-commands">Docker-команды</Label>
|
||||
<Textarea
|
||||
id="speaker-docker-commands"
|
||||
readOnly
|
||||
value={commands}
|
||||
className="min-h-64 font-mono text-xs"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -105,9 +105,10 @@ export function bgpSessionStateRu(state: string | null | undefined): string {
|
||||
export function speakerRoleRu(role: string | null | undefined): string {
|
||||
switch (role) {
|
||||
case 'master':
|
||||
return 'Основной'
|
||||
case 'primary':
|
||||
return 'Основной'
|
||||
case 'replica':
|
||||
return 'Реплика'
|
||||
case 'secondary':
|
||||
return 'Резервный'
|
||||
case 'speaker':
|
||||
|
||||
@@ -293,13 +293,24 @@ export type SpeakerRow = {
|
||||
last_dispatch_error?: string | null
|
||||
meta_json?: Record<string, unknown>
|
||||
agent_secret?: string
|
||||
node_token?: string
|
||||
bundle_pubkey_base64?: string
|
||||
install?: SpeakerInstall
|
||||
live?: SpeakerLiveStatus
|
||||
}
|
||||
export type SpeakerInstall = {
|
||||
docker_commands?: string
|
||||
compose_yaml?: string
|
||||
}
|
||||
export type SpeakersResponse = Page<SpeakerRow>
|
||||
export type BgpSpeakerCreate = {
|
||||
endpoint: string
|
||||
role?: string
|
||||
meta_json?: string
|
||||
meta_json?: string | Record<string, string>
|
||||
letsencrypt_email?: string
|
||||
cf_dns_api_token?: string
|
||||
panel_ip_whitelist?: string
|
||||
control_plane_url?: string
|
||||
}
|
||||
export type BgpSpeakerPatch = Partial<BgpSpeakerCreate>
|
||||
|
||||
|
||||
+52
-6
@@ -1801,11 +1801,55 @@ components:
|
||||
type: string
|
||||
description: URL agent или https://AGENT_DOMAIN
|
||||
meta_json:
|
||||
oneOf:
|
||||
- type: string
|
||||
- type: object
|
||||
description: >
|
||||
JSON-объект (строка или object). Ключи node_ipv4, bird_bgp_source_ipv4
|
||||
(default = node_ipv4), agent_domain, agent_secret (генерируется при создании если пуст).
|
||||
letsencrypt_email:
|
||||
type: string
|
||||
description: Email ACME для Traefik на ноде. Только для генерации install.docker_commands, не сохраняется.
|
||||
cf_dns_api_token:
|
||||
type: string
|
||||
description: Cloudflare DNS API token (Zone:DNS:Edit) для LE DNS-01. Только для install-сниппета, не сохраняется.
|
||||
panel_ip_whitelist:
|
||||
type: string
|
||||
description: CIDR/IP панели для Traefik ipallowlist. Только для install-сниппета, не сохраняется.
|
||||
control_plane_url:
|
||||
type: string
|
||||
format: uri
|
||||
description: Публичный HTTPS URL панели (EVOBGP_CONTROL_PLANE_URL на реплике). Если пуст — из Origin / X-Forwarded-Host.
|
||||
additionalProperties: true
|
||||
|
||||
SpeakerInstall:
|
||||
type: object
|
||||
description: Одноразовый пакет установки реплики (только POST /v1/speakers 201).
|
||||
properties:
|
||||
docker_commands:
|
||||
type: string
|
||||
description: >
|
||||
JSON-объект. Ключи node_ipv4, bird_bgp_source_ipv4 (default = node_ipv4),
|
||||
agent_domain, agent_secret (генерируется при создании если пуст).
|
||||
additionalProperties: true
|
||||
Bash: sysctl, heredoc docker-compose.yaml (bird2 + agent + Traefik DNS-01) и docker compose up -d.
|
||||
compose_yaml:
|
||||
type: string
|
||||
description: Тело docker-compose.yaml без heredoc (превью).
|
||||
|
||||
BgpSpeakerCreated:
|
||||
allOf:
|
||||
- $ref: "#/components/schemas/BgpSpeaker"
|
||||
- type: object
|
||||
properties:
|
||||
agent_secret:
|
||||
type: string
|
||||
description: Bearer для Panel→Node (EVOBGP_AGENT_SECRET). Только в 201.
|
||||
node_token:
|
||||
type: string
|
||||
description: API-ключ role=node (EVOBGP_NODE_TOKEN). Только в 201.
|
||||
bundle_pubkey_base64:
|
||||
type: string
|
||||
description: Ed25519 pubkey для verify-bundle на ноде.
|
||||
install:
|
||||
$ref: "#/components/schemas/SpeakerInstall"
|
||||
|
||||
BgpSpeakerPatch:
|
||||
type: object
|
||||
@@ -3360,7 +3404,9 @@ paths:
|
||||
post:
|
||||
tags: [Speakers]
|
||||
summary: Зарегистрировать спикер
|
||||
description: Реплика, canary и т.д.
|
||||
description: >
|
||||
Реплика или master. Для replica 201 содержит agent_secret, node_token и
|
||||
install.docker_commands (bird2 + agent + Traefik LE DNS-01) — один раз.
|
||||
operationId: createSpeaker
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/TenantId"
|
||||
@@ -3373,11 +3419,11 @@ paths:
|
||||
$ref: "#/components/schemas/BgpSpeakerCreate"
|
||||
responses:
|
||||
"201":
|
||||
description: Ресурс создан.
|
||||
description: Ресурс создан. Для replica — одноразовый install-сниппет.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/BgpSpeaker"
|
||||
$ref: "#/components/schemas/BgpSpeakerCreated"
|
||||
default:
|
||||
$ref: "#/components/responses/DefaultProblem"
|
||||
|
||||
|
||||
+51
-37
@@ -8,32 +8,50 @@ Runbook для реплик **bird2 + evobgp-agent** на отдельных VPS
|
||||
|-----------|--------|
|
||||
| Panel → Node:PORT | CP POST `https://AGENT_DOMAIN/v1/agent/sync` |
|
||||
| SECRET_KEY | `agent_secret` (Bearer) |
|
||||
| Copy compose | Web UI → карточка спикера |
|
||||
| Copy compose | Web UI → после создания реплики: docker-команды (bird2 + agent + Traefik) |
|
||||
| Push Xray JSON | Wake-up → pull signed bundle → verify Ed25519 → apply |
|
||||
|
||||
Подробнее: [architecture.md](architecture.md).
|
||||
|
||||
## Быстрый старт
|
||||
|
||||
1. **CP (microvps-full):** зафиксируйте `EVOBGP_BUNDLE_SEED_HEX` (32 байта hex) — стабильный ключ подписи бандлов.
|
||||
2. **Web UI → Сеть → Спикеры:** создайте спикер `role=replica`, укажите **Agent domain**, **IP ноды**, **BGP source** (по умолчанию = IP ноды).
|
||||
3. Сохраните **`agent_secret`** (показывается один раз) и скопируйте **docker-compose** из UI.
|
||||
4. Выдайте **node API-ключ** ([access.md](access.md)) для `EVOBGP_NODE_TOKEN`.
|
||||
5. `GET /v1/bundle/signing-public-key` → `EVOBGP_BUNDLE_PUBKEY_BASE64` на реплике.
|
||||
6. На VPS реплики:
|
||||
```bash
|
||||
cd deploy/compose
|
||||
cp .env.remote-speaker.example .env.remote-speaker
|
||||
cp .env.remote-speaker-tls.example .env.remote-speaker-tls
|
||||
# заполните переменные из UI
|
||||
docker compose -f docker-compose.remote-speaker.yaml \
|
||||
--env-file .env.remote-speaker --env-file .env.remote-speaker-tls \
|
||||
--profile production up -d
|
||||
```
|
||||
7. **CP:** `EVOBGP_NODE_DISPATCH_ENABLED=1` — Panel шлёт wake-up после publish.
|
||||
8. Cloudflare: `AGENT_DOMAIN` → IP VPS, **DNS only** (как Web UI в [quickstart.md](quickstart.md)).
|
||||
1. **CP (microvps-full):** зафиксируйте `EVOBGP_BUNDLE_SEED_HEX` (32 байта hex) — стабильный ключ подписи бандлов. `EVOBGP_NODE_DISPATCH_ENABLED=1`.
|
||||
2. Cloudflare: A/AAAA `AGENT_DOMAIN` → публичный IP VPS реплики, режим **DNS only** (серый облачко), как Web UI в [quickstart.md](quickstart.md).
|
||||
3. **Web UI → Сеть → Спикеры:** создайте спикер `role=replica`. Укажите **домен агента**, **IP ноды**, **BGP source** (по умолчанию = IP ноды), **email Let's Encrypt**, **Cloudflare DNS API token** (`Zone:DNS:Edit`), **IP панели** (CIDR whitelist).
|
||||
4. В диалоге «Установка на ноду» скопируйте **docker-команды** (секреты `agent_secret` и `node_token` показываются **один раз**). Репозиторий EvoBGP на ноде не нужен: команда пишет `/opt/evobgp-speaker/docker-compose.yaml` (bird2 + agent + Traefik DNS-01) и делает `docker compose up -d`.
|
||||
5. Если образы из приватного реестра — на VPS заранее `docker login git.shx.one`.
|
||||
6. Не делайте `docker compose down -v` на реплике без бэкапа тома `evobgp_speaker_traefik_letsencrypt` (`acme.json`).
|
||||
|
||||
## Compose-профили
|
||||
Эталонный compose в репозитории (lab / ручной запуск): [docker-compose.remote-speaker.yaml](../deploy/compose/docker-compose.remote-speaker.yaml). Prod-установка с панели — paste из UI.
|
||||
|
||||
## HTTPS на ноде (DNS-01)
|
||||
|
||||
Сертификат **не** выписывает Control Plane и **не** Cloudflare Origin CA. Его выпускает **Traefik на самой реплике** (`evobgp-edge`), resolver `letsencrypt`, **ACME DNS-01** через Cloudflare.
|
||||
|
||||
| Кто | Что делает |
|
||||
|-----|------------|
|
||||
| Оператор | DNS only: `AGENT_DOMAIN` → IP VPS |
|
||||
| Traefik на **ноде** | `dnschallenge=true`, `provider=cloudflare` |
|
||||
| `CF_DNS_API_TOKEN` | В env **реплики** (вшит в команду из UI). Traefik создаёт TXT `_acme-challenge.<AGENT_DOMAIN>` |
|
||||
| Let's Encrypt | Проверяет TXT, отдаёт сертификат |
|
||||
| Том | `evobgp_speaker_traefik_letsencrypt` → `/letsencrypt/acme.json` |
|
||||
| CP → нода | `https://AGENT_DOMAIN/v1/agent/*` + `Authorization: Bearer <agent_secret>` + Traefik `ipallowlist` (`PANEL_IP_WHITELIST`) |
|
||||
|
||||
Порты:
|
||||
|
||||
| Порт | Кто | Зачем |
|
||||
|------|-----|-------|
|
||||
| **443** | IP CP (`PANEL_IP_WHITELIST`) | HTTPS dispatch, health, `GET /v1/agent/bird/protocols` |
|
||||
| **179** | BGP peers | Data plane |
|
||||
| **80** | любой | редирект HTTP → HTTPS (не HTTP-01 ACME) |
|
||||
|
||||
DNS-01 ходит **исходящим** к Cloudflare API и Let's Encrypt; inbound 80 для выпуска сертификата не нужен. Agent слушает `:8443` только во внутренней docker-сети; снаружи — Traefik 443.
|
||||
|
||||
Токен Cloudflare для панели (`evobgp-edge` на CP) в процесс API **не проброшен** — для реплики его задают в форме создания.
|
||||
|
||||
Profile `plain` в файле репозитория — только lab без Traefik.
|
||||
|
||||
## Compose-профили (файл в репозитории)
|
||||
|
||||
| Profile | Состав |
|
||||
|---------|--------|
|
||||
@@ -41,19 +59,11 @@ Runbook для реплик **bird2 + evobgp-agent** на отдельных VPS
|
||||
| `plain` | bird2 + agent на хосте без Traefik (только lab) |
|
||||
| `fallback` | + `sync-bundle` polling (`scripts/sync-bundle.sh`) |
|
||||
|
||||
Файлы: [docker-compose.remote-speaker.yaml](../deploy/compose/docker-compose.remote-speaker.yaml).
|
||||
Команда из UI — самодостаточный yaml **без profiles** (эквивалент production).
|
||||
|
||||
## Firewall
|
||||
## Подготовка VPS
|
||||
|
||||
| Порт | Кто | Зачем |
|
||||
|------|-----|-------|
|
||||
| **443** | IP CP (`PANEL_IP_WHITELIST`) | HTTPS dispatch, health, **`GET /v1/agent/bird/protocols`** (live peer sessions) |
|
||||
| **179** | BGP peers | Data plane |
|
||||
| **80** | ACME | Traefik → 443 |
|
||||
|
||||
## Подготовка VPS (перед `docker compose up`)
|
||||
|
||||
`bird2` — **`network_mode: host`**. Docker **не может** задать `net.ipv4.ip_forward` в таком контейнере; включите на **хосте**:
|
||||
`bird2` — **`network_mode: host`**. Docker **не может** задать `net.ipv4.ip_forward` в таком контейнере. Команда из UI включает sysctl; для постоянства:
|
||||
|
||||
```bash
|
||||
sysctl -w net.ipv4.ip_forward=1
|
||||
@@ -66,17 +76,18 @@ sysctl --system
|
||||
## Безопасность (три участка)
|
||||
|
||||
1. **CP → реплика:** HTTPS (LE) + Traefik ipallowlist + `agent_secret`.
|
||||
2. **Реплика → CP:** HTTPS + роль `node` (только bundle/latest/enroll).
|
||||
2. **Реплика → CP:** HTTPS + роль `node` (только bundle/latest/enroll). Ключ создаётся вместе со спикером.
|
||||
3. **Конфиг:** Ed25519 `bundle.sig`, SHA-256 manifest, `bird -p`, LKG на ноде.
|
||||
|
||||
Prod checklist:
|
||||
|
||||
- [ ] `EVOBGP_CONTROL_PLANE_URL=https://...`
|
||||
- [ ] `EVOBGP_CONTROL_PLANE_URL=https://...` (в команде из UI)
|
||||
- [ ] `EVOBGP_NODE_DISPATCH_ENABLED=1` на CP
|
||||
- [ ] `EVOBGP_BUNDLE_SEED_HEX` на CP (не менять после выдачи pubkey репликам)
|
||||
- [ ] Уникальные `agent_secret` и node token на спикер
|
||||
- [ ] Не использовать profile `plain` в prod
|
||||
- [ ] Не отключать verify-bundle в agent
|
||||
- [ ] Не `docker compose down -v` без бэкапа `acme.json`
|
||||
|
||||
## Per-speaker BGP source
|
||||
|
||||
@@ -95,7 +106,8 @@ Tenant `/v1/settings` (`bird_bgp_source_ipv4`) — fallback для master / ес
|
||||
| Симптом | Проверка |
|
||||
|---------|----------|
|
||||
| `sysctl net.ipv4.ip_forward not allowed in host network` | Уберите sysctls из compose (уже так в main); включите ip_forward на VPS (см. выше) |
|
||||
| `no service selected` | `--profile production` или `COMPOSE_PROFILES=production` |
|
||||
| `CHANGE_ME_*` в yaml | В форме не заполнены email LE / CF token / IP панели / домен |
|
||||
| Traefik отдаёт дефолтный сертификат | DNS only; token `Zone:DNS:Edit`; логи `evobgp-edge`; том acme.json |
|
||||
| Offline в UI | `GET https://AGENT_DOMAIN/v1/agent/health` с CP; LE cert; whitelist |
|
||||
| dispatch error | CP logs job meta; firewall 443; `agent_secret` |
|
||||
| verify-bundle fail | pubkey совпадает с CP seed; пересоберите pubkey после смены seed |
|
||||
@@ -105,16 +117,18 @@ Tenant `/v1/settings` (`bird_bgp_source_ipv4`) — fallback для master / ес
|
||||
|
||||
- Peers **не** фильтруются по `speaker_id` — один tenant-wide peers fragment на все реплики.
|
||||
- Разные peer-наборы per site — отдельная итерация pipeline.
|
||||
- Если Panel не достучится до agent — включите profile `fallback` (polling).
|
||||
- Если Panel не достучится до agent — включите profile `fallback` (polling) в файле репозитория.
|
||||
|
||||
## Связанные env
|
||||
|
||||
| Переменная | Где |
|
||||
|------------|-----|
|
||||
| `EVOBGP_NODE_DISPATCH_ENABLED=1` | CP |
|
||||
| `EVOBGP_AGENT_SECRET` | реплика |
|
||||
| `EVOBGP_NODE_TOKEN` | реплика |
|
||||
| `EVOBGP_AGENT_SECRET` | реплика (из UI, один раз) |
|
||||
| `EVOBGP_NODE_TOKEN` | реплика (API-ключ role=node, из UI) |
|
||||
| `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` | реплика (в команде из UI) |
|
||||
| `PANEL_IP_WHITELIST` | Traefik на реплике |
|
||||
| `CF_DNS_API_TOKEN` | Traefik на реплике |
|
||||
| `LETSENCRYPT_EMAIL` | Traefik на реплике |
|
||||
|
||||
@@ -1129,11 +1129,25 @@ func (s *Server) handlePostSpeaker(w http.ResponseWriter, r *http.Request) {
|
||||
if !ok || !s.requirePerm(w, a, "bgp:network:write") {
|
||||
return
|
||||
}
|
||||
var body store.Speaker
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
var req speakerCreateRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeProblem(w, http.StatusBadRequest, "Bad Request", "invalid json")
|
||||
return
|
||||
}
|
||||
metaStr, err := metaJSONRawToString(req.MetaJSON)
|
||||
if err != nil {
|
||||
writeProblem(w, http.StatusBadRequest, "Bad Request", "meta_json must be a JSON object or string")
|
||||
return
|
||||
}
|
||||
role := strings.TrimSpace(req.Role)
|
||||
if role == "" {
|
||||
role = "replica"
|
||||
}
|
||||
body := store.Speaker{
|
||||
Role: role,
|
||||
Endpoint: strings.TrimSpace(req.Endpoint),
|
||||
MetaJSON: metaStr,
|
||||
}
|
||||
if err := normalizeSpeakerCreate(&body); err != nil {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
@@ -1148,6 +1162,26 @@ func (s *Server) handlePostSpeaker(w http.ResponseWriter, r *http.Request) {
|
||||
if meta := store.ParseSpeakerMeta(x.MetaJSON); meta.AgentSecret != "" {
|
||||
resp["agent_secret"] = meta.AgentSecret
|
||||
}
|
||||
|
||||
if strings.ToLower(strings.TrimSpace(x.Role)) != "master" {
|
||||
createdKey, kerr := s.store.CreateAPIKey(a.TenantID, &store.APIKeyCreate{
|
||||
Name: "speaker:" + x.ID,
|
||||
Role: "node",
|
||||
})
|
||||
if kerr != nil {
|
||||
_ = s.store.DeleteSpeaker(a.TenantID, x.ID)
|
||||
writeStoreErr(w, kerr)
|
||||
return
|
||||
}
|
||||
if err := s.keyResolver.Reload(s.store); err != nil {
|
||||
writeProblem(w, http.StatusInternalServerError, "Internal Server Error", "failed to reload api keys")
|
||||
return
|
||||
}
|
||||
s.recordCRUDAudit(r, a, "bgp.api_key.create", "Created API key "+createdKey.Name, createdKey.ID, map[string]any{"api_key_id": createdKey.ID, "role": createdKey.Role, "speaker_id": x.ID})
|
||||
resp["node_token"] = createdKey.Token
|
||||
s.attachReplicaInstall(resp, x, createdKey.Token, req, r)
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusCreated, resp)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,17 +1,32 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"evobgp/internal/nodedispatch"
|
||||
"evobgp/internal/speakerinstall"
|
||||
"evobgp/internal/store"
|
||||
)
|
||||
|
||||
var errSpeakerMetaJSONType = errors.New("meta_json must be a JSON object or string")
|
||||
|
||||
type speakerCreateRequest struct {
|
||||
Role string `json:"role"`
|
||||
Endpoint string `json:"endpoint"`
|
||||
MetaJSON json.RawMessage `json:"meta_json"`
|
||||
LetsEncryptEmail string `json:"letsencrypt_email"`
|
||||
CFDNSAPIToken string `json:"cf_dns_api_token"`
|
||||
PanelIPWhitelist string `json:"panel_ip_whitelist"`
|
||||
ControlPlaneURL string `json:"control_plane_url"`
|
||||
}
|
||||
|
||||
func speakerJSONFromStore(st store.Backend, sp *store.Speaker) map[string]any {
|
||||
if sp == nil {
|
||||
return map[string]any{}
|
||||
@@ -123,6 +138,79 @@ func normalizeSpeakerCreate(in *store.Speaker) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func metaJSONRawToString(raw json.RawMessage) (string, error) {
|
||||
t := bytes.TrimSpace(raw)
|
||||
if len(t) == 0 {
|
||||
return "{}", nil
|
||||
}
|
||||
switch t[0] {
|
||||
case '"':
|
||||
var s string
|
||||
if err := json.Unmarshal(t, &s); err != nil {
|
||||
return "", err
|
||||
}
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" {
|
||||
return "{}", nil
|
||||
}
|
||||
return s, nil
|
||||
case '{':
|
||||
return string(t), nil
|
||||
default:
|
||||
return "", errSpeakerMetaJSONType
|
||||
}
|
||||
}
|
||||
|
||||
func publicControlPlaneURL(r *http.Request, override string) string {
|
||||
if s := strings.TrimSpace(override); s != "" {
|
||||
return strings.TrimRight(s, "/")
|
||||
}
|
||||
if origin := strings.TrimSpace(r.Header.Get("Origin")); strings.HasPrefix(origin, "http://") || strings.HasPrefix(origin, "https://") {
|
||||
return strings.TrimRight(origin, "/")
|
||||
}
|
||||
proto := strings.TrimSpace(r.Header.Get("X-Forwarded-Proto"))
|
||||
if proto == "" {
|
||||
proto = "https"
|
||||
}
|
||||
host := strings.TrimSpace(r.Header.Get("X-Forwarded-Host"))
|
||||
if i := strings.Index(host, ","); i >= 0 {
|
||||
host = strings.TrimSpace(host[:i])
|
||||
}
|
||||
if host == "" {
|
||||
host = strings.TrimSpace(r.Host)
|
||||
}
|
||||
if host == "" {
|
||||
return ""
|
||||
}
|
||||
return proto + "://" + host
|
||||
}
|
||||
|
||||
func (s *Server) attachReplicaInstall(resp map[string]any, sp *store.Speaker, nodeToken string, req speakerCreateRequest, r *http.Request) {
|
||||
if s == nil || sp == nil || resp == nil {
|
||||
return
|
||||
}
|
||||
meta := store.ParseSpeakerMeta(sp.MetaJSON)
|
||||
built, err := speakerinstall.Build(speakerinstall.Params{
|
||||
SpeakerID: sp.ID,
|
||||
AgentSecret: meta.AgentSecret,
|
||||
NodeToken: nodeToken,
|
||||
BundlePubkey: s.BundlePublicKeyBase64(),
|
||||
ControlPlaneURL: publicControlPlaneURL(r, req.ControlPlaneURL),
|
||||
AgentDomain: meta.AgentDomain,
|
||||
LetsEncryptEmail: req.LetsEncryptEmail,
|
||||
CFDNSAPIToken: req.CFDNSAPIToken,
|
||||
PanelIPWhitelist: req.PanelIPWhitelist,
|
||||
})
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
resp["bundle_pubkey_base64"] = s.BundlePublicKeyBase64()
|
||||
resp["install"] = map[string]any{
|
||||
"docker_commands": built.DockerCommands,
|
||||
"compose_yaml": built.ComposeYAML,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) recordSpeakerDispatch(tenantID string, sp *store.Speaker, res nodedispatch.Result) {
|
||||
if s == nil || s.store == nil || sp == nil {
|
||||
return
|
||||
|
||||
@@ -33,12 +33,23 @@ func TestPostSpeaker_defaultsFromEndpointIP(t *testing.T) {
|
||||
if out["agent_secret"] == nil || out["agent_secret"] == "" {
|
||||
t.Fatal("expected agent_secret on create")
|
||||
}
|
||||
if out["node_token"] == nil || out["node_token"] == "" {
|
||||
t.Fatal("expected node_token on replica create")
|
||||
}
|
||||
if out["node_ipv4"] != "203.0.113.55" {
|
||||
t.Fatalf("node_ipv4: %#v", out["node_ipv4"])
|
||||
}
|
||||
if out["bird_bgp_source_ipv4"] != "203.0.113.55" {
|
||||
t.Fatalf("bird_bgp_source_ipv4: %#v", out["bird_bgp_source_ipv4"])
|
||||
}
|
||||
install, _ := out["install"].(map[string]any)
|
||||
if install == nil {
|
||||
t.Fatal("expected install on replica create")
|
||||
}
|
||||
cmd, _ := install["docker_commands"].(string)
|
||||
if !strings.Contains(cmd, "traefik") || !strings.Contains(cmd, "dnschallenge") {
|
||||
t.Fatalf("docker_commands missing traefik dns challenge: %s", cmd[:min(200, len(cmd))])
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteSpeaker(t *testing.T) {
|
||||
@@ -84,3 +95,103 @@ func TestGetBundleSigningPublicKey(t *testing.T) {
|
||||
t.Fatalf("missing public_key_base64: %#v", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPostSpeaker_installCommandsAndMetaObject(t *testing.T) {
|
||||
srv, err := New(Options{InsecureDev: true, SeedDemo: true, BundleSeedHex: testBundleSeed})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer srv.Close()
|
||||
tenant, _, _, _, _ := srv.Store().DemoIDs()
|
||||
mustSetTestAPIKeys(t, srv, "edkey|"+tenant+"|editor")
|
||||
|
||||
body := `{
|
||||
"endpoint":"https://bgp-dc2.example.com",
|
||||
"role":"replica",
|
||||
"meta_json":{"agent_domain":"bgp-dc2.example.com","node_ipv4":"203.0.113.10"},
|
||||
"letsencrypt_email":"[email protected]",
|
||||
"cf_dns_api_token":"cf-token-xyz",
|
||||
"panel_ip_whitelist":"203.0.113.1/32",
|
||||
"control_plane_url":"https://cp.example.com"
|
||||
}`
|
||||
req := httptest.NewRequest(http.MethodPost, "/v1/speakers", strings.NewReader(body))
|
||||
req.Header.Set("Authorization", "Bearer edkey")
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec := httptest.NewRecorder()
|
||||
h := srv.Handler()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusCreated {
|
||||
t.Fatalf("status %d body %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var out map[string]any
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
id, _ := out["id"].(string)
|
||||
if id == "" {
|
||||
t.Fatal("missing id")
|
||||
}
|
||||
secret, _ := out["agent_secret"].(string)
|
||||
token, _ := out["node_token"].(string)
|
||||
pub, _ := out["bundle_pubkey_base64"].(string)
|
||||
if secret == "" || token == "" || pub == "" {
|
||||
t.Fatalf("missing one-shot secrets: %#v", out)
|
||||
}
|
||||
install, _ := out["install"].(map[string]any)
|
||||
cmd, _ := install["docker_commands"].(string)
|
||||
for _, want := range []string{
|
||||
"traefik",
|
||||
"dnschallenge=true",
|
||||
"dnschallenge.provider=cloudflare",
|
||||
"CF_DNS_API_TOKEN",
|
||||
"cf-token-xyz",
|
||||
"Host(`bgp-dc2.example.com`)",
|
||||
secret,
|
||||
token,
|
||||
"https://cp.example.com",
|
||||
} {
|
||||
if !strings.Contains(cmd, want) {
|
||||
t.Errorf("docker_commands missing %q", want)
|
||||
}
|
||||
}
|
||||
|
||||
get := httptest.NewRequest(http.MethodGet, "/v1/speakers/"+id, nil)
|
||||
get.Header.Set("Authorization", "Bearer edkey")
|
||||
grec := httptest.NewRecorder()
|
||||
h.ServeHTTP(grec, get)
|
||||
if grec.Code != http.StatusOK {
|
||||
t.Fatalf("GET status %d body %s", grec.Code, grec.Body.String())
|
||||
}
|
||||
got := grec.Body.String()
|
||||
if strings.Contains(got, secret) || strings.Contains(got, token) || strings.Contains(got, "docker_commands") {
|
||||
t.Fatalf("GET must not leak install secrets: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPostSpeaker_masterSkipsInstall(t *testing.T) {
|
||||
srv, err := New(Options{InsecureDev: true, SeedDemo: true, BundleSeedHex: testBundleSeed})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer srv.Close()
|
||||
tenant, _, _, _, _ := srv.Store().DemoIDs()
|
||||
mustSetTestAPIKeys(t, srv, "edkey|"+tenant+"|editor")
|
||||
|
||||
body := `{"endpoint":"https://127.0.0.1:8080","role":"master"}`
|
||||
req := httptest.NewRequest(http.MethodPost, "/v1/speakers", strings.NewReader(body))
|
||||
req.Header.Set("Authorization", "Bearer edkey")
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec := httptest.NewRecorder()
|
||||
srv.Handler().ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusCreated {
|
||||
t.Fatalf("status %d body %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var out map[string]any
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &out)
|
||||
if out["node_token"] != nil {
|
||||
t.Fatalf("master must not mint node_token: %#v", out["node_token"])
|
||||
}
|
||||
if out["install"] != nil {
|
||||
t.Fatalf("master must not include install: %#v", out["install"])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,254 @@
|
||||
// Package speakerinstall generates a one-shot docker compose snippet for a replica node
|
||||
// (bird2 + evobgp-agent + Traefik Let's Encrypt DNS-01), matching
|
||||
// deploy/compose/docker-compose.remote-speaker.yaml production services.
|
||||
package speakerinstall
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
"text/template"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultRegistry = "git.shx.one/denozord"
|
||||
defaultImageTag = "latest"
|
||||
|
||||
placeholderAgentDomain = "CHANGE_ME_AGENT_DOMAIN"
|
||||
placeholderLetsEncryptEmail = "CHANGE_ME_LETSENCRYPT_EMAIL"
|
||||
placeholderCFDNSToken = "CHANGE_ME_CF_DNS_API_TOKEN"
|
||||
placeholderPanelIP = "CHANGE_ME_PANEL_IP"
|
||||
placeholderControlPlaneURL = "CHANGE_ME_CONTROL_PLANE_URL"
|
||||
)
|
||||
|
||||
// Params are values baked into the one-shot compose (not stored on the speaker row).
|
||||
type Params struct {
|
||||
Registry string
|
||||
ImageTag string
|
||||
SpeakerID string
|
||||
AgentSecret string
|
||||
NodeToken string
|
||||
BundlePubkey string
|
||||
ControlPlaneURL string
|
||||
AgentDomain string
|
||||
LetsEncryptEmail string
|
||||
CFDNSAPIToken string
|
||||
PanelIPWhitelist string
|
||||
}
|
||||
|
||||
// Result is the pasteable install payload for POST /v1/speakers 201.
|
||||
type Result struct {
|
||||
ComposeYAML string
|
||||
DockerCommands string
|
||||
}
|
||||
|
||||
type renderData struct {
|
||||
BirdImage string
|
||||
AgentImage string
|
||||
SpeakerID string
|
||||
AgentSecret string
|
||||
NodeToken string
|
||||
BundlePubkey string
|
||||
ControlPlaneURL string
|
||||
AgentDomain string
|
||||
LetsEncryptEmail string
|
||||
CFDNSAPIToken string
|
||||
PanelIPWhitelist string
|
||||
}
|
||||
|
||||
const composeTemplate = `# EvoBGP replica: bird2 + evobgp-agent + Traefik (Let's Encrypt DNS-01 / Cloudflare).
|
||||
# Generated by control plane. Do not commit secrets. ACME state: volume evobgp_speaker_traefik_letsencrypt.
|
||||
|
||||
name: evobgp-remote-speaker
|
||||
|
||||
x-logging: &default-logging
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
|
||||
services:
|
||||
bird2:
|
||||
image: {{.BirdImage}}
|
||||
restart: unless-stopped
|
||||
network_mode: host
|
||||
cap_add:
|
||||
- NET_ADMIN
|
||||
volumes:
|
||||
- bird_etc:/etc/bird
|
||||
- bird_run:/run/bird
|
||||
logging: *default-logging
|
||||
|
||||
evobgp-agent:
|
||||
image: {{.AgentImage}}
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
- bird2
|
||||
cap_add:
|
||||
- NET_ADMIN
|
||||
environment:
|
||||
EVOBGP_AGENT_LISTEN: ":8443"
|
||||
EVOBGP_AGENT_SECRET: {{yamlQuote .AgentSecret}}
|
||||
EVOBGP_CONTROL_PLANE_URL: {{yamlQuote .ControlPlaneURL}}
|
||||
EVOBGP_NODE_TOKEN: {{yamlQuote .NodeToken}}
|
||||
EVOBGP_SPEAKER_ID: {{yamlQuote .SpeakerID}}
|
||||
EVOBGP_BUNDLE_PUBKEY_BASE64: {{yamlQuote .BundlePubkey}}
|
||||
EVOBGP_BIRD_EXTRACT_DIR: /etc/bird
|
||||
EVOBGP_BIRDC_SOCKET: /run/bird/bird.ctl
|
||||
volumes:
|
||||
- bird_etc:/etc/bird
|
||||
- bird_run:/run/bird
|
||||
entrypoint: ["/usr/local/bin/evobgp-agent"]
|
||||
command: ["serve", "-socket=/run/bird/bird.ctl"]
|
||||
networks:
|
||||
- speaker-net
|
||||
labels:
|
||||
- traefik.enable=true
|
||||
- traefik.http.routers.evobgp-agent.rule={{traefikHost .AgentDomain}}
|
||||
- traefik.http.routers.evobgp-agent.entrypoints=websecure
|
||||
- traefik.http.routers.evobgp-agent.tls=true
|
||||
- traefik.http.routers.evobgp-agent.tls.certresolver=letsencrypt
|
||||
- traefik.http.routers.evobgp-agent.middlewares=panel-ipwhitelist@docker
|
||||
- traefik.http.middlewares.panel-ipwhitelist.ipallowlist.sourcerange={{.PanelIPWhitelist}}
|
||||
- traefik.http.services.evobgp-agent.loadbalancer.server.port=8443
|
||||
logging: *default-logging
|
||||
|
||||
evobgp-edge:
|
||||
image: traefik:latest
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
- evobgp-agent
|
||||
ports:
|
||||
- "80:80"
|
||||
- "443:443"
|
||||
environment:
|
||||
DOCKER_API_VERSION: "1.44"
|
||||
CF_DNS_API_TOKEN: {{yamlQuote .CFDNSAPIToken}}
|
||||
command:
|
||||
- --api.dashboard=false
|
||||
- --providers.docker=true
|
||||
- --providers.docker.exposedbydefault=false
|
||||
- --entrypoints.web.address=:80
|
||||
- --entrypoints.websecure.address=:443
|
||||
- --entrypoints.web.http.redirections.entrypoint.to=websecure
|
||||
- --entrypoints.web.http.redirections.entrypoint.scheme=https
|
||||
- --certificatesresolvers.letsencrypt.acme.email={{.LetsEncryptEmail}}
|
||||
- --certificatesresolvers.letsencrypt.acme.storage=/letsencrypt/acme.json
|
||||
- --certificatesresolvers.letsencrypt.acme.dnschallenge=true
|
||||
- --certificatesresolvers.letsencrypt.acme.dnschallenge.provider=cloudflare
|
||||
- --certificatesresolvers.letsencrypt.acme.dnschallenge.delaybeforecheck=15
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||
- traefik_letsencrypt:/letsencrypt
|
||||
networks:
|
||||
- speaker-net
|
||||
logging: *default-logging
|
||||
|
||||
networks:
|
||||
speaker-net:
|
||||
|
||||
volumes:
|
||||
bird_etc:
|
||||
bird_run:
|
||||
traefik_letsencrypt:
|
||||
name: evobgp_speaker_traefik_letsencrypt
|
||||
`
|
||||
|
||||
var composeTpl = template.Must(template.New("remote-speaker").Funcs(template.FuncMap{
|
||||
"yamlQuote": yamlDoubleQuote,
|
||||
"traefikHost": traefikHostRule,
|
||||
}).Parse(composeTemplate))
|
||||
|
||||
// Build returns compose YAML and bash docker_commands for a replica VPS.
|
||||
func Build(p Params) (Result, error) {
|
||||
p = normalize(p)
|
||||
data := renderData{
|
||||
BirdImage: p.Registry + "/evobgp-bird2:" + p.ImageTag,
|
||||
AgentImage: p.Registry + "/evobgp-agent:" + p.ImageTag,
|
||||
SpeakerID: p.SpeakerID,
|
||||
AgentSecret: p.AgentSecret,
|
||||
NodeToken: p.NodeToken,
|
||||
BundlePubkey: p.BundlePubkey,
|
||||
ControlPlaneURL: p.ControlPlaneURL,
|
||||
AgentDomain: p.AgentDomain,
|
||||
LetsEncryptEmail: p.LetsEncryptEmail,
|
||||
CFDNSAPIToken: p.CFDNSAPIToken,
|
||||
PanelIPWhitelist: p.PanelIPWhitelist,
|
||||
}
|
||||
var yaml strings.Builder
|
||||
if err := composeTpl.Execute(&yaml, data); err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
compose := strings.TrimSpace(yaml.String()) + "\n"
|
||||
return Result{
|
||||
ComposeYAML: compose,
|
||||
DockerCommands: dockerCommands(compose),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func normalize(p Params) Params {
|
||||
p.Registry = firstNonEmpty(p.Registry, os.Getenv("EVOBGP_REGISTRY"), defaultRegistry)
|
||||
p.ImageTag = firstNonEmpty(p.ImageTag, os.Getenv("EVOBGP_IMAGE_TAG"), defaultImageTag)
|
||||
p.AgentDomain = sanitizeHost(firstNonEmpty(p.AgentDomain, placeholderAgentDomain))
|
||||
p.LetsEncryptEmail = firstNonEmpty(p.LetsEncryptEmail, placeholderLetsEncryptEmail)
|
||||
p.CFDNSAPIToken = firstNonEmpty(p.CFDNSAPIToken, placeholderCFDNSToken)
|
||||
p.PanelIPWhitelist = firstNonEmpty(p.PanelIPWhitelist, placeholderPanelIP)
|
||||
p.ControlPlaneURL = strings.TrimRight(firstNonEmpty(p.ControlPlaneURL, placeholderControlPlaneURL), "/")
|
||||
return p
|
||||
}
|
||||
|
||||
func dockerCommands(composeYAML string) string {
|
||||
var b strings.Builder
|
||||
b.WriteString(`# EvoBGP replica: bird2 + agent + Traefik (Let's Encrypt DNS-01 / Cloudflare)
|
||||
# docker login git.shx.one # if images are private
|
||||
set -euo pipefail
|
||||
sysctl -w net.ipv4.ip_forward=1
|
||||
sysctl -w net.ipv6.conf.all.forwarding=1
|
||||
mkdir -p /etc/sysctl.d
|
||||
printf '%s\n' 'net.ipv4.ip_forward=1' 'net.ipv6.conf.all.forwarding=1' > /etc/sysctl.d/99-evobgp-bird.conf
|
||||
mkdir -p /opt/evobgp-speaker
|
||||
cat > /opt/evobgp-speaker/docker-compose.yaml <<'EVOBGP_SPEAKER_COMPOSE_EOF'
|
||||
`)
|
||||
b.WriteString(composeYAML)
|
||||
if !strings.HasSuffix(composeYAML, "\n") {
|
||||
b.WriteByte('\n')
|
||||
}
|
||||
b.WriteString("EVOBGP_SPEAKER_COMPOSE_EOF\n")
|
||||
b.WriteString("cd /opt/evobgp-speaker && docker compose up -d\n")
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func traefikHostRule(domain string) string {
|
||||
return "Host(`" + domain + "`)"
|
||||
}
|
||||
|
||||
func yamlDoubleQuote(s string) string {
|
||||
escaped := strings.ReplaceAll(s, `\`, `\\`)
|
||||
escaped = strings.ReplaceAll(escaped, `"`, `\"`)
|
||||
escaped = strings.ReplaceAll(escaped, "\n", `\n`)
|
||||
return `"` + escaped + `"`
|
||||
}
|
||||
|
||||
func sanitizeHost(s string) string {
|
||||
s = strings.TrimSpace(s)
|
||||
s = strings.TrimPrefix(s, "https://")
|
||||
s = strings.TrimPrefix(s, "http://")
|
||||
if i := strings.IndexAny(s, "/:"); i >= 0 {
|
||||
s = s[:i]
|
||||
}
|
||||
s = strings.ReplaceAll(s, "`", "")
|
||||
s = strings.ReplaceAll(s, `"`, "")
|
||||
s = strings.ReplaceAll(s, "'", "")
|
||||
if s == "" {
|
||||
return placeholderAgentDomain
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func firstNonEmpty(vals ...string) string {
|
||||
for _, v := range vals {
|
||||
if s := strings.TrimSpace(v); s != "" {
|
||||
return s
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package speakerinstall
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestBuild_includesTraefikDNS01(t *testing.T) {
|
||||
res, err := Build(Params{
|
||||
SpeakerID: "11111111-1111-1111-1111-111111111111",
|
||||
AgentSecret: "secret-abc",
|
||||
NodeToken: "node-tok",
|
||||
BundlePubkey: "pubkey==",
|
||||
ControlPlaneURL: "https://cp.example.com",
|
||||
AgentDomain: "bgp-dc2.example.com",
|
||||
LetsEncryptEmail: "[email protected]",
|
||||
CFDNSAPIToken: "cf-token-xyz",
|
||||
PanelIPWhitelist: "203.0.113.1/32",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cmd := res.DockerCommands
|
||||
for _, want := range []string{
|
||||
"traefik",
|
||||
"dnschallenge=true",
|
||||
"dnschallenge.provider=cloudflare",
|
||||
"CF_DNS_API_TOKEN",
|
||||
"cf-token-xyz",
|
||||
"Host(`bgp-dc2.example.com`)",
|
||||
"secret-abc",
|
||||
"node-tok",
|
||||
"https://cp.example.com",
|
||||
"docker compose up -d",
|
||||
"sysctl -w net.ipv4.ip_forward=1",
|
||||
"evobgp_speaker_traefik_letsencrypt",
|
||||
} {
|
||||
if !strings.Contains(cmd, want) {
|
||||
t.Errorf("docker_commands missing %q", want)
|
||||
}
|
||||
}
|
||||
if strings.Contains(cmd, "?set ") {
|
||||
t.Error("compose must bake values, not ${VAR:?set VAR}")
|
||||
}
|
||||
if res.ComposeYAML == "" {
|
||||
t.Fatal("compose_yaml empty")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuild_placeholdersWhenEmpty(t *testing.T) {
|
||||
res, err := Build(Params{SpeakerID: "id", AgentSecret: "s", NodeToken: "t", BundlePubkey: "p"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, ph := range []string{
|
||||
placeholderAgentDomain,
|
||||
placeholderLetsEncryptEmail,
|
||||
placeholderCFDNSToken,
|
||||
placeholderPanelIP,
|
||||
placeholderControlPlaneURL,
|
||||
} {
|
||||
if !strings.Contains(res.DockerCommands, ph) {
|
||||
t.Errorf("expected placeholder %s", ph)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user