feat(remote-speakers): enhance remote speaker management and API integration
CI / changes (push) Successful in 8s
CI / commitlint (push) Has been skipped
CI / openapi (push) Successful in 25s
CI / web (push) Failing after 34s
CI / go (push) Failing after 19s
CI / bird2 (push) Has been skipped
CI / release (push) Has been skipped
CI / changes (push) Successful in 8s
CI / commitlint (push) Has been skipped
CI / openapi (push) Successful in 25s
CI / web (push) Failing after 34s
CI / go (push) Failing after 19s
CI / bird2 (push) Has been skipped
CI / release (push) Has been skipped
- Added support for remote speaker configuration in the README and documentation. - Implemented a new endpoint for retrieving the bundle signing public key. - Updated the `evobgp-agent` to include a `serve` command for Panel→Node sync API. - Enhanced CI workflow to validate remote speaker compose files. - Introduced new fields in the API and UI for managing speaker metadata, including dispatch status and sync status. - Improved error handling and response formatting in speaker-related API endpoints. - Updated documentation to reflect changes in remote speaker functionality and usage guidelines.
This commit is contained in:
@@ -0,0 +1,192 @@
|
||||
package agentserver
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"evobgp/internal/nodecli"
|
||||
)
|
||||
|
||||
// Config holds evobgp-agent serve settings.
|
||||
type Config struct {
|
||||
Listen string
|
||||
Secret string
|
||||
ControlPlaneURL string
|
||||
NodeToken string
|
||||
SpeakerID string
|
||||
PubKeyB64 string
|
||||
PubKeyHex string
|
||||
ExtractDir string
|
||||
BirdBin string
|
||||
BirdcBin string
|
||||
Socket string
|
||||
SyncTimeout time.Duration
|
||||
LastSync func() (revisionID string, at time.Time)
|
||||
OnSyncSuccess func(revisionID string)
|
||||
}
|
||||
|
||||
// Server serves Panel→Node internal API (Remnawave-style wake-up).
|
||||
type Server struct {
|
||||
cfg Config
|
||||
mux *http.ServeMux
|
||||
}
|
||||
|
||||
// New builds an agent HTTP server.
|
||||
func New(cfg Config) *Server {
|
||||
s := &Server{cfg: cfg, mux: http.NewServeMux()}
|
||||
s.mux.HandleFunc("GET /v1/agent/health", s.handleHealth)
|
||||
s.mux.HandleFunc("POST /v1/agent/sync", s.handleSync)
|
||||
return s
|
||||
}
|
||||
|
||||
// Handler returns the root HTTP handler.
|
||||
func (s *Server) Handler() http.Handler {
|
||||
return s.mux
|
||||
}
|
||||
|
||||
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.authorize(r) {
|
||||
writeProblem(w, http.StatusUnauthorized, "missing or invalid Authorization")
|
||||
return
|
||||
}
|
||||
body := map[string]any{
|
||||
"ok": true,
|
||||
"speaker_id": strings.TrimSpace(s.cfg.SpeakerID),
|
||||
}
|
||||
if s.cfg.LastSync != nil {
|
||||
if rev, at := s.cfg.LastSync(); rev != "" {
|
||||
body["last_applied_revision_id"] = rev
|
||||
body["last_sync_at"] = at.UTC().Format(time.RFC3339Nano)
|
||||
}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, body)
|
||||
}
|
||||
|
||||
func (s *Server) handleSync(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.authorize(r) {
|
||||
writeProblem(w, http.StatusUnauthorized, "missing or invalid Authorization")
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
RevisionID string `json:"revision_id"`
|
||||
}
|
||||
_ = json.NewDecoder(r.Body).Decode(&req)
|
||||
|
||||
timeout := s.cfg.SyncTimeout
|
||||
if timeout <= 0 {
|
||||
timeout = 45 * time.Second
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(r.Context(), timeout)
|
||||
defer cancel()
|
||||
|
||||
res, err := nodecli.SyncBundle(ctx, nodecli.SyncConfig{
|
||||
BaseURL: s.cfg.ControlPlaneURL,
|
||||
Token: s.cfg.NodeToken,
|
||||
SpeakerID: s.cfg.SpeakerID,
|
||||
RevisionID: strings.TrimSpace(req.RevisionID),
|
||||
PubKeyB64: s.cfg.PubKeyB64,
|
||||
PubKeyHex: s.cfg.PubKeyHex,
|
||||
ExtractDir: s.cfg.ExtractDir,
|
||||
BirdBin: s.cfg.BirdBin,
|
||||
BirdcBin: s.cfg.BirdcBin,
|
||||
Socket: s.cfg.Socket,
|
||||
Timeout: timeout,
|
||||
})
|
||||
if err != nil {
|
||||
log.Printf("agentserver: sync: %v", err)
|
||||
writeProblem(w, http.StatusBadGateway, err.Error())
|
||||
return
|
||||
}
|
||||
if s.cfg.OnSyncSuccess != nil {
|
||||
s.cfg.OnSyncSuccess(res.RevisionID)
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"ok": true,
|
||||
"applied_revision_id": res.RevisionID,
|
||||
"main_config": res.MainConfig,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) authorize(r *http.Request) bool {
|
||||
secret := strings.TrimSpace(s.cfg.Secret)
|
||||
if secret == "" {
|
||||
return false
|
||||
}
|
||||
h := r.Header.Get("Authorization")
|
||||
const prefix = "Bearer "
|
||||
if !strings.HasPrefix(h, prefix) {
|
||||
return false
|
||||
}
|
||||
return strings.TrimSpace(h[len(prefix):]) == secret
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, v any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
|
||||
func writeProblem(w http.ResponseWriter, status int, detail string) {
|
||||
w.Header().Set("Content-Type", "application/problem+json")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"title": http.StatusText(status),
|
||||
"status": status,
|
||||
"detail": detail,
|
||||
})
|
||||
}
|
||||
|
||||
// ListenAndServe starts the agent HTTP server on cfg.Listen.
|
||||
func ListenAndServe(cfg Config) error {
|
||||
if strings.TrimSpace(cfg.Listen) == "" {
|
||||
cfg.Listen = ":8443"
|
||||
}
|
||||
srv := &http.Server{
|
||||
Addr: cfg.Listen,
|
||||
Handler: New(cfg).Handler(),
|
||||
ReadHeaderTimeout: 10 * time.Second,
|
||||
}
|
||||
log.Printf("evobgp-agent serve: listening on %s speaker=%s", cfg.Listen, cfg.SpeakerID)
|
||||
return srv.ListenAndServe()
|
||||
}
|
||||
|
||||
// ConfigFromEnv builds Config from EVOBGP_* environment variables.
|
||||
func ConfigFromEnv() (Config, error) {
|
||||
cfg := Config{
|
||||
Listen: envOr("EVOBGP_AGENT_LISTEN", ":8443"),
|
||||
Secret: strings.TrimSpace(os.Getenv("EVOBGP_AGENT_SECRET")),
|
||||
ControlPlaneURL: strings.TrimSpace(os.Getenv("EVOBGP_CONTROL_PLANE_URL")),
|
||||
NodeToken: strings.TrimSpace(os.Getenv("EVOBGP_NODE_TOKEN")),
|
||||
SpeakerID: strings.TrimSpace(os.Getenv("EVOBGP_SPEAKER_ID")),
|
||||
PubKeyB64: strings.TrimSpace(os.Getenv("EVOBGP_BUNDLE_PUBKEY_BASE64")),
|
||||
PubKeyHex: strings.TrimSpace(os.Getenv("EVOBGP_BUNDLE_PUBKEY_HEX")),
|
||||
ExtractDir: envOr("EVOBGP_BIRD_EXTRACT_DIR", "/etc/bird"),
|
||||
BirdBin: strings.TrimSpace(os.Getenv("EVOBGP_BIRD_BIN")),
|
||||
BirdcBin: strings.TrimSpace(os.Getenv("EVOBGP_BIRDC_BIN")),
|
||||
Socket: strings.TrimSpace(os.Getenv("EVOBGP_BIRDC_SOCKET")),
|
||||
SyncTimeout: 45 * time.Second,
|
||||
}
|
||||
if cfg.Secret == "" {
|
||||
return cfg, fmt.Errorf("agentserver: EVOBGP_AGENT_SECRET required")
|
||||
}
|
||||
if cfg.ControlPlaneURL == "" || cfg.NodeToken == "" || cfg.SpeakerID == "" {
|
||||
return cfg, fmt.Errorf("agentserver: EVOBGP_CONTROL_PLANE_URL, EVOBGP_NODE_TOKEN, EVOBGP_SPEAKER_ID required")
|
||||
}
|
||||
if cfg.PubKeyB64 == "" && cfg.PubKeyHex == "" {
|
||||
return cfg, fmt.Errorf("agentserver: EVOBGP_BUNDLE_PUBKEY_BASE64 or EVOBGP_BUNDLE_PUBKEY_HEX required")
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func envOr(key, def string) string {
|
||||
if v := strings.TrimSpace(os.Getenv(key)); v != "" {
|
||||
return v
|
||||
}
|
||||
return def
|
||||
}
|
||||
Reference in New Issue
Block a user