feat(httpapi): remove firewall HTTP/UI after EvoFirewall cutover
Все /v1/firewall/* отвечают 410 Gone; UI и docs указывают на EvoFirewall. Co-authored-by: Cursor <[email protected]>
This commit is contained in:
@@ -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
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user