// 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. # BGP: ports 179:179 like control plane (overlay sets router id). Logs: docker compose logs -f bird2 evobgp-agent 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 cap_add: - NET_ADMIN sysctls: net.ipv4.ip_forward: "1" net.ipv6.conf.all.forwarding: "1" ports: - "179:179/tcp" volumes: - bird_etc:/etc/bird - bird_run:/run/bird networks: - speaker-net 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 # BGP TCP/179 published like the control plane. Cloud security group must allow 179. # Logs: cd /opt/evobgp-speaker && docker compose logs -f bird2 evobgp-agent 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 "" }