Files
EvoBGP/internal/httpapi/routes_firewall.go
T
Denozordec 5edbd656ba
CI / changes (push) Successful in 11s
CI / commitlint (push) Has been skipped
CI / openapi (push) Successful in 29s
CI / web (push) Successful in 1m6s
CI / go (push) Successful in 1m23s
CI / bird2 (push) Successful in 17s
CI / release (push) Successful in 4m42s
feat(firewall): add packet statistics tracking for firewall clients
Enhanced the firewall client functionality by introducing packet statistics tracking, including the cumulative count of packets dropped and accepted. Updated the API to support these new fields and modified the database schema accordingly. Improved the firewall scripts to collect and report packet statistics, ensuring better visibility into client performance. Adjusted the UI components to display packet counts in the clients table, enhancing user experience and monitoring capabilities.
2026-07-09 00:50:38 +07:00

664 lines
21 KiB
Go

package httpapi
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"io"
"net/http"
"os"
"path/filepath"
"sort"
"strings"
"time"
"evobgp/internal/authkey"
"evobgp/internal/firewall"
"evobgp/internal/firewallscripts"
"evobgp/internal/store"
)
func (s *Server) registerFirewallRoutes(m *http.ServeMux) {
m.HandleFunc("GET /firewall/install-context", s.handleFirewallInstallContext)
m.HandleFunc("GET /firewall/clients", s.handleListFirewallClients)
m.HandleFunc("GET /firewall/clients/{id}", s.handleGetFirewallClient)
m.HandleFunc("GET /firewall/clients/{id}/preview", s.handleFirewallClientPreview)
m.HandleFunc("PATCH /firewall/clients/{id}", s.handlePatchFirewallClient)
m.HandleFunc("POST /firewall/clients/{id}/approve", s.handleApproveFirewallClient)
m.HandleFunc("POST /firewall/clients/{id}/revoke", s.handleRevokeFirewallClient)
m.HandleFunc("DELETE /firewall/clients/{id}", s.handleDeleteFirewallClient)
m.HandleFunc("GET /firewall/rules", s.handleListFirewallRules)
m.HandleFunc("POST /firewall/rules", s.handleCreateFirewallRule)
m.HandleFunc("PATCH /firewall/rules/{id}", s.handlePatchFirewallRule)
m.HandleFunc("DELETE /firewall/rules/{id}", s.handleDeleteFirewallRule)
m.HandleFunc("POST /firewall/rules:reorder", s.handleReorderFirewallRules)
m.HandleFunc("GET /firewall/blocklist", s.handleFirewallBlocklist)
m.HandleFunc("POST /firewall/apply-report", s.handleFirewallApplyReport)
m.HandleFunc("POST /firewall/heartbeat", s.handleFirewallHeartbeat)
}
func (s *Server) handleFirewallInstallContext(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "operator") {
return
}
seed := strings.TrimSpace(s.bundleSeedHex)
writeJSON(w, http.StatusOK, map[string]any{
"bundle_seed": seed,
"bundle_seed_configured": seed != "",
"suggested_cp_url": publicHTTPSBaseURL(r),
"install_sh_url": publicHTTPSBaseURL(r) + "/v1/firewall/install.sh",
})
}
// publicHTTPSBaseURL is the external HTTPS origin for firewall install/enroll links.
func publicHTTPSBaseURL(r *http.Request) string {
host := strings.TrimSpace(r.Host)
if xf := strings.TrimSpace(r.Header.Get("X-Forwarded-Host")); xf != "" {
host = strings.TrimSpace(strings.Split(xf, ",")[0])
}
if host == "" {
return ""
}
return "https://" + host
}
func requestBaseURL(r *http.Request) string {
return publicHTTPSBaseURL(r)
}
func (s *Server) handleFirewallEnrollPublic(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
writeProblem(w, http.StatusMethodNotAllowed, "Method Not Allowed", "POST required")
return
}
seed := strings.TrimSpace(r.Header.Get("X-EvoBGP-Seed"))
if seed == "" || s.bundleSeedHex == "" || !strings.EqualFold(seed, s.bundleSeedHex) {
writeProblem(w, http.StatusForbidden, "Forbidden", "invalid or missing X-EvoBGP-Seed")
return
}
var body struct {
Name string `json:"name"`
Hostname string `json:"hostname"`
ClientToken string `json:"client_token"`
ClientVersion string `json:"client_version"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil && !errors.Is(err, io.EOF) {
writeProblem(w, http.StatusBadRequest, "Bad Request", "invalid JSON body")
return
}
name := strings.TrimSpace(body.Name)
tok := strings.TrimSpace(body.ClientToken)
if name == "" || tok == "" {
writeProblem(w, http.StatusUnprocessableEntity, "Unprocessable Entity", "name and client_token are required")
return
}
if !strings.HasPrefix(tok, "evobgp_fw_") {
writeProblem(w, http.StatusUnprocessableEntity, "Unprocessable Entity", "client_token must use evobgp_fw_ prefix")
return
}
tenantID, err := s.firewallEnrollTenantID()
if err != nil {
writeInternalError(w, "internal", err)
return
}
hash := authkey.HashToken(tok)
prefix := tok
if len(prefix) > 12 {
prefix = prefix[:12]
}
client, err := s.store.CreateFirewallClient(tenantID, &store.FirewallClientCreate{
Name: name,
Hostname: strings.TrimSpace(body.Hostname),
TokenPrefix: prefix,
TokenHash: hash,
ClientVersion: strings.TrimSpace(body.ClientVersion),
})
if err != nil {
if errors.Is(err, store.ErrInvalidInput) {
writeProblem(w, http.StatusConflict, "Conflict", "client token already enrolled")
return
}
writeStoreErr(w, err)
return
}
writeJSON(w, http.StatusCreated, map[string]any{
"client_id": client.ID,
"status": client.Status,
"message": "pending operator approval in EvoBGP UI",
})
}
func (s *Server) firewallEnrollTenantID() (string, error) {
tid, _, _, _, _ := s.store.DemoIDs()
if tid != "" {
return tid, nil
}
ids, err := s.store.ListTenantIDs()
if err != nil {
return "", err
}
if len(ids) == 0 {
return "", errors.New("httpapi: no tenant for firewall enroll")
}
return ids[0], nil
}
func (s *Server) handleFirewallInstallScript(w http.ResponseWriter, r *http.Request) {
s.serveFirewallScript(w, "install.sh")
}
func (s *Server) handleFirewallSyncScript(w http.ResponseWriter, r *http.Request) {
s.serveFirewallScript(w, "evobgp-firewall.sh")
}
func (s *Server) serveFirewallScript(w http.ResponseWriter, name string) {
b, err := readFirewallScript(name)
if err != nil {
writeProblem(w, http.StatusNotFound, "Not Found", "script not found")
return
}
w.Header().Set("Content-Type", "text/x-shellscript; charset=utf-8")
w.WriteHeader(http.StatusOK)
_, _ = w.Write(b)
}
func readFirewallScript(name string) ([]byte, error) {
if b, err := firewallscripts.FS.ReadFile(name); err == nil {
return b, nil
}
candidates := []string{}
if dir := strings.TrimSpace(os.Getenv("EVOBGP_FIREWALL_SCRIPTS")); dir != "" {
candidates = append(candidates, filepath.Join(dir, name))
}
candidates = append(candidates, filepath.Join("scripts", "firewall", name))
for _, p := range candidates {
b, err := os.ReadFile(p)
if err == nil {
return b, nil
}
}
return nil, os.ErrNotExist
}
func (s *Server) handleListFirewallClients(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "viewer") {
return
}
items, err := s.store.ListFirewallClients(a.TenantID)
if err != nil {
writeInternalError(w, "internal", err)
return
}
writeJSON(w, http.StatusOK, map[string]any{"items": items})
}
func (s *Server) handleGetFirewallClient(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "viewer") {
return
}
id := strings.TrimSpace(r.PathValue("id"))
client, err := s.store.GetFirewallClient(a.TenantID, id)
if err != nil {
writeProblem(w, http.StatusNotFound, "Not Found", "client not found")
return
}
writeJSON(w, http.StatusOK, client)
}
func (s *Server) handlePatchFirewallClient(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "operator") {
return
}
id := strings.TrimSpace(r.PathValue("id"))
var patch store.FirewallClientPatch
if err := json.NewDecoder(r.Body).Decode(&patch); err != nil {
writeProblem(w, http.StatusBadRequest, "Bad Request", "invalid JSON body")
return
}
client, err := s.store.UpdateFirewallClient(a.TenantID, id, &patch)
if err != nil {
writeProblem(w, http.StatusNotFound, "Not Found", "client not found")
return
}
writeJSON(w, http.StatusOK, client)
}
func (s *Server) handleApproveFirewallClient(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "operator") {
return
}
id := strings.TrimSpace(r.PathValue("id"))
client, err := s.store.ApproveFirewallClient(a.TenantID, id, a.APIKeyID)
if err != nil {
writeProblem(w, http.StatusNotFound, "Not Found", "client not found")
return
}
_ = s.firewallResolver.Reload(s.store)
go s.replicateFirewallStateToSpeakers(a.TenantID)
writeJSON(w, http.StatusOK, client)
}
func (s *Server) handleRevokeFirewallClient(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "operator") {
return
}
id := strings.TrimSpace(r.PathValue("id"))
if err := s.store.RevokeFirewallClient(a.TenantID, id); err != nil {
writeProblem(w, http.StatusNotFound, "Not Found", "client not found")
return
}
_ = s.firewallResolver.Reload(s.store)
go s.replicateFirewallStateToSpeakers(a.TenantID)
writeJSON(w, http.StatusOK, map[string]string{"status": "revoked"})
}
func (s *Server) handleDeleteFirewallClient(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "operator") {
return
}
id := strings.TrimSpace(r.PathValue("id"))
if err := s.store.DeleteFirewallClient(a.TenantID, id); err != nil {
writeProblem(w, http.StatusNotFound, "Not Found", "client not found")
return
}
_ = s.firewallResolver.Reload(s.store)
go s.replicateFirewallStateToSpeakers(a.TenantID)
w.WriteHeader(http.StatusNoContent)
}
func (s *Server) handleListFirewallRules(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "viewer") {
return
}
scope := strings.TrimSpace(r.URL.Query().Get("scope"))
var clientID *string
if scope == "client" {
cid := strings.TrimSpace(r.URL.Query().Get("client_id"))
if cid == "" {
writeProblem(w, http.StatusUnprocessableEntity, "Unprocessable Entity", "client_id required for scope=client")
return
}
clientID = &cid
}
items, err := s.store.ListFirewallRules(a.TenantID, clientID)
if err != nil {
writeInternalError(w, "internal", err)
return
}
writeJSON(w, http.StatusOK, map[string]any{"items": items})
}
func (s *Server) handleCreateFirewallRule(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "operator") {
return
}
var body struct {
Scope string `json:"scope"`
ClientID *string `json:"client_id"`
Action string `json:"action"`
CommunityID *string `json:"community_id"`
Comment string `json:"comment"`
Priority *int `json:"priority"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeProblem(w, http.StatusBadRequest, "Bad Request", "invalid JSON body")
return
}
var clientID *string
if strings.TrimSpace(body.Scope) == "client" {
if body.ClientID == nil || strings.TrimSpace(*body.ClientID) == "" {
writeProblem(w, http.StatusUnprocessableEntity, "Unprocessable Entity", "client_id required for scope=client")
return
}
cid := strings.TrimSpace(*body.ClientID)
clientID = &cid
}
rule, err := s.store.CreateFirewallRule(a.TenantID, clientID, &store.FirewallRuleCreate{
Priority: body.Priority,
Action: body.Action,
CommunityID: body.CommunityID,
Comment: body.Comment,
})
if err != nil {
writeProblem(w, http.StatusUnprocessableEntity, "Unprocessable Entity", "invalid rule")
return
}
go s.replicateFirewallStateToSpeakers(a.TenantID)
writeJSON(w, http.StatusCreated, rule)
}
func (s *Server) handlePatchFirewallRule(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "operator") {
return
}
id := strings.TrimSpace(r.PathValue("id"))
var patch store.FirewallRulePatch
if err := json.NewDecoder(r.Body).Decode(&patch); err != nil {
writeProblem(w, http.StatusBadRequest, "Bad Request", "invalid JSON body")
return
}
rule, err := s.store.UpdateFirewallRule(a.TenantID, id, &patch)
if err != nil {
writeProblem(w, http.StatusNotFound, "Not Found", "rule not found")
return
}
go s.replicateFirewallStateToSpeakers(a.TenantID)
writeJSON(w, http.StatusOK, rule)
}
func (s *Server) handleDeleteFirewallRule(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "operator") {
return
}
id := strings.TrimSpace(r.PathValue("id"))
if err := s.store.DeleteFirewallRule(a.TenantID, id); err != nil {
writeProblem(w, http.StatusNotFound, "Not Found", "rule not found")
return
}
go s.replicateFirewallStateToSpeakers(a.TenantID)
w.WriteHeader(http.StatusNoContent)
}
func (s *Server) handleReorderFirewallRules(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "operator") {
return
}
var body struct {
Scope string `json:"scope"`
ClientID *string `json:"client_id"`
OrderedIDs []string `json:"ordered_ids"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeProblem(w, http.StatusBadRequest, "Bad Request", "invalid JSON body")
return
}
var clientID *string
if strings.TrimSpace(body.Scope) == "client" {
if body.ClientID == nil || strings.TrimSpace(*body.ClientID) == "" {
writeProblem(w, http.StatusUnprocessableEntity, "Unprocessable Entity", "client_id required")
return
}
cid := strings.TrimSpace(*body.ClientID)
clientID = &cid
}
if err := s.store.ReorderFirewallRules(a.TenantID, clientID, body.OrderedIDs); err != nil {
writeProblem(w, http.StatusUnprocessableEntity, "Unprocessable Entity", "invalid reorder")
return
}
go s.replicateFirewallStateToSpeakers(a.TenantID)
writeJSON(w, http.StatusOK, map[string]bool{"ok": true})
}
func (s *Server) handleFirewallBlocklist(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireFirewall(w, a) {
return
}
client, err := s.store.GetFirewallClient(a.TenantID, a.APIKeyID)
if err != nil {
writeProblem(w, http.StatusUnauthorized, "Unauthorized", "unknown firewall client")
return
}
if client.Status != "approved" {
w.Header().Set("Retry-After", "60")
writeProblem(w, http.StatusForbidden, "Forbidden", "client pending approval")
return
}
_ = s.store.TouchFirewallClientLastSeen(client.ID, "cp", clientIP(r), r.UserAgent())
resp, err := s.buildFirewallBlocklist(r.Context(), client)
if err != nil {
if errors.Is(err, errNoFirewallRevision) {
writeProblem(w, http.StatusNotFound, "Not Found", "no published revision")
return
}
writeInternalError(w, "internal", err)
return
}
w.Header().Set("X-EvoBGP-Source", "cp")
w.Header().Set("X-EvoBGP-Revision-ID", resp.RevisionID)
w.Header().Set("X-EvoBGP-Generated-At", resp.GeneratedAt)
w.Header().Set("X-EvoBGP-Rules-Version", resp.RulesVersion)
if strings.Contains(r.Header.Get("Accept"), "text/plain") {
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
for _, p := range resp.Prefixes {
_, _ = w.Write([]byte(p + "\n"))
}
return
}
writeJSON(w, http.StatusOK, resp)
}
func (s *Server) handleFirewallApplyReport(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireFirewall(w, a) {
return
}
var body struct {
Status string `json:"status"`
Error string `json:"error"`
PrefixCount int `json:"prefix_count"`
IPCount int `json:"ip_count"`
PacketsDropped int64 `json:"packets_dropped"`
PacketsAccepted int64 `json:"packets_accepted"`
Version string `json:"version"`
KernelMethod string `json:"kernel_method"`
Source string `json:"source"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeProblem(w, http.StatusBadRequest, "Bad Request", "invalid JSON body")
return
}
src := strings.TrimSpace(body.Source)
if src == "" {
src = "cp"
}
_ = s.store.TouchFirewallClientLastApply(
a.APIKeyID, src, body.Status, body.Error,
body.PrefixCount, body.IPCount, body.PacketsDropped, body.PacketsAccepted,
)
writeJSON(w, http.StatusOK, map[string]bool{"ok": true})
}
func (s *Server) handleFirewallHeartbeat(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireFirewall(w, a) {
return
}
var body struct {
Source string `json:"source"`
}
_ = json.NewDecoder(r.Body).Decode(&body)
src := strings.TrimSpace(body.Source)
if src == "" {
src = "cp"
}
_ = s.store.TouchFirewallClientLastSeen(a.APIKeyID, src, clientIP(r), r.UserAgent())
writeJSON(w, http.StatusOK, map[string]bool{"ok": true})
}
func (s *Server) handleFirewallClientPreview(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "viewer") {
return
}
id := strings.TrimSpace(r.PathValue("id"))
client, err := s.store.GetFirewallClient(a.TenantID, id)
if err != nil {
writeProblem(w, http.StatusNotFound, "Not Found", "client not found")
return
}
resp, err := s.buildFirewallBlocklist(r.Context(), client)
if err != nil {
if errors.Is(err, errNoFirewallRevision) {
writeProblem(w, http.StatusNotFound, "Not Found", "no published revision")
return
}
writeInternalError(w, "internal", err)
return
}
writeJSON(w, http.StatusOK, resp)
}
var errNoFirewallRevision = errors.New("httpapi: no firewall revision")
type firewallBlocklistResponse struct {
ClientID string `json:"client_id"`
RevisionID string `json:"revision_id"`
GeneratedAt string `json:"generated_at"`
Source string `json:"source"`
RulesApplied int `json:"rules_applied"`
CommunitiesEvaluated int `json:"communities_evaluated"`
CommunitiesBlocked int `json:"communities_blocked"`
Prefixes []string `json:"prefixes"`
Total int `json:"total"`
Hash string `json:"hash"`
RulesVersion string `json:"-"`
}
func (s *Server) buildFirewallBlocklist(ctx context.Context, client *store.FirewallClient) (*firewallBlocklistResponse, error) {
_ = ctx
revs, _, _ := s.store.ListRevisions(client.TenantID, "", "", 1)
if len(revs) == 0 {
return nil, errNoFirewallRevision
}
rev := revs[0]
prefixesByCommunity, commCount, err := s.loadPrefixesByCommunity(client.TenantID, rev.ID)
if err != nil {
return nil, err
}
rules, err := s.store.ListAllFirewallRulesForClient(client.TenantID, client.ID)
if err != nil {
return nil, err
}
fwRules := storeRulesToFirewall(rules)
blocked := firewall.Evaluate(client.ID, fwRules, prefixesByCommunity)
blockedComm := countBlockedCommunities(client.ID, fwRules, prefixesByCommunity)
hash := prefixListHash(blocked)
return &firewallBlocklistResponse{
ClientID: client.ID,
RevisionID: rev.ID,
GeneratedAt: time.Now().UTC().Format(time.RFC3339),
Source: "cp",
RulesApplied: len(rules),
CommunitiesEvaluated: commCount,
CommunitiesBlocked: blockedComm,
Prefixes: blocked,
Total: len(blocked),
Hash: hash,
RulesVersion: firewall.RulesVersionHash(fwRules),
}, nil
}
func (s *Server) loadPrefixesByCommunity(tenantID, revisionID string) (map[string][]string, int, error) {
out := make(map[string][]string)
communities := make(map[string]struct{})
cursor := ""
for {
rows, next, more := s.store.ListRevisionPrefixes(tenantID, revisionID, cursor, 5000)
for _, row := range rows {
key := ""
if row.CommunityID != nil {
key = strings.TrimSpace(*row.CommunityID)
}
communities[key] = struct{}{}
out[key] = append(out[key], strings.TrimSpace(row.Prefix))
}
if !more {
break
}
cursor = next
}
return out, len(communities), nil
}
func storeRulesToFirewall(rules []*store.FirewallRule) []firewall.Rule {
out := make([]firewall.Rule, 0, len(rules))
for _, r := range rules {
var cid *string
if r.CommunityID != nil {
v := *r.CommunityID
cid = &v
}
var cl *string
if r.ClientID != nil {
v := *r.ClientID
cl = &v
}
out = append(out, firewall.Rule{
ClientID: cl,
Priority: r.Priority,
Action: r.Action,
CommunityID: cid,
})
}
return out
}
func countBlockedCommunities(clientID string, rules []firewall.Rule, prefixesByCommunity map[string][]string) int {
n := 0
for k := range prefixesByCommunity {
ordered := mergeRulesForCount(clientID, rules)
for _, r := range ordered {
if r.CommunityID == nil || strings.TrimSpace(*r.CommunityID) == k {
if strings.EqualFold(r.Action, "block") {
n++
}
break
}
}
}
return n
}
func mergeRulesForCount(clientID string, rules []firewall.Rule) []firewall.Rule {
var clientRules, tenantRules []firewall.Rule
for _, r := range rules {
if r.ClientID != nil && *r.ClientID == clientID {
clientRules = append(clientRules, r)
continue
}
if r.ClientID == nil {
tenantRules = append(tenantRules, r)
}
}
sort.Slice(clientRules, func(i, j int) bool { return clientRules[i].Priority < clientRules[j].Priority })
sort.Slice(tenantRules, func(i, j int) bool { return tenantRules[i].Priority < tenantRules[j].Priority })
out := append([]firewall.Rule{}, clientRules...)
return append(out, tenantRules...)
}
func prefixListHash(prefixes []string) string {
cp := append([]string(nil), prefixes...)
sort.Strings(cp)
sum := sha256.Sum256([]byte(strings.Join(cp, "\n")))
return "sha256:" + hex.EncodeToString(sum[:])
}
func clientIP(r *http.Request) string {
if xff := strings.TrimSpace(r.Header.Get("X-Forwarded-For")); xff != "" {
parts := strings.Split(xff, ",")
return strings.TrimSpace(parts[0])
}
host := r.RemoteAddr
if i := strings.LastIndex(host, ":"); i >= 0 {
return host[:i]
}
return host
}