package agentserver import ( "context" "crypto/subtle" "encoding/json" "fmt" "log" "net/http" "os" "strings" "time" "evobgp/internal/birdfmt" "evobgp/internal/nodecli" ) const upstreamErrorDetail = "upstream request failed" // 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 firewall *firewallAgent } // New builds an agent HTTP server. func New(cfg Config) *Server { s := &Server{cfg: cfg, mux: http.NewServeMux(), firewall: newFirewallAgent(cfg)} s.mux.HandleFunc("GET /v1/agent/health", s.handleHealth) s.mux.HandleFunc("GET /v1/agent/bird/protocols", s.handleBirdProtocols) s.mux.HandleFunc("POST /v1/agent/sync", s.handleSync) if s.firewall.enabled { s.mux.HandleFunc("GET /v1/firewall/blocklist", s.firewall.handleBlocklist) s.mux.HandleFunc("POST /v1/firewall/apply-report", s.firewall.handleApplyReportForward) s.mux.HandleFunc("POST /v1/agent/firewall-replicate", s.handleFirewallReplicate) } 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) handleBirdProtocols(w http.ResponseWriter, r *http.Request) { if !s.authorize(r) { writeProblem(w, http.StatusUnauthorized, "missing or invalid Authorization") return } sock := strings.TrimSpace(s.cfg.Socket) if sock == "" { writeProblem(w, http.StatusServiceUnavailable, "EVOBGP_BIRDC_SOCKET not configured") return } ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second) defer cancel() out, err := birdfmt.ShowProtocols(ctx, sock, strings.TrimSpace(s.cfg.BirdcBin)) if err != nil { log.Printf("agentserver: bird protocols: %v", err) writeProblem(w, http.StatusBadGateway, upstreamErrorDetail) return } writeJSON(w, http.StatusOK, map[string]any{ "ok": true, "sessions": birdfmt.ParseBGPSessions(out), }) } 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, upstreamErrorDetail) 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 } got := strings.TrimSpace(h[len(prefix):]) return subtle.ConstantTimeCompare([]byte(got), []byte(secret)) == 1 } 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 }