Очередь job получила lease и fencing, телеметрия и прогресс deploy сохраняются, ревизия пишет manifest входов. Обновление префиксов не переписывает неизменённый снимок, большие наборы идут через COPY, а устаревшая ревизия не попадает в deploy. Бандл ноды кэшируется, prune и постановка job больше не держат горячий путь. Co-authored-by: Cursor <cursoragent@cursor.com>
277 lines
7.2 KiB
Go
277 lines
7.2 KiB
Go
package httpapi
|
|
|
|
import (
|
|
"bytes"
|
|
"crypto/rand"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"net"
|
|
"net/http"
|
|
"net/netip"
|
|
"net/url"
|
|
"strconv"
|
|
"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 r == nil {
|
|
return ""
|
|
}
|
|
if peer, ok := peerAddress(r); ok && isTrustedProxy(peer, trustedProxyPrefixes()) {
|
|
protos := r.Header.Values("X-Forwarded-Proto")
|
|
hosts := r.Header.Values("X-Forwarded-Host")
|
|
if len(protos) == 1 && len(hosts) == 1 {
|
|
proto := strings.TrimSpace(protos[0])
|
|
host := strings.TrimSpace(hosts[0])
|
|
if (proto == "http" || proto == "https") && validControlPlaneHost(host) {
|
|
return proto + "://" + host
|
|
}
|
|
}
|
|
}
|
|
if !validControlPlaneHost(r.Host) {
|
|
return ""
|
|
}
|
|
proto := "http"
|
|
if r.TLS != nil {
|
|
proto = "https"
|
|
}
|
|
return proto + "://" + r.Host
|
|
}
|
|
|
|
func validControlPlaneHost(host string) bool {
|
|
if host == "" || strings.TrimSpace(host) != host || strings.HasSuffix(host, ":") || strings.ContainsAny(host, " ,/@?#\\\t\r\n") {
|
|
return false
|
|
}
|
|
u, err := url.Parse("http://" + host)
|
|
if err != nil || u.Host != host || u.User != nil || u.Path != "" || u.RawQuery != "" || u.Fragment != "" {
|
|
return false
|
|
}
|
|
name := u.Hostname()
|
|
if name == "" {
|
|
return false
|
|
}
|
|
if strings.HasPrefix(host, "[") {
|
|
if _, err := netip.ParseAddr(name); err != nil {
|
|
return false
|
|
}
|
|
} else if net.ParseIP(name) == nil {
|
|
for _, label := range strings.Split(name, ".") {
|
|
if label == "" || label[0] == '-' || label[len(label)-1] == '-' {
|
|
return false
|
|
}
|
|
for _, c := range label {
|
|
if !(c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9' || c == '-') {
|
|
return false
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if port := u.Port(); port != "" {
|
|
n, err := strconv.Atoi(port)
|
|
if err != nil || n < 1 || n > 65535 {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
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})
|
|
}
|