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]>
255 lines
7.8 KiB
Go
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 ""
|
|
}
|