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{} } meta := store.ParseSpeakerMeta(sp.MetaJSON) m := map[string]any{ "id": sp.ID, "role": sp.Role, "endpoint": sp.Endpoint, } if sp.LastAppliedRevisionID != nil { m["last_applied_revision_id"] = *sp.LastAppliedRevisionID } else { m["last_applied_revision_id"] = nil } if st != nil { if rid, at, err := st.LatestPublishedRevision(sp.ID); err == nil && rid != "" { m["published_revision_id"] = rid m["published_at"] = at.UTC().Format(time.RFC3339Nano) } else { m["published_revision_id"] = nil m["published_at"] = nil } } if strings.TrimSpace(sp.MetaJSON) != "" && sp.MetaJSON != "{}" { var raw map[string]any if json.Unmarshal([]byte(sp.MetaJSON), &raw) == nil { delete(raw, "agent_secret") if len(raw) > 0 { m["meta_json"] = raw } } } if meta.AgentDomain != "" { m["agent_domain"] = meta.AgentDomain } if meta.NodeIPv4 != "" { m["node_ipv4"] = meta.NodeIPv4 } if meta.BirdBgpSourceIPv4 != "" { m["bird_bgp_source_ipv4"] = meta.BirdBgpSourceIPv4 } if meta.LastDispatchAt != "" { m["last_dispatch_at"] = meta.LastDispatchAt } if meta.LastDispatchError != "" { m["last_dispatch_error"] = meta.LastDispatchError } if meta.LastDispatchStatus != "" { m["dispatch_status"] = meta.LastDispatchStatus } if meta.SyncStatus != "" { m["sync_status"] = meta.SyncStatus } return m } func (s *Server) handleBundleSigningPublicKey(w http.ResponseWriter, r *http.Request) { a, ok := authFromContext(r.Context()) if !ok || !s.requirePerm(w, a, "bgp:network:read") { return } writeJSON(w, http.StatusOK, map[string]any{ "public_key_base64": s.BundlePublicKeyBase64(), }) } // normalizeSpeakerCreate fills meta defaults and validates replica fields. func normalizeSpeakerCreate(in *store.Speaker) error { if in == nil { return store.ErrInvalidInput } meta := store.ParseSpeakerMeta(in.MetaJSON) if meta.AgentSecret == "" { b := make([]byte, 24) if _, err := rand.Read(b); err != nil { return err } meta.AgentSecret = hex.EncodeToString(b) } if meta.AgentPort == 0 { meta.AgentPort = 8443 } if meta.NodeIPv4 == "" { meta.NodeIPv4 = store.IPv4FromEndpoint(in.Endpoint) } if meta.BirdBgpSourceIPv4 == "" && meta.NodeIPv4 != "" { meta.BirdBgpSourceIPv4 = meta.NodeIPv4 } if meta.BirdBgpSourceIPv4 != "" && !store.ValidIPv4(meta.BirdBgpSourceIPv4) { return store.ErrInvalidInput } if meta.AgentDomain == "" && in.Endpoint != "" { ep := strings.TrimSpace(in.Endpoint) if strings.HasPrefix(ep, "https://") { u := strings.TrimPrefix(ep, "https://") if idx := strings.Index(u, "/"); idx >= 0 { u = u[:idx] } if idx := strings.Index(u, ":"); idx >= 0 { u = u[:idx] } if u != "" && !store.ValidIPv4(u) { meta.AgentDomain = u } } } in.MetaJSON = store.SpeakerMetaJSON(meta) 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 } patch := store.SpeakerMeta{ LastDispatchAt: time.Now().UTC().Format(time.RFC3339Nano), LastDispatchStatus: res.Status, } if res.Error != "" { patch.LastDispatchError = res.Error patch.SyncStatus = "error" } else if res.Status == "ok" { patch.LastDispatchError = "" patch.SyncStatus = "synced" } meta := store.MergeSpeakerMetaJSON(sp.MetaJSON, patch) _, _ = s.store.UpdateSpeaker(tenantID, sp.ID, &store.SpeakerPatch{MetaJSON: &meta}) }