quality / commitlint (push) Skipped
quality / changes (push) Successful in 8s
quality / docker-check (push) Skipped
quality / openapi (push) Successful in 26s
quality / web (push) Successful in 1m27s
quality / go (push) Successful in 1m18s
quality / bird2 (push) Successful in 16s
CD / quality (push) Successful in 3m43s
CD / publish (push) Successful in 3m11s
- Enhanced the speaker installation documentation to clarify the use of TCP port 179 and the logging commands for monitoring BIRD and evobgp-agent. - Updated the speaker form dialog to include additional information about MikroTik connections and logging commands. - Modified the BIRD configuration to include logging to stderr for better visibility during operations. - Adjusted the Docker Compose configuration to ensure proper network settings and sysctl configurations for BGP functionality.
248 lines
7.3 KiB
Go
248 lines
7.3 KiB
Go
package agentserver
|
|
|
|
import (
|
|
"context"
|
|
"crypto/subtle"
|
|
"encoding/json"
|
|
"fmt"
|
|
"log"
|
|
"net/http"
|
|
"net/url"
|
|
"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
|
|
}
|
|
revID := strings.TrimSpace(req.RevisionID)
|
|
log.Printf("agentserver: sync start speaker_id=%s revision_id=%q control_plane=%s",
|
|
strings.TrimSpace(s.cfg.SpeakerID), revID, controlPlaneHost(s.cfg.ControlPlaneURL))
|
|
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: revID,
|
|
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 failed speaker_id=%s revision_id=%q err=%v",
|
|
strings.TrimSpace(s.cfg.SpeakerID), revID, err)
|
|
writeProblem(w, http.StatusBadGateway, upstreamErrorDetail)
|
|
return
|
|
}
|
|
if s.cfg.OnSyncSuccess != nil {
|
|
s.cfg.OnSyncSuccess(res.RevisionID)
|
|
}
|
|
log.Printf("agentserver: sync ok speaker_id=%s applied_revision_id=%s",
|
|
strings.TrimSpace(s.cfg.SpeakerID), res.RevisionID)
|
|
writeJSON(w, http.StatusOK, map[string]any{
|
|
"ok": true,
|
|
"applied_revision_id": res.RevisionID,
|
|
"main_config": res.MainConfig,
|
|
})
|
|
}
|
|
|
|
func controlPlaneHost(raw string) string {
|
|
raw = strings.TrimSpace(raw)
|
|
if raw == "" {
|
|
return ""
|
|
}
|
|
u, err := url.Parse(raw)
|
|
if err != nil || strings.TrimSpace(u.Host) == "" {
|
|
return raw
|
|
}
|
|
return u.Host
|
|
}
|
|
|
|
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
|
|
}
|