Files
EvoBGP/internal/speakerinstall/install.go
T
DenozordecandCursor 3723ba7ed1
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
feat(httpapi): return replica docker install commands on speaker create
После создания реплики 201 отдаёт agent_secret, node_token и install.docker_commands (bird2 + agent + Traefik DNS-01). UI показывает шаг установки вместо закрытия диалога, чтобы секрет больше не терялся.

Co-authored-by: Cursor <[email protected]>
2026-08-21 13:51:06 +07:00

255 lines
7.8 KiB
Go

// 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 ""
}