feat(firewall): implement firewall blocklist feature with client management and policy rules
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
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.
This commit is contained in:
@@ -4,6 +4,8 @@ import (
|
||||
"context"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"evobgp/internal/authkey"
|
||||
)
|
||||
|
||||
type ctxKey int
|
||||
@@ -95,6 +97,14 @@ func (s *Server) resolveAuth(raw string) (Auth, bool) {
|
||||
}
|
||||
rec, ok := s.keyResolver.Lookup(raw)
|
||||
if !ok {
|
||||
if s.firewallResolver != nil {
|
||||
if fw, ok := s.firewallResolver.Lookup(raw); ok {
|
||||
return Auth{TenantID: fw.tenantID, Role: "firewall", Token: raw, APIKeyID: fw.clientID}, true
|
||||
}
|
||||
}
|
||||
if client, err := s.store.LookupFirewallClientByTokenHash(authkey.HashToken(raw)); err == nil {
|
||||
return Auth{TenantID: client.TenantID, Role: "firewall", Token: raw, APIKeyID: client.ID}, true
|
||||
}
|
||||
return Auth{}, false
|
||||
}
|
||||
return authFromKeyRecord(raw, rec), true
|
||||
@@ -134,6 +144,14 @@ func (s *Server) requireAtLeast(w http.ResponseWriter, a Auth, need string) bool
|
||||
return true
|
||||
}
|
||||
|
||||
func (s *Server) requireFirewall(w http.ResponseWriter, a Auth) bool {
|
||||
if strings.ToLower(a.Role) != "firewall" {
|
||||
writeProblem(w, http.StatusForbidden, "Forbidden", "firewall client role required")
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (s *Server) requireNode(w http.ResponseWriter, a Auth) bool {
|
||||
if strings.ToLower(a.Role) != "node" {
|
||||
writeProblem(w, http.StatusForbidden, "Forbidden", "node role required")
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"evobgp/internal/nodedispatch"
|
||||
"evobgp/internal/store"
|
||||
)
|
||||
|
||||
func (s *Server) replicateFirewallStateToSpeakers(tenantID string) {
|
||||
if !nodedispatch.Enabled() {
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second)
|
||||
defer cancel()
|
||||
|
||||
clients, err := s.store.ListApprovedFirewallClientsForReplication(tenantID)
|
||||
if err != nil {
|
||||
log.Printf("httpapi: firewall replicate clients: %v", err)
|
||||
return
|
||||
}
|
||||
rules, err := s.store.ListAllFirewallRulesForReplication(tenantID)
|
||||
if err != nil {
|
||||
log.Printf("httpapi: firewall replicate rules: %v", err)
|
||||
return
|
||||
}
|
||||
revs, _, _ := s.store.ListRevisions(tenantID, "", "", 1)
|
||||
if len(revs) == 0 {
|
||||
return
|
||||
}
|
||||
revID := revs[0].ID
|
||||
prefixesByCommunity, _, err := s.loadPrefixesByCommunity(tenantID, revID)
|
||||
if err != nil {
|
||||
log.Printf("httpapi: firewall replicate prefixes: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
payloadRules := make([]map[string]any, 0, len(rules))
|
||||
for _, r := range rules {
|
||||
payloadRules = append(payloadRules, map[string]any{
|
||||
"client_id": r.ClientID,
|
||||
"priority": r.Priority,
|
||||
"action": r.Action,
|
||||
"community_id": r.CommunityID,
|
||||
})
|
||||
}
|
||||
payloadClients := make([]map[string]any, 0, len(clients))
|
||||
for _, c := range clients {
|
||||
payloadClients = append(payloadClients, map[string]any{
|
||||
"token_hash_hex": c.TokenHashHex,
|
||||
"client_id": c.ClientID,
|
||||
"name": c.Name,
|
||||
})
|
||||
}
|
||||
body := map[string]any{
|
||||
"tenant_id": tenantID,
|
||||
"revision_id": revID,
|
||||
"generated_at": time.Now().UTC().Format(time.RFC3339),
|
||||
"clients": payloadClients,
|
||||
"rules": payloadRules,
|
||||
"prefixes_by_community": prefixesByCommunity,
|
||||
}
|
||||
|
||||
speakers := s.store.ListSpeakersForTenant(tenantID)
|
||||
for _, sp := range speakers {
|
||||
meta := store.ParseSpeakerMeta(sp.MetaJSON)
|
||||
if !store.SpeakerNeedsRemoteDispatch(sp.Role, meta) || !meta.FirewallFailover {
|
||||
continue
|
||||
}
|
||||
domain := strings.TrimSpace(meta.AgentDomain)
|
||||
if domain == "" {
|
||||
continue
|
||||
}
|
||||
url := "https://" + strings.TrimSuffix(domain, "/") + "/v1/agent/firewall-replicate"
|
||||
status, errMsg := postFirewallReplicate(ctx, url, meta.AgentSecret, body)
|
||||
patch := store.SpeakerMeta{
|
||||
LastFirewallReplicateAt: time.Now().UTC().Format(time.RFC3339Nano),
|
||||
LastFirewallReplicateStatus: status,
|
||||
LastFirewallReplicateError: errMsg,
|
||||
}
|
||||
merged := store.MergeSpeakerMetaJSON(sp.MetaJSON, patch)
|
||||
mp := merged
|
||||
if _, err := s.store.UpdateSpeaker(tenantID, sp.ID, &store.SpeakerPatch{MetaJSON: &mp}); err != nil {
|
||||
log.Printf("httpapi: firewall replicate meta update %s: %v", sp.ID, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func postFirewallReplicate(ctx context.Context, url, secret string, body map[string]any) (status, errMsg string) {
|
||||
b, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return "error", err.Error()
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(b))
|
||||
if err != nil {
|
||||
return "error", err.Error()
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+strings.TrimSpace(secret))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
client := &http.Client{Timeout: 30 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return "error", err.Error()
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
_, _ = io.Copy(io.Discard, resp.Body)
|
||||
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
|
||||
return "ok", ""
|
||||
}
|
||||
return "error", resp.Status
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"encoding/hex"
|
||||
"sync"
|
||||
|
||||
"evobgp/internal/authkey"
|
||||
"evobgp/internal/store"
|
||||
)
|
||||
|
||||
type firewallAuthRow struct {
|
||||
tenantID string
|
||||
clientID string
|
||||
}
|
||||
|
||||
type firewallTokenResolver struct {
|
||||
mu sync.RWMutex
|
||||
byHash map[string]firewallAuthRow
|
||||
}
|
||||
|
||||
func newFirewallTokenResolver(st store.Backend) (*firewallTokenResolver, error) {
|
||||
r := &firewallTokenResolver{byHash: make(map[string]firewallAuthRow)}
|
||||
return r, r.reloadFromStore(st)
|
||||
}
|
||||
|
||||
func (r *firewallTokenResolver) reloadFromStore(st store.Backend) error {
|
||||
rows, err := st.ListActiveFirewallClientHashes()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
byHash := make(map[string]firewallAuthRow, len(rows))
|
||||
for _, row := range rows {
|
||||
if len(row.TokenHash) != 32 {
|
||||
continue
|
||||
}
|
||||
byHash[hex.EncodeToString(row.TokenHash)] = firewallAuthRow{
|
||||
tenantID: row.TenantID,
|
||||
clientID: row.ID,
|
||||
}
|
||||
}
|
||||
r.mu.Lock()
|
||||
r.byHash = byHash
|
||||
r.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *firewallTokenResolver) Reload(st store.Backend) error {
|
||||
return r.reloadFromStore(st)
|
||||
}
|
||||
|
||||
func (r *firewallTokenResolver) Lookup(raw string) (firewallAuthRow, bool) {
|
||||
hash := authkey.HashToken(raw)
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
rec, ok := r.byHash[hex.EncodeToString(hash)]
|
||||
return rec, ok
|
||||
}
|
||||
@@ -35,6 +35,9 @@ func (s *Server) Handler() http.Handler {
|
||||
s.mux.HandleFunc("GET /v1/health", s.handleHealth)
|
||||
s.mux.HandleFunc("GET /v1/ready", s.handleReady)
|
||||
s.mux.HandleFunc("GET /v1/version", s.handleVersion)
|
||||
s.mux.HandleFunc("POST /v1/firewall/enroll", s.handleFirewallEnrollPublic)
|
||||
s.mux.HandleFunc("GET /v1/firewall/install.sh", s.handleFirewallInstallScript)
|
||||
s.mux.HandleFunc("GET /v1/firewall/sync-script", s.handleFirewallSyncScript)
|
||||
s.mux.Handle("/v1/", s.authMiddleware(wrappedV1))
|
||||
return s.withCORS(observability.HTTPMiddleware(s.mux))
|
||||
}
|
||||
@@ -82,6 +85,7 @@ func (s *Server) registerV1(m *http.ServeMux) {
|
||||
s.registerPostgresMaintenanceRoutes(m)
|
||||
s.registerMaintenanceRoutes(m)
|
||||
s.registerRuntimeLogsRoutes(m)
|
||||
s.registerFirewallRoutes(m)
|
||||
}
|
||||
|
||||
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
@@ -0,0 +1,609 @@
|
||||
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/store"
|
||||
)
|
||||
|
||||
func (s *Server) registerFirewallRoutes(m *http.ServeMux) {
|
||||
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) 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
|
||||
}
|
||||
writeInternalError(w, "internal", 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) {
|
||||
path := filepath.Join("scripts", "firewall", name)
|
||||
b, err := os.ReadFile(path)
|
||||
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 (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"`
|
||||
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)
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"evobgp/internal/authkey"
|
||||
"evobgp/internal/store"
|
||||
)
|
||||
|
||||
func TestFirewallEnrollAndBlocklist(t *testing.T) {
|
||||
srv, err := New(Options{SeedDemo: true, BundleSeedHex: testBundleSeed})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer srv.Close()
|
||||
tenant, _, _, _, _ := srv.Store().DemoIDs()
|
||||
mustSetTestAPIKeys(t, srv, "opkey|"+tenant+"|operator")
|
||||
|
||||
ts := httptest.NewServer(srv.Handler())
|
||||
defer ts.Close()
|
||||
client := ts.Client()
|
||||
|
||||
tok := "evobgp_fw_testtoken123456789012345678901234"
|
||||
enrollBody := `{"name":"web-01","hostname":"web-01.local","client_token":"` + tok + `","client_version":"test/1"}`
|
||||
reqEnroll, _ := http.NewRequest(http.MethodPost, ts.URL+"/v1/firewall/enroll", strings.NewReader(enrollBody))
|
||||
reqEnroll.Header.Set("Content-Type", "application/json")
|
||||
reqEnroll.Header.Set("X-EvoBGP-Seed", testBundleSeed)
|
||||
respEnroll, err := client.Do(reqEnroll)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() { _ = respEnroll.Body.Close() }()
|
||||
if respEnroll.StatusCode != http.StatusCreated {
|
||||
b, _ := io.ReadAll(respEnroll.Body)
|
||||
t.Fatalf("enroll status=%d body=%s", respEnroll.StatusCode, b)
|
||||
}
|
||||
var enroll map[string]any
|
||||
if err := json.NewDecoder(respEnroll.Body).Decode(&enroll); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
clientID, _ := enroll["client_id"].(string)
|
||||
if clientID == "" {
|
||||
t.Fatal("missing client_id")
|
||||
}
|
||||
|
||||
reqBlock, _ := http.NewRequest(http.MethodGet, ts.URL+"/v1/firewall/blocklist", nil)
|
||||
reqBlock.Header.Set("Authorization", "Bearer "+tok)
|
||||
respBlock, err := client.Do(reqBlock)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() { _ = respBlock.Body.Close() }()
|
||||
if respBlock.StatusCode != http.StatusForbidden {
|
||||
t.Fatalf("pending blocklist want 403 got %d", respBlock.StatusCode)
|
||||
}
|
||||
|
||||
reqApprove, _ := http.NewRequest(http.MethodPost, ts.URL+"/v1/firewall/clients/"+clientID+"/approve", nil)
|
||||
reqApprove.Header.Set("Authorization", "Bearer opkey")
|
||||
respApprove, err := client.Do(reqApprove)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() { _ = respApprove.Body.Close() }()
|
||||
if respApprove.StatusCode != http.StatusOK {
|
||||
b, _ := io.ReadAll(respApprove.Body)
|
||||
t.Fatalf("approve status=%d body=%s", respApprove.StatusCode, b)
|
||||
}
|
||||
|
||||
_, err = srv.Store().CreateFirewallRule(tenant, nil, &store.FirewallRuleCreate{Action: "accept", Comment: "default"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
reqBlock2, _ := http.NewRequest(http.MethodGet, ts.URL+"/v1/firewall/blocklist", nil)
|
||||
reqBlock2.Header.Set("Authorization", "Bearer "+tok)
|
||||
respBlock2, err := client.Do(reqBlock2)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() { _ = respBlock2.Body.Close() }()
|
||||
if respBlock2.StatusCode != http.StatusOK {
|
||||
b, _ := io.ReadAll(respBlock2.Body)
|
||||
t.Fatalf("blocklist status=%d body=%s", respBlock2.StatusCode, b)
|
||||
}
|
||||
var bl map[string]any
|
||||
if err := json.NewDecoder(respBlock2.Body).Decode(&bl); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if total, _ := bl["total"].(float64); total != 0 {
|
||||
t.Fatalf("accept-only want empty blocklist, total=%v", total)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFirewallEnrollBadSeed(t *testing.T) {
|
||||
srv, err := New(Options{SeedDemo: true, BundleSeedHex: testBundleSeed})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer srv.Close()
|
||||
ts := httptest.NewServer(srv.Handler())
|
||||
defer ts.Close()
|
||||
|
||||
body := `{"name":"x","client_token":"evobgp_fw_` + strings.Repeat("a", 40) + `"}`
|
||||
req, _ := http.NewRequest(http.MethodPost, ts.URL+"/v1/firewall/enroll", strings.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("X-EvoBGP-Seed", "deadbeef")
|
||||
resp, err := ts.Client().Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
if resp.StatusCode != http.StatusForbidden {
|
||||
t.Fatalf("want 403 got %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFirewallTokenHashMatchesAuthkey(t *testing.T) {
|
||||
tok := "evobgp_fw_sample"
|
||||
h := authkey.HashToken(tok)
|
||||
if len(h) != 32 {
|
||||
t.Fatalf("hash len %d", len(h))
|
||||
}
|
||||
}
|
||||
@@ -29,6 +29,8 @@ type Server struct {
|
||||
jobs *jobs.Registry
|
||||
bundlePriv ed25519.PrivateKey
|
||||
keyResolver *apiKeyResolver
|
||||
firewallResolver *firewallTokenResolver
|
||||
bundleSeedHex string
|
||||
corsOrigins []string
|
||||
cdnHTTP *http.Client
|
||||
runtimeLogs *runtimelogs.Service
|
||||
@@ -74,6 +76,10 @@ func New(opts Options) (*Server, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
fwResolver, err := newFirewallTokenResolver(backend)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var pgMon *pgmonitor.Service
|
||||
var maintCfg *maintenance.ConfigProvider
|
||||
var maintStats *maintenance.DBStatsProvider
|
||||
@@ -92,6 +98,8 @@ func New(opts Options) (*Server, error) {
|
||||
jobs: reg,
|
||||
bundlePriv: priv,
|
||||
keyResolver: resolver,
|
||||
firewallResolver: fwResolver,
|
||||
bundleSeedHex: strings.TrimSpace(opts.BundleSeedHex),
|
||||
corsOrigins: parseCORSOrigins(opts.CORSAllowedOrigins),
|
||||
cdnHTTP: NewCDNHTTPClient(),
|
||||
runtimeLogs: runtimelogs.NewService(runtimelogs.ConfigFromEnv()),
|
||||
|
||||
Reference in New Issue
Block a user