CI / changes (push) Successful in 12s
CI / commitlint (push) Has been skipped
CI / openapi (push) Successful in 25s
CI / web (push) Successful in 46s
CI / go (push) Successful in 1m15s
CI / bird2 (push) Successful in 18s
CI / release (push) Successful in 3m59s
Introduced a comprehensive firewall blocklist feature, allowing for the management of firewall clients and their associated rules. This includes endpoints for enrolling clients, listing clients and rules, and reporting apply statuses. Enhanced the API to support firewall operations, including the ability to handle block/accept policies. Updated the documentation to reflect these changes and added necessary components in the web UI for better user interaction. Additionally, modified the agent server to support firewall failover and integrated firewall functionality into the existing architecture.
227 lines
6.6 KiB
Go
227 lines
6.6 KiB
Go
package agentserver
|
|
|
|
import (
|
|
"context"
|
|
"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
|
|
}
|
|
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
|
|
}
|