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.
193 lines
5.4 KiB
Go
193 lines
5.4 KiB
Go
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
|
|
}
|