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

После создания реплики 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:
Denozordec
2026-08-21 13:51:06 +07:00
co-authored by Cursor
parent c5148ac4a0
commit 3723ba7ed1
11 changed files with 891 additions and 112 deletions
+36 -2
View File
@@ -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)
}
+88
View File
@@ -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
+111
View File
@@ -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"])
}
}
+254
View File
@@ -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 ""
}
+66
View File
@@ -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)
}
}
}