feat(httpapi): remove firewall HTTP/UI after EvoFirewall cutover
CI / changes (push) Successful in 4s
CI / commitlint (push) Skipped
CI / openapi (push) Skipped
CI / web (push) Successful in 51s
CI / go (push) Successful in 59s
CI / bird2 (push) Successful in 15s
CI / release (push) Successful in 4m11s

Все /v1/firewall/* отвечают 410 Gone; UI и docs указывают на EvoFirewall.

Co-authored-by: Cursor <[email protected]>
This commit is contained in:
Denozordec
2026-07-20 19:51:08 +07:00
co-authored by Cursor
parent b28ad88b22
commit c1132cbe19
13 changed files with 58 additions and 1989 deletions
-118
View File
@@ -1,118 +0,0 @@
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
}
+3 -4
View File
@@ -36,9 +36,9 @@ func (s *Server) Handler() http.Handler {
s.mux.HandleFunc("GET /v1/ready", s.handleReady)
s.mux.HandleFunc("GET /v1/version", s.handleVersion)
s.mux.HandleFunc("GET /v1/auth/config", s.handleAuthConfigPublic)
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)
// Firewall subsystem moved to the standalone EvoFirewall service; see docs/firewall.md.
// Registered on the public mux so it wins over the "/v1/" subtree below regardless of auth.
s.mux.HandleFunc("/v1/firewall/", s.handleFirewallGone)
s.mux.Handle("/v1/", s.authMiddleware(wrappedV1))
return s.withCORS(observability.HTTPMiddleware(s.mux))
}
@@ -87,7 +87,6 @@ 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) {
+12 -705
View File
@@ -1,709 +1,16 @@
package httpapi
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"io"
"net/http"
"os"
"path/filepath"
"sort"
"strings"
"time"
import "net/http"
"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.requirePerm(w, a, "bgp:firewall:write") {
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.requirePerm(w, a, "bgp:firewall:read") {
return
}
items, err := s.store.ListFirewallClients(a.TenantID)
if err != nil {
writeInternalError(w, "internal", err)
return
}
items = store.FilterOwned(items, func(c *store.FirewallClient) string { return c.CreatedByUserID }, a.Kind, a.IsAdmin, a.UserID)
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.requirePerm(w, a, "bgp:firewall:read") {
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
}
if !store.CanAccessOwned(a.Kind, a.IsAdmin, a.UserID, client.CreatedByUserID) {
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.requirePerm(w, a, "bgp:firewall:write") {
return
}
id := strings.TrimSpace(r.PathValue("id"))
if existing, gerr := s.store.GetFirewallClient(a.TenantID, id); gerr == nil {
if !store.CanAccessOwned(a.Kind, a.IsAdmin, a.UserID, existing.CreatedByUserID) {
writeProblem(w, http.StatusNotFound, "Not Found", "client not found")
return
}
}
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.requirePerm(w, a, "bgp:firewall:write") {
return
}
id := strings.TrimSpace(r.PathValue("id"))
if existing, gerr := s.store.GetFirewallClient(a.TenantID, id); gerr == nil {
if !store.CanAccessOwned(a.Kind, a.IsAdmin, a.UserID, existing.CreatedByUserID) {
writeProblem(w, http.StatusNotFound, "Not Found", "client not found")
return
}
}
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.requirePerm(w, a, "bgp:firewall:write") {
return
}
id := strings.TrimSpace(r.PathValue("id"))
if existing, gerr := s.store.GetFirewallClient(a.TenantID, id); gerr == nil {
if !store.CanAccessOwned(a.Kind, a.IsAdmin, a.UserID, existing.CreatedByUserID) {
writeProblem(w, http.StatusNotFound, "Not Found", "client not found")
return
}
}
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.requirePerm(w, a, "bgp:firewall:write") {
return
}
id := strings.TrimSpace(r.PathValue("id"))
if existing, gerr := s.store.GetFirewallClient(a.TenantID, id); gerr == nil {
if !store.CanAccessOwned(a.Kind, a.IsAdmin, a.UserID, existing.CreatedByUserID) {
writeProblem(w, http.StatusNotFound, "Not Found", "client not found")
return
}
}
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.requirePerm(w, a, "bgp:firewall:read") {
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
}
items = store.FilterOwned(items, func(rule *store.FirewallRule) string { return rule.CreatedByUserID }, a.Kind, a.IsAdmin, a.UserID)
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.requirePerm(w, a, "bgp:firewall:write") {
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
}
fwRule := &store.FirewallRuleCreate{
Priority: body.Priority,
Action: body.Action,
CommunityID: body.CommunityID,
Comment: body.Comment,
}
if a.Kind == AuthKindJWT && strings.TrimSpace(a.UserID) != "" {
fwRule.CreatedByUserID = a.UserID
}
rule, err := s.store.CreateFirewallRule(a.TenantID, clientID, fwRule)
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.requirePerm(w, a, "bgp:firewall:write") {
return
}
id := strings.TrimSpace(r.PathValue("id"))
if existing, gerr := s.store.GetFirewallRule(a.TenantID, id); gerr == nil {
if !store.CanAccessOwned(a.Kind, a.IsAdmin, a.UserID, existing.CreatedByUserID) {
writeProblem(w, http.StatusNotFound, "Not Found", "rule not found")
return
}
}
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.requirePerm(w, a, "bgp:firewall:write") {
return
}
id := strings.TrimSpace(r.PathValue("id"))
if existing, gerr := s.store.GetFirewallRule(a.TenantID, id); gerr == nil {
if !store.CanAccessOwned(a.Kind, a.IsAdmin, a.UserID, existing.CreatedByUserID) {
writeProblem(w, http.StatusNotFound, "Not Found", "rule not found")
return
}
}
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.requirePerm(w, a, "bgp:firewall:write") {
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.requirePerm(w, a, "bgp:firewall:read") {
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
// handleFirewallGone responds 410 Gone for every legacy /v1/firewall/* endpoint
// (enroll, install.sh, sync-script, clients, rules, blocklist, apply-report,
// heartbeat, install-context). The firewall subsystem (client enrollment,
// block/accept rules, blocklist distribution) has moved to the standalone
// EvoFirewall service; see docs/firewall.md.
//
// Existing firewall_client / firewall_rule tables and store code (memory_firewall.go,
// postgres_firewall.go, firewall_types.go) are intentionally left in place — only the
// HTTP surface is decommissioned here.
func (s *Server) handleFirewallGone(w http.ResponseWriter, r *http.Request) {
writeProblem(w, http.StatusGone, "Gone", "firewall feature moved to EvoFirewall — see docs/firewall.md")
}
+32 -278
View File
@@ -1,19 +1,15 @@
package httpapi
import (
"encoding/json"
"errors"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"evobgp/internal/authkey"
"evobgp/internal/store"
)
func TestFirewallEnrollAndBlocklist(t *testing.T) {
// TestFirewallRoutesGone verifies the firewall subsystem HTTP surface has been
// decommissioned in favor of the standalone EvoFirewall service (see docs/firewall.md).
// Every legacy /v1/firewall/* path — public and authenticated — must answer 410 Gone.
func TestFirewallRoutesGone(t *testing.T) {
srv, err := New(Options{SeedDemo: true, BundleSeedHex: testBundleSeed})
if err != nil {
t.Fatal(err)
@@ -26,279 +22,37 @@ func TestFirewallEnrollAndBlocklist(t *testing.T) {
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)
cases := []struct {
method string
path string
auth bool // send a valid operator bearer token
}{
{http.MethodPost, "/v1/firewall/enroll", false},
{http.MethodGet, "/v1/firewall/install.sh", false},
{http.MethodGet, "/v1/firewall/sync-script", false},
{http.MethodGet, "/v1/firewall/install-context", true},
{http.MethodGet, "/v1/firewall/clients", true},
{http.MethodGet, "/v1/firewall/clients/any-id", true},
{http.MethodGet, "/v1/firewall/rules", true},
{http.MethodGet, "/v1/firewall/blocklist", false},
{http.MethodPost, "/v1/firewall/apply-report", false},
{http.MethodPost, "/v1/firewall/heartbeat", false},
}
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)
}
reportBody := `{"status":"ok","prefix_count":0,"ip_count":0,"packets_dropped":42,"packets_accepted":1000,"source":"cp","kernel_method":"nft"}`
reqReport, _ := http.NewRequest(http.MethodPost, ts.URL+"/v1/firewall/apply-report", strings.NewReader(reportBody))
reqReport.Header.Set("Authorization", "Bearer "+tok)
reqReport.Header.Set("Content-Type", "application/json")
respReport, err := client.Do(reqReport)
if err != nil {
t.Fatal(err)
}
defer func() { _ = respReport.Body.Close() }()
if respReport.StatusCode != http.StatusOK {
b, _ := io.ReadAll(respReport.Body)
t.Fatalf("apply-report status=%d body=%s", respReport.StatusCode, b)
}
gotClient, err := srv.Store().GetFirewallClient(tenant, clientID)
if err != nil {
t.Fatal(err)
}
if gotClient.LastApplyPacketsDropped != 42 || gotClient.LastApplyPacketsAccepted != 1000 {
t.Fatalf("packet stats dropped=%d accepted=%d", gotClient.LastApplyPacketsDropped, gotClient.LastApplyPacketsAccepted)
}
}
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 TestFirewallInstallScriptPublic(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()
for _, path := range []string{"/v1/firewall/install.sh", "/v1/firewall/sync-script"} {
req, _ := http.NewRequest(http.MethodGet, ts.URL+path, nil)
resp, err := ts.Client().Do(req)
for _, tc := range cases {
req, err := http.NewRequest(tc.method, ts.URL+tc.path, nil)
if err != nil {
t.Fatal(err)
}
func() {
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
b, _ := io.ReadAll(resp.Body)
t.Fatalf("%s status=%d body=%s", path, resp.StatusCode, b)
}
ct := resp.Header.Get("Content-Type")
if !strings.Contains(ct, "shellscript") {
t.Fatalf("%s content-type=%q", path, ct)
}
b, _ := io.ReadAll(resp.Body)
if !strings.HasPrefix(string(b), "#!/") {
t.Fatalf("%s missing shebang", path)
}
}()
}
}
func TestFirewallInstallContext(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,vwkey|"+tenant+"|viewer")
ts := httptest.NewServer(srv.Handler())
defer ts.Close()
client := ts.Client()
reqOp, _ := http.NewRequest(http.MethodGet, ts.URL+"/v1/firewall/install-context", nil)
reqOp.Header.Set("Authorization", "Bearer opkey")
respOp, err := client.Do(reqOp)
if err != nil {
t.Fatal(err)
}
defer func() { _ = respOp.Body.Close() }()
if respOp.StatusCode != http.StatusOK {
b, _ := io.ReadAll(respOp.Body)
t.Fatalf("operator install-context status=%d body=%s", respOp.StatusCode, b)
}
var ctx map[string]any
if err := json.NewDecoder(respOp.Body).Decode(&ctx); err != nil {
t.Fatal(err)
}
if seed, _ := ctx["bundle_seed"].(string); seed != testBundleSeed {
t.Fatalf("bundle_seed=%q want %q", seed, testBundleSeed)
}
if configured, _ := ctx["bundle_seed_configured"].(bool); !configured {
t.Fatal("bundle_seed_configured want true")
}
if url, _ := ctx["suggested_cp_url"].(string); !strings.HasPrefix(url, "https://") {
t.Fatalf("suggested_cp_url=%q want https", url)
}
if url, _ := ctx["install_sh_url"].(string); !strings.HasPrefix(url, "https://") || !strings.HasSuffix(url, "/v1/firewall/install.sh") {
t.Fatalf("install_sh_url=%q", url)
}
reqVw, _ := http.NewRequest(http.MethodGet, ts.URL+"/v1/firewall/install-context", nil)
reqVw.Header.Set("Authorization", "Bearer vwkey")
respVw, err := client.Do(reqVw)
if err != nil {
t.Fatal(err)
}
defer func() { _ = respVw.Body.Close() }()
if respVw.StatusCode != http.StatusForbidden {
t.Fatalf("viewer install-context want 403 got %d", respVw.StatusCode)
}
}
func TestFirewallDeletePendingClient(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_revoketest123456789012345678901"
enrollBody := `{"name":"reject-me","hostname":"test.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")
}
reqDelete, _ := http.NewRequest(http.MethodDelete, ts.URL+"/v1/firewall/clients/"+clientID, nil)
reqDelete.Header.Set("Authorization", "Bearer opkey")
respDelete, err := client.Do(reqDelete)
if err != nil {
t.Fatal(err)
}
defer func() { _ = respDelete.Body.Close() }()
if respDelete.StatusCode != http.StatusNoContent {
b, _ := io.ReadAll(respDelete.Body)
t.Fatalf("delete status=%d body=%s", respDelete.StatusCode, b)
}
_, err = srv.Store().GetFirewallClient(tenant, clientID)
if err == nil {
t.Fatal("client should be deleted")
}
if !errors.Is(err, store.ErrNotFound) {
t.Fatalf("delete err=%v", err)
}
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.StatusUnauthorized {
t.Fatalf("deleted blocklist want 401 got %d", respBlock.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))
if tc.auth {
req.Header.Set("Authorization", "Bearer opkey")
}
resp, err := client.Do(req)
if err != nil {
t.Fatal(err)
}
_ = resp.Body.Close()
if resp.StatusCode != http.StatusGone {
t.Fatalf("%s %s: want 410 got %d", tc.method, tc.path, resp.StatusCode)
}
}
}