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:
@@ -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"])
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user