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

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:
Denozordec
2026-07-08 16:37:27 +07:00
parent 276194a9d0
commit 7a3eae98b1
36 changed files with 4581 additions and 174 deletions
+256
View File
@@ -0,0 +1,256 @@
package agentserver
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"io"
"log"
"net/http"
"os"
"path/filepath"
"sort"
"strings"
"sync"
"time"
"evobgp/internal/authkey"
"evobgp/internal/firewall"
)
type firewallReplicatePayload struct {
TenantID string `json:"tenant_id"`
RevisionID string `json:"revision_id"`
GeneratedAt string `json:"generated_at"`
UpdatedAt string `json:"updated_at"`
ClientsByHash map[string]clientMeta `json:"clients_by_hash"`
Rules []firewallRuleJSON `json:"rules"`
PrefixesByCommunity map[string][]string `json:"prefixes_by_community"`
}
type clientMeta struct {
ClientID string `json:"client_id"`
Name string `json:"name"`
}
type firewallRuleJSON struct {
ClientID *string `json:"client_id"`
Priority int `json:"priority"`
Action string `json:"action"`
CommunityID *string `json:"community_id"`
}
type firewallAgent struct {
mu sync.RWMutex
stateFile string
cpURL string
enabled bool
state firewallReplicatePayload
}
func newFirewallAgent(cfg Config) *firewallAgent {
enabled := strings.TrimSpace(os.Getenv("EVOBGP_FIREWALL_FAILOVER_ENABLED")) == "1"
stateFile := envOr("EVOBGP_FIREWALL_STATE_FILE", "/var/lib/evobgp-agent/firewall-state.json")
fa := &firewallAgent{
stateFile: stateFile,
cpURL: strings.TrimSpace(cfg.ControlPlaneURL),
enabled: enabled,
}
if enabled {
_ = fa.load()
}
return fa
}
func (s *Server) handleFirewallReplicate(w http.ResponseWriter, r *http.Request) {
if !s.authorize(r) {
writeProblem(w, http.StatusUnauthorized, "missing or invalid Authorization")
return
}
var raw struct {
TenantID string `json:"tenant_id"`
RevisionID string `json:"revision_id"`
GeneratedAt string `json:"generated_at"`
Clients []struct {
TokenHashHex string `json:"token_hash_hex"`
ClientID string `json:"client_id"`
Name string `json:"name"`
} `json:"clients"`
Rules []firewallRuleJSON `json:"rules"`
PrefixesByCommunity map[string][]string `json:"prefixes_by_community"`
}
if err := json.NewDecoder(r.Body).Decode(&raw); err != nil {
writeProblem(w, http.StatusBadRequest, "invalid JSON body")
return
}
byHash := make(map[string]clientMeta, len(raw.Clients))
for _, c := range raw.Clients {
byHash[strings.ToLower(c.TokenHashHex)] = clientMeta{ClientID: c.ClientID, Name: c.Name}
}
payload := firewallReplicatePayload{
TenantID: raw.TenantID,
RevisionID: raw.RevisionID,
GeneratedAt: raw.GeneratedAt,
UpdatedAt: time.Now().UTC().Format(time.RFC3339),
ClientsByHash: byHash,
Rules: raw.Rules,
PrefixesByCommunity: raw.PrefixesByCommunity,
}
s.firewall.mu.Lock()
s.firewall.state = payload
s.firewall.mu.Unlock()
if err := s.firewall.save(); err != nil {
log.Printf("agentserver: firewall state save: %v", err)
}
writeJSON(w, http.StatusOK, map[string]any{
"ok": true,
"clients_count": len(byHash),
"rules_count": len(raw.Rules),
"prefix_count": len(raw.PrefixesByCommunity),
})
}
func (fa *firewallAgent) handleBlocklist(w http.ResponseWriter, r *http.Request) {
token := bearerToken(r)
if token == "" {
writeProblem(w, http.StatusUnauthorized, "missing bearer token")
return
}
hash := hex.EncodeToString(authkey.HashToken(token))
fa.mu.RLock()
st := fa.state
meta, ok := st.ClientsByHash[hash]
fa.mu.RUnlock()
if !ok {
writeProblem(w, http.StatusUnauthorized, "unknown firewall client")
return
}
rules := make([]firewall.Rule, 0, len(st.Rules))
for _, r := range st.Rules {
rules = append(rules, firewall.Rule{
ClientID: r.ClientID,
Priority: r.Priority,
Action: r.Action,
CommunityID: r.CommunityID,
})
}
prefixes := firewall.Evaluate(meta.ClientID, rules, st.PrefixesByCommunity)
updatedAt, _ := time.Parse(time.RFC3339, st.UpdatedAt)
age := int(time.Since(updatedAt).Seconds())
if updatedAt.IsZero() {
age = 0
}
w.Header().Set("X-EvoBGP-Source", "speaker")
w.Header().Set("X-EvoBGP-Revision-ID", st.RevisionID)
w.Header().Set("X-EvoBGP-Generated-At", st.GeneratedAt)
w.Header().Set("X-EvoBGP-Rules-Version", firewall.RulesVersionHash(rules))
if age > 3600 {
w.Header().Set("X-EvoBGP-Stale", "true")
}
if age > 0 {
w.Header().Set("Age", strconvItoa(age))
}
resp := map[string]any{
"client_id": meta.ClientID,
"revision_id": st.RevisionID,
"generated_at": st.GeneratedAt,
"source": "speaker",
"rules_applied": len(rules),
"communities_evaluated": len(st.PrefixesByCommunity),
"prefixes": prefixes,
"total": len(prefixes),
"hash": prefixHash(prefixes),
}
if strings.Contains(r.Header.Get("Accept"), "text/plain") {
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
for _, p := range prefixes {
_, _ = w.Write([]byte(p + "\n"))
}
return
}
writeJSON(w, http.StatusOK, resp)
}
func (fa *firewallAgent) handleApplyReportForward(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
forwarded := false
if fa.cpURL != "" {
req, err := http.NewRequestWithContext(r.Context(), http.MethodPost, strings.TrimSuffix(fa.cpURL, "/")+"/v1/firewall/apply-report", strings.NewReader(string(body)))
if err == nil {
req.Header.Set("Authorization", r.Header.Get("Authorization"))
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
if err == nil {
forwarded = resp.StatusCode >= 200 && resp.StatusCode < 300
_ = resp.Body.Close()
}
}
}
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "forwarded": forwarded})
}
func (fa *firewallAgent) load() error {
b, err := os.ReadFile(fa.stateFile)
if err != nil {
if os.IsNotExist(err) {
return nil
}
return err
}
var st firewallReplicatePayload
if err := json.Unmarshal(b, &st); err != nil {
return err
}
fa.mu.Lock()
fa.state = st
fa.mu.Unlock()
return nil
}
func (fa *firewallAgent) save() error {
fa.mu.RLock()
b, err := json.MarshalIndent(fa.state, "", " ")
fa.mu.RUnlock()
if err != nil {
return err
}
dir := filepath.Dir(fa.stateFile)
if err := os.MkdirAll(dir, 0o755); err != nil {
return err
}
tmp := fa.stateFile + ".tmp"
if err := os.WriteFile(tmp, b, 0o600); err != nil {
return err
}
return os.Rename(tmp, fa.stateFile)
}
func bearerToken(r *http.Request) string {
h := r.Header.Get("Authorization")
const p = "Bearer "
if !strings.HasPrefix(h, p) {
return ""
}
return strings.TrimSpace(h[len(p):])
}
func prefixHash(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 strconvItoa(n int) string {
if n == 0 {
return "0"
}
var b [20]byte
i := len(b)
for n > 0 {
i--
b[i] = byte('0' + n%10)
n /= 10
}
return string(b[i:])
}
+9 -3
View File
@@ -36,16 +36,22 @@ type Config struct {
// Server serves Panel→Node internal API (Remnawave-style wake-up).
type Server struct {
cfg Config
mux *http.ServeMux
cfg Config
mux *http.ServeMux
firewall *firewallAgent
}
// New builds an agent HTTP server.
func New(cfg Config) *Server {
s := &Server{cfg: cfg, mux: http.NewServeMux()}
s := &Server{cfg: cfg, mux: http.NewServeMux(), firewall: newFirewallAgent(cfg)}
s.mux.HandleFunc("GET /v1/agent/health", s.handleHealth)
s.mux.HandleFunc("GET /v1/agent/bird/protocols", s.handleBirdProtocols)
s.mux.HandleFunc("POST /v1/agent/sync", s.handleSync)
if s.firewall.enabled {
s.mux.HandleFunc("GET /v1/firewall/blocklist", s.firewall.handleBlocklist)
s.mux.HandleFunc("POST /v1/firewall/apply-report", s.firewall.handleApplyReportForward)
s.mux.HandleFunc("POST /v1/agent/firewall-replicate", s.handleFirewallReplicate)
}
return s
}
+127
View File
@@ -0,0 +1,127 @@
// Package firewall evaluates block/accept policy rules into CIDR blocklists.
package firewall
import (
"crypto/sha256"
"encoding/hex"
"sort"
"strconv"
"strings"
)
// Rule is one ordered firewall policy rule for evaluation.
type Rule struct {
ClientID *string
Priority int
Action string // "block" | "accept"
CommunityID *string
}
// Evaluate returns a deduplicated flat CIDR list to block in the kernel.
// communityPrefixes maps community ID to prefixes; key "" holds prefixes without community.
// Default when no rule matches: accept (do not block).
func Evaluate(clientID string, rules []Rule, communityPrefixes map[string][]string) []string {
ordered := mergeRules(clientID, rules)
if len(communityPrefixes) == 0 {
return nil
}
keys := make([]string, 0, len(communityPrefixes))
for k := range communityPrefixes {
keys = append(keys, k)
}
sort.Strings(keys)
var out []string
seen := make(map[string]struct{})
for _, commKey := range keys {
if !shouldBlockCommunity(commKey, ordered) {
continue
}
for _, p := range communityPrefixes[commKey] {
p = strings.TrimSpace(p)
if p == "" {
continue
}
if _, ok := seen[p]; ok {
continue
}
seen[p] = struct{}{}
out = append(out, p)
}
}
return out
}
func mergeRules(clientID string, rules []Rule) []Rule {
var clientRules, tenantRules []Rule
for _, r := range rules {
if r.ClientID != nil && strings.TrimSpace(*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 := make([]Rule, 0, len(clientRules)+len(tenantRules))
out = append(out, clientRules...)
out = append(out, tenantRules...)
return out
}
func shouldBlockCommunity(communityKey string, ordered []Rule) bool {
for _, r := range ordered {
if r.CommunityID == nil || strings.TrimSpace(*r.CommunityID) == communityKey {
return strings.EqualFold(strings.TrimSpace(r.Action), "block")
}
}
return false
}
// RulesVersionHash returns a stable fingerprint of rules for cache headers.
func RulesVersionHash(rules []Rule) string {
if len(rules) == 0 {
return "sha256:empty"
}
cp := append([]Rule(nil), rules...)
sort.Slice(cp, func(i, j int) bool {
a, b := cp[i], cp[j]
ac, bc := "", ""
if a.ClientID != nil {
ac = *a.ClientID
}
if b.ClientID != nil {
bc = *b.ClientID
}
if ac != bc {
return ac < bc
}
if a.Priority != b.Priority {
return a.Priority < b.Priority
}
return a.Action < b.Action
})
var b strings.Builder
for _, r := range cp {
cid := "*"
if r.CommunityID != nil {
cid = *r.CommunityID
}
cl := "tenant"
if r.ClientID != nil {
cl = *r.ClientID
}
b.WriteString(cl)
b.WriteByte('|')
b.WriteString(r.Action)
b.WriteByte('|')
b.WriteString(cid)
b.WriteByte('|')
b.WriteString(strconv.Itoa(r.Priority))
b.WriteByte(';')
}
sum := sha256.Sum256([]byte(b.String()))
return "sha256:" + hex.EncodeToString(sum[:])
}
+75
View File
@@ -0,0 +1,75 @@
package firewall
import (
"testing"
)
func TestEvaluate_emptyRules(t *testing.T) {
prefixes := map[string][]string{"c1": {"1.2.3.0/24"}}
got := Evaluate("client1", nil, prefixes)
if len(got) != 0 {
t.Fatalf("expected empty blocklist, got %v", got)
}
}
func TestEvaluate_onlyAccept(t *testing.T) {
wild := (*string)(nil)
rules := []Rule{{Priority: 1, Action: "accept", CommunityID: wild}}
prefixes := map[string][]string{"c1": {"1.2.3.0/24"}}
got := Evaluate("client1", rules, prefixes)
if len(got) != 0 {
t.Fatalf("accept alone must not block, got %v", got)
}
}
func TestEvaluate_explicitBlock(t *testing.T) {
cid := "c1"
rules := []Rule{{Priority: 1, Action: "block", CommunityID: &cid}}
prefixes := map[string][]string{"c1": {"1.2.3.0/24", "5.6.7.8/32"}, "c2": {"9.9.9.9/32"}}
got := Evaluate("client1", rules, prefixes)
if len(got) != 2 {
t.Fatalf("expected 2 prefixes, got %v", got)
}
}
func TestEvaluate_clientOverrideAccept(t *testing.T) {
wild := (*string)(nil)
cTrusted := "trusted"
clientID := "srv1"
rules := []Rule{
{Priority: 1, Action: "block", CommunityID: wild},
{ClientID: &clientID, Priority: 1, Action: "accept", CommunityID: &cTrusted},
}
prefixes := map[string][]string{
"trusted": {"1.1.1.0/24"},
"bad": {"2.2.2.0/24"},
}
got := Evaluate(clientID, rules, prefixes)
if len(got) != 1 || got[0] != "2.2.2.0/24" {
t.Fatalf("expected only bad community, got %v", got)
}
}
func TestEvaluate_clientOverrideWins(t *testing.T) {
cid := "c1"
clientID := "srv1"
rules := []Rule{
{Priority: 1, Action: "accept", CommunityID: &cid},
{ClientID: &clientID, Priority: 1, Action: "block", CommunityID: &cid},
}
prefixes := map[string][]string{"c1": {"1.2.3.0/24"}}
got := Evaluate(clientID, rules, prefixes)
if len(got) != 1 {
t.Fatalf("client override should block, got %v", got)
}
}
func TestEvaluate_noCommunityKey(t *testing.T) {
empty := ""
rules := []Rule{{Priority: 1, Action: "block", CommunityID: &empty}}
prefixes := map[string][]string{"": {"10.0.0.0/8"}}
got := Evaluate("c", rules, prefixes)
if len(got) != 1 {
t.Fatalf("expected prefix without community, got %v", got)
}
}
+18
View File
@@ -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")
+118
View File
@@ -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
}
+57
View File
@@ -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
}
+4
View File
@@ -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) {
+609
View File
@@ -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
}
+128
View File
@@ -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))
}
}
+8
View File
@@ -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()),
+494
View File
@@ -0,0 +1,494 @@
package repository
import (
"context"
"encoding/hex"
"errors"
"strings"
"time"
"evobgp/internal/store"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
)
func (p *Postgres) ListFirewallClients(tenantID string) ([]*store.FirewallClient, error) {
ctx := context.Background()
rows, err := p.pool.Query(ctx, `
SELECT id, name, hostname, token_prefix, status,
last_seen_at, last_seen_at_source, last_seen_ip,
last_apply_at, last_apply_status, last_apply_error,
last_apply_prefix_count, last_apply_ip_count, last_apply_source,
client_version, created_at, approved_at, approved_by_api_key_id, revoked_at
FROM firewall_client WHERE tenant_id=$1 ORDER BY created_at DESC`, tenantID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []*store.FirewallClient
for rows.Next() {
c, err := scanFirewallClientRow(rows.Scan, tenantID)
if err != nil {
return nil, err
}
out = append(out, c)
}
return out, rows.Err()
}
func (p *Postgres) GetFirewallClient(tenantID, id string) (*store.FirewallClient, error) {
ctx := context.Background()
row := p.pool.QueryRow(ctx, `
SELECT id, name, hostname, token_prefix, status,
last_seen_at, last_seen_at_source, last_seen_ip,
last_apply_at, last_apply_status, last_apply_error,
last_apply_prefix_count, last_apply_ip_count, last_apply_source,
client_version, created_at, approved_at, approved_by_api_key_id, revoked_at
FROM firewall_client WHERE id=$1 AND tenant_id=$2`, id, tenantID)
c, err := scanFirewallClientRow(row.Scan, tenantID)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, store.ErrNotFound
}
return nil, err
}
return c, nil
}
func (p *Postgres) CreateFirewallClient(tenantID string, in *store.FirewallClientCreate) (*store.FirewallClient, error) {
if in == nil || strings.TrimSpace(in.Name) == "" || len(in.TokenHash) != 32 {
return nil, store.ErrInvalidInput
}
id := uuid.NewString()
ctx := context.Background()
_, err := p.pool.Exec(ctx, `
INSERT INTO firewall_client (id, tenant_id, name, hostname, token_prefix, token_hash, client_version)
VALUES ($1,$2,$3,$4,$5,$6,$7)`,
id, tenantID, strings.TrimSpace(in.Name), strings.TrimSpace(in.Hostname),
in.TokenPrefix, in.TokenHash, strings.TrimSpace(in.ClientVersion))
if err != nil {
return nil, err
}
return p.GetFirewallClient(tenantID, id)
}
func (p *Postgres) UpdateFirewallClient(tenantID, id string, patch *store.FirewallClientPatch) (*store.FirewallClient, error) {
cur, err := p.GetFirewallClient(tenantID, id)
if err != nil {
return nil, err
}
if patch == nil {
return nil, store.ErrInvalidInput
}
name := cur.Name
hostname := cur.Hostname
if patch.Name != nil {
name = strings.TrimSpace(*patch.Name)
if name == "" {
return nil, store.ErrInvalidInput
}
}
if patch.Hostname != nil {
hostname = strings.TrimSpace(*patch.Hostname)
}
ctx := context.Background()
_, err = p.pool.Exec(ctx, `UPDATE firewall_client SET name=$3, hostname=$4 WHERE id=$1 AND tenant_id=$2`,
id, tenantID, name, hostname)
if err != nil {
return nil, err
}
return p.GetFirewallClient(tenantID, id)
}
func (p *Postgres) ApproveFirewallClient(tenantID, id, approverAPIKeyID string) (*store.FirewallClient, error) {
ctx := context.Background()
tag, err := p.pool.Exec(ctx, `
UPDATE firewall_client
SET status='approved', approved_at=now(), approved_by_api_key_id=$3, revoked_at=NULL
WHERE id=$1 AND tenant_id=$2 AND status != 'revoked'`, id, tenantID, nullIfEmpty(approverAPIKeyID))
if err != nil {
return nil, err
}
if tag.RowsAffected() == 0 {
return nil, store.ErrNotFound
}
return p.GetFirewallClient(tenantID, id)
}
func (p *Postgres) RevokeFirewallClient(tenantID, id string) error {
ctx := context.Background()
tag, err := p.pool.Exec(ctx, `
UPDATE firewall_client SET status='revoked', revoked_at=now() WHERE id=$1 AND tenant_id=$2`, id, tenantID)
if err != nil {
return err
}
if tag.RowsAffected() == 0 {
return store.ErrNotFound
}
return nil
}
func (p *Postgres) DeleteFirewallClient(tenantID, id string) error {
ctx := context.Background()
tag, err := p.pool.Exec(ctx, `DELETE FROM firewall_client WHERE id=$1 AND tenant_id=$2`, id, tenantID)
if err != nil {
return err
}
if tag.RowsAffected() == 0 {
return store.ErrNotFound
}
return nil
}
func (p *Postgres) LookupFirewallClientByTokenHash(hash []byte) (*store.FirewallClient, error) {
if len(hash) != 32 {
return nil, store.ErrNotFound
}
ctx := context.Background()
row := p.pool.QueryRow(ctx, `
SELECT tenant_id, id, name, hostname, token_prefix, status,
last_seen_at, last_seen_at_source, last_seen_ip,
last_apply_at, last_apply_status, last_apply_error,
last_apply_prefix_count, last_apply_ip_count, last_apply_source,
client_version, created_at, approved_at, approved_by_api_key_id, revoked_at
FROM firewall_client WHERE token_hash=$1`, hash)
c, err := scanFirewallClientLookupRow(row.Scan)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, store.ErrNotFound
}
return nil, err
}
return c, nil
}
func (p *Postgres) TouchFirewallClientLastSeen(id, source, clientIP, clientVersion string) error {
ctx := context.Background()
_, err := p.pool.Exec(ctx, `
UPDATE firewall_client SET last_seen_at=now(), last_seen_at_source=$2, last_seen_ip=$3,
client_version=COALESCE(NULLIF($4,''), client_version)
WHERE id=$1`, id, strings.TrimSpace(source), strings.TrimSpace(clientIP), strings.TrimSpace(clientVersion))
return err
}
func (p *Postgres) TouchFirewallClientLastApply(id, source, status, errMsg string, prefixCount, ipCount int) error {
ctx := context.Background()
_, err := p.pool.Exec(ctx, `
UPDATE firewall_client SET last_apply_at=now(), last_apply_source=$2, last_apply_status=$3,
last_apply_error=$4, last_apply_prefix_count=$5, last_apply_ip_count=$6
WHERE id=$1`, id, strings.TrimSpace(source), strings.TrimSpace(status), strings.TrimSpace(errMsg), prefixCount, ipCount)
return err
}
func (p *Postgres) ListActiveFirewallClientHashes() ([]store.FirewallClientAuthRow, error) {
ctx := context.Background()
rows, err := p.pool.Query(ctx, `
SELECT id, tenant_id, token_hash FROM firewall_client WHERE status='approved'`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []store.FirewallClientAuthRow
for rows.Next() {
var row store.FirewallClientAuthRow
if err := rows.Scan(&row.ID, &row.TenantID, &row.TokenHash); err != nil {
return nil, err
}
if len(row.TokenHash) != 32 {
continue
}
out = append(out, row)
}
return out, rows.Err()
}
func (p *Postgres) ListApprovedFirewallClientsForReplication(tenantID string) ([]store.FirewallClientReplicationRow, error) {
ctx := context.Background()
rows, err := p.pool.Query(ctx, `
SELECT id, name, token_hash FROM firewall_client
WHERE tenant_id=$1 AND status='approved' ORDER BY id`, tenantID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []store.FirewallClientReplicationRow
for rows.Next() {
var id, name string
var hash []byte
if err := rows.Scan(&id, &name, &hash); err != nil {
return nil, err
}
out = append(out, store.FirewallClientReplicationRow{
ClientID: id,
Name: name,
TokenHashHex: hex.EncodeToString(hash),
})
}
return out, rows.Err()
}
func (p *Postgres) ListFirewallRules(tenantID string, clientID *string) ([]*store.FirewallRule, error) {
ctx := context.Background()
var rows pgx.Rows
var err error
if clientID == nil {
rows, err = p.pool.Query(ctx, `
SELECT id, client_id, priority, action, community_id, comment, created_at, updated_at
FROM firewall_rule WHERE tenant_id=$1 AND client_id IS NULL ORDER BY priority`, tenantID)
} else {
rows, err = p.pool.Query(ctx, `
SELECT id, client_id, priority, action, community_id, comment, created_at, updated_at
FROM firewall_rule WHERE tenant_id=$1 AND client_id=$2 ORDER BY priority`, tenantID, *clientID)
}
if err != nil {
return nil, err
}
defer rows.Close()
return scanFirewallRules(rows, tenantID)
}
func (p *Postgres) ListAllFirewallRulesForClient(tenantID, clientID string) ([]*store.FirewallRule, error) {
ctx := context.Background()
rows, err := p.pool.Query(ctx, `
SELECT id, client_id, priority, action, community_id, comment, created_at, updated_at
FROM firewall_rule
WHERE tenant_id=$1 AND (client_id IS NULL OR client_id=$2)
ORDER BY CASE WHEN client_id IS NULL THEN 1 ELSE 0 END, priority`, tenantID, clientID)
if err != nil {
return nil, err
}
defer rows.Close()
return scanFirewallRules(rows, tenantID)
}
func (p *Postgres) ListAllFirewallRulesForReplication(tenantID string) ([]*store.FirewallRule, error) {
ctx := context.Background()
rows, err := p.pool.Query(ctx, `
SELECT id, client_id, priority, action, community_id, comment, created_at, updated_at
FROM firewall_rule WHERE tenant_id=$1
ORDER BY CASE WHEN client_id IS NULL THEN 1 ELSE 0 END, client_id, priority`, tenantID)
if err != nil {
return nil, err
}
defer rows.Close()
return scanFirewallRules(rows, tenantID)
}
func (p *Postgres) CreateFirewallRule(tenantID string, clientID *string, in *store.FirewallRuleCreate) (*store.FirewallRule, error) {
if in == nil || !store.ValidFirewallAction(in.Action) {
return nil, store.ErrInvalidInput
}
if clientID != nil {
if _, err := p.GetFirewallClient(tenantID, *clientID); err != nil {
return nil, err
}
}
priority := 1
if in.Priority != nil && *in.Priority >= 1 {
priority = *in.Priority
} else {
next, err := p.nextFirewallRulePriority(tenantID, clientID)
if err != nil {
return nil, err
}
priority = next
}
id := uuid.NewString()
ctx := context.Background()
_, err := p.pool.Exec(ctx, `
INSERT INTO firewall_rule (id, tenant_id, client_id, priority, action, community_id, comment)
VALUES ($1,$2,$3,$4,$5,$6,$7)`,
id, tenantID, clientID, priority, strings.ToLower(strings.TrimSpace(in.Action)), in.CommunityID, strings.TrimSpace(in.Comment))
if err != nil {
return nil, err
}
return p.GetFirewallRule(tenantID, id)
}
func (p *Postgres) GetFirewallRule(tenantID, id string) (*store.FirewallRule, error) {
ctx := context.Background()
row := p.pool.QueryRow(ctx, `
SELECT id, client_id, priority, action, community_id, comment, created_at, updated_at
FROM firewall_rule WHERE id=$1 AND tenant_id=$2`, id, tenantID)
r, err := scanFirewallRuleRow(row.Scan, tenantID)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, store.ErrNotFound
}
return nil, err
}
return r, nil
}
func (p *Postgres) UpdateFirewallRule(tenantID, ruleID string, patch *store.FirewallRulePatch) (*store.FirewallRule, error) {
cur, err := p.GetFirewallRule(tenantID, ruleID)
if err != nil {
return nil, err
}
if patch == nil {
return nil, store.ErrInvalidInput
}
action := cur.Action
communityID := cur.CommunityID
comment := cur.Comment
if patch.Action != nil {
if !store.ValidFirewallAction(*patch.Action) {
return nil, store.ErrInvalidInput
}
action = strings.ToLower(strings.TrimSpace(*patch.Action))
}
if patch.ClearCommunity {
communityID = nil
} else if patch.CommunityID != nil {
communityID = patch.CommunityID
}
if patch.Comment != nil {
comment = strings.TrimSpace(*patch.Comment)
}
ctx := context.Background()
_, err = p.pool.Exec(ctx, `
UPDATE firewall_rule SET action=$3, community_id=$4, comment=$5, updated_at=now()
WHERE id=$1 AND tenant_id=$2`, ruleID, tenantID, action, communityID, comment)
if err != nil {
return nil, err
}
return p.GetFirewallRule(tenantID, ruleID)
}
func (p *Postgres) DeleteFirewallRule(tenantID, ruleID string) error {
ctx := context.Background()
tag, err := p.pool.Exec(ctx, `DELETE FROM firewall_rule WHERE id=$1 AND tenant_id=$2`, ruleID, tenantID)
if err != nil {
return err
}
if tag.RowsAffected() == 0 {
return store.ErrNotFound
}
return nil
}
func (p *Postgres) ReorderFirewallRules(tenantID string, clientID *string, orderedIDs []string) error {
ctx := context.Background()
tx, err := p.pool.Begin(ctx)
if err != nil {
return err
}
defer func() { _ = tx.Rollback(ctx) }()
for i, id := range orderedIDs {
var execErr error
if clientID == nil {
_, execErr = tx.Exec(ctx, `
UPDATE firewall_rule SET priority=$3, updated_at=now()
WHERE id=$1 AND tenant_id=$2 AND client_id IS NULL`, id, tenantID, i+1)
} else {
_, execErr = tx.Exec(ctx, `
UPDATE firewall_rule SET priority=$4, updated_at=now()
WHERE id=$1 AND tenant_id=$2 AND client_id=$3`, id, tenantID, *clientID, i+1)
}
if execErr != nil {
return execErr
}
}
return tx.Commit(ctx)
}
func (p *Postgres) nextFirewallRulePriority(tenantID string, clientID *string) (int, error) {
ctx := context.Background()
var max int
var err error
if clientID == nil {
err = p.pool.QueryRow(ctx, `
SELECT COALESCE(MAX(priority),0) FROM firewall_rule WHERE tenant_id=$1 AND client_id IS NULL`, tenantID).Scan(&max)
} else {
err = p.pool.QueryRow(ctx, `
SELECT COALESCE(MAX(priority),0) FROM firewall_rule WHERE tenant_id=$1 AND client_id=$2`, tenantID, *clientID).Scan(&max)
}
if err != nil {
return 0, err
}
return max + 1, nil
}
func scanFirewallRules(rows pgx.Rows, tenantID string) ([]*store.FirewallRule, error) {
var out []*store.FirewallRule
for rows.Next() {
r, err := scanFirewallRuleRow(rows.Scan, tenantID)
if err != nil {
return nil, err
}
out = append(out, r)
}
return out, rows.Err()
}
func scanFirewallRuleRow(scan scanFn, tenantID string) (*store.FirewallRule, error) {
var r store.FirewallRule
r.TenantID = tenantID
var clientID, communityID *string
if err := scan(&r.ID, &clientID, &r.Priority, &r.Action, &communityID, &r.Comment, &r.CreatedAt, &r.UpdatedAt); err != nil {
return nil, err
}
r.ClientID = clientID
r.CommunityID = communityID
return &r, nil
}
func scanFirewallClientRow(scan scanFn, tenantID string) (*store.FirewallClient, error) {
var c store.FirewallClient
c.TenantID = tenantID
var approvedBy *string
var lastSeen, lastApply, approved, revoked *time.Time
var prefixCount, ipCount *int
if err := scan(
&c.ID, &c.Name, &c.Hostname, &c.TokenPrefix, &c.Status,
&lastSeen, &c.LastSeenAtSource, &c.LastSeenIP,
&lastApply, &c.LastApplyStatus, &c.LastApplyError,
&prefixCount, &ipCount, &c.LastApplySource,
&c.ClientVersion, &c.CreatedAt, &approved, &approvedBy, &revoked,
); err != nil {
return nil, err
}
return finishFirewallClientScan(&c, lastSeen, lastApply, approved, revoked, approvedBy, prefixCount, ipCount), nil
}
func scanFirewallClientLookupRow(scan scanFn) (*store.FirewallClient, error) {
var c store.FirewallClient
var approvedBy *string
var lastSeen, lastApply, approved, revoked *time.Time
var prefixCount, ipCount *int
if err := scan(
&c.TenantID, &c.ID, &c.Name, &c.Hostname, &c.TokenPrefix, &c.Status,
&lastSeen, &c.LastSeenAtSource, &c.LastSeenIP,
&lastApply, &c.LastApplyStatus, &c.LastApplyError,
&prefixCount, &ipCount, &c.LastApplySource,
&c.ClientVersion, &c.CreatedAt, &approved, &approvedBy, &revoked,
); err != nil {
return nil, err
}
return finishFirewallClientScan(&c, lastSeen, lastApply, approved, revoked, approvedBy, prefixCount, ipCount), nil
}
func finishFirewallClientScan(c *store.FirewallClient, lastSeen, lastApply, approved, revoked *time.Time, approvedBy *string, prefixCount, ipCount *int) *store.FirewallClient {
c.LastSeenAt = lastSeen
c.LastApplyAt = lastApply
c.ApprovedAt = approved
c.RevokedAt = revoked
if approvedBy != nil {
c.ApprovedByAPIKeyID = *approvedBy
}
if prefixCount != nil {
c.LastApplyPrefixCount = *prefixCount
}
if ipCount != nil {
c.LastApplyIPCount = *ipCount
}
return c
}
func nullIfEmpty(s string) any {
if strings.TrimSpace(s) == "" {
return nil
}
return s
}
+22
View File
@@ -131,6 +131,28 @@ type Backend interface {
// Runtime log cleanup audit (filesystem ops logged per tenant).
AppendRuntimeLogCleanupAudit(tenantID, actor, filename, action string, sizeBefore int64, sizeAfter *int64, detail map[string]any) (string, error)
ListRuntimeLogCleanupAudit(tenantID, cursor string, limit int) ([]*RuntimeLogCleanupAudit, string, bool, error)
// Firewall blocklist clients and policy rules.
ListFirewallClients(tenantID string) ([]*FirewallClient, error)
GetFirewallClient(tenantID, id string) (*FirewallClient, error)
CreateFirewallClient(tenantID string, in *FirewallClientCreate) (*FirewallClient, error)
UpdateFirewallClient(tenantID, id string, patch *FirewallClientPatch) (*FirewallClient, error)
ApproveFirewallClient(tenantID, id, approverAPIKeyID string) (*FirewallClient, error)
RevokeFirewallClient(tenantID, id string) error
DeleteFirewallClient(tenantID, id string) error
LookupFirewallClientByTokenHash(hash []byte) (*FirewallClient, error)
TouchFirewallClientLastSeen(id, source, clientIP, clientVersion string) error
TouchFirewallClientLastApply(id, source, status, errMsg string, prefixCount, ipCount int) error
ListActiveFirewallClientHashes() ([]FirewallClientAuthRow, error)
ListApprovedFirewallClientsForReplication(tenantID string) ([]FirewallClientReplicationRow, error)
ListFirewallRules(tenantID string, clientID *string) ([]*FirewallRule, error)
ListAllFirewallRulesForClient(tenantID, clientID string) ([]*FirewallRule, error)
ListAllFirewallRulesForReplication(tenantID string) ([]*FirewallRule, error)
CreateFirewallRule(tenantID string, clientID *string, in *FirewallRuleCreate) (*FirewallRule, error)
UpdateFirewallRule(tenantID, ruleID string, patch *FirewallRulePatch) (*FirewallRule, error)
DeleteFirewallRule(tenantID, ruleID string) error
ReorderFirewallRules(tenantID string, clientID *string, orderedIDs []string) error
}
// ASNPrefixCacheEntry is a cached RIPEstat response for one ASN.
+98
View File
@@ -0,0 +1,98 @@
package store
import (
"strings"
"time"
)
// FirewallClient is a Linux blocklist sync client enrolled via seed.
type FirewallClient struct {
ID string `json:"id"`
TenantID string `json:"tenant_id,omitempty"`
Name string `json:"name"`
Hostname string `json:"hostname,omitempty"`
TokenPrefix string `json:"token_prefix"`
Status string `json:"status"`
LastSeenAt *time.Time `json:"last_seen_at,omitempty"`
LastSeenAtSource string `json:"last_seen_at_source,omitempty"`
LastSeenIP string `json:"last_seen_ip,omitempty"`
LastApplyAt *time.Time `json:"last_apply_at,omitempty"`
LastApplyStatus string `json:"last_apply_status,omitempty"`
LastApplyError string `json:"last_apply_error,omitempty"`
LastApplyPrefixCount int `json:"last_apply_prefix_count,omitempty"`
LastApplyIPCount int `json:"last_apply_ip_count,omitempty"`
LastApplySource string `json:"last_apply_source,omitempty"`
ClientVersion string `json:"client_version,omitempty"`
CreatedAt time.Time `json:"created_at"`
ApprovedAt *time.Time `json:"approved_at,omitempty"`
ApprovedByAPIKeyID string `json:"approved_by_api_key_id,omitempty"`
RevokedAt *time.Time `json:"revoked_at,omitempty"`
}
// FirewallClientCreate is input for enroll (token hash supplied by caller).
type FirewallClientCreate struct {
Name string
Hostname string
TokenPrefix string
TokenHash []byte
ClientVersion string
}
// FirewallClientPatch is a partial update for operator edits.
type FirewallClientPatch struct {
Name *string `json:"name,omitempty"`
Hostname *string `json:"hostname,omitempty"`
}
// FirewallClientAuthRow is used to build the in-process firewall token index.
type FirewallClientAuthRow struct {
ID string
TenantID string
TokenHash []byte
}
// FirewallClientReplicationRow is pushed to speaker agents.
type FirewallClientReplicationRow struct {
ClientID string `json:"client_id"`
Name string `json:"name"`
TokenHashHex string `json:"token_hash_hex"`
}
// FirewallRule is one block/accept policy rule.
type FirewallRule struct {
ID string `json:"id"`
TenantID string `json:"tenant_id,omitempty"`
ClientID *string `json:"client_id,omitempty"`
Priority int `json:"priority"`
Action string `json:"action"`
CommunityID *string `json:"community_id,omitempty"`
Comment string `json:"comment,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// FirewallRuleCreate is input for creating a rule.
type FirewallRuleCreate struct {
Priority *int `json:"priority,omitempty"`
Action string `json:"action"`
CommunityID *string `json:"community_id,omitempty"`
Comment string `json:"comment,omitempty"`
}
// FirewallRulePatch is a partial rule update.
type FirewallRulePatch struct {
Action *string `json:"action,omitempty"`
CommunityID *string `json:"community_id,omitempty"`
ClearCommunity bool `json:"-"`
Comment *string `json:"comment,omitempty"`
}
// ValidFirewallAction reports whether action is block or accept.
func ValidFirewallAction(action string) bool {
switch strings.ToLower(strings.TrimSpace(action)) {
case "block", "accept":
return true
default:
return false
}
}
+11
View File
@@ -44,6 +44,9 @@ type Memory struct {
moduleSnapshots map[string]*moduleSnapshotRec
asnPrefixCache map[int64]*ASNPrefixCacheEntry
apiKeys map[string]*apiKeyRec
firewallClients map[string]*firewallClientRec
firewallRules map[string]*FirewallRule
firewallHashIndex map[string]string // hex hash -> client id
maintenancePolicies map[string]*MaintenancePolicy
maintConfigAudit []*MaintenancePolicyConfigAudit
runtimeLogCleanupAudit []*RuntimeLogCleanupAudit
@@ -66,6 +69,11 @@ type apiKeyRec struct {
TokenHash []byte
}
type firewallClientRec struct {
FirewallClient
TokenHash []byte
}
type Tenant struct {
ID string
Name string
@@ -143,6 +151,9 @@ func NewMemory() *Memory {
moduleSnapshots: make(map[string]*moduleSnapshotRec),
asnPrefixCache: make(map[int64]*ASNPrefixCacheEntry),
apiKeys: make(map[string]*apiKeyRec),
firewallClients: make(map[string]*firewallClientRec),
firewallRules: make(map[string]*FirewallRule),
firewallHashIndex: make(map[string]string),
maintenancePolicies: make(map[string]*MaintenancePolicy),
maintConfigAudit: nil,
runtimeLogCleanupAudit: nil,
+463
View File
@@ -0,0 +1,463 @@
package store
import (
"encoding/hex"
"sort"
"strings"
"time"
"github.com/google/uuid"
)
func (m *Memory) ListFirewallClients(tenantID string) ([]*FirewallClient, error) {
m.mu.RLock()
defer m.mu.RUnlock()
var out []*FirewallClient
for _, rec := range m.firewallClients {
if rec.TenantID == tenantID {
out = append(out, firewallClientCopy(&rec.FirewallClient))
}
}
sort.Slice(out, func(i, j int) bool { return out[i].CreatedAt.After(out[j].CreatedAt) })
return out, nil
}
func (m *Memory) GetFirewallClient(tenantID, id string) (*FirewallClient, error) {
m.mu.RLock()
defer m.mu.RUnlock()
rec, ok := m.firewallClients[id]
if !ok || rec.TenantID != tenantID {
return nil, ErrNotFound
}
return firewallClientCopy(&rec.FirewallClient), nil
}
func (m *Memory) CreateFirewallClient(tenantID string, in *FirewallClientCreate) (*FirewallClient, error) {
if in == nil || strings.TrimSpace(in.Name) == "" || len(in.TokenHash) != 32 {
return nil, ErrInvalidInput
}
m.mu.Lock()
defer m.mu.Unlock()
if _, ok := m.tenants[tenantID]; !ok {
return nil, ErrTenantScope
}
hashKey := hex.EncodeToString(in.TokenHash)
if _, dup := m.firewallHashIndex[hashKey]; dup {
return nil, ErrInvalidInput
}
now := time.Now().UTC()
id := uuid.NewString()
rec := &firewallClientRec{
FirewallClient: FirewallClient{
ID: id,
TenantID: tenantID,
Name: strings.TrimSpace(in.Name),
Hostname: strings.TrimSpace(in.Hostname),
TokenPrefix: in.TokenPrefix,
Status: "pending",
ClientVersion: strings.TrimSpace(in.ClientVersion),
CreatedAt: now,
},
TokenHash: append([]byte(nil), in.TokenHash...),
}
m.firewallClients[id] = rec
m.firewallHashIndex[hashKey] = id
return firewallClientCopy(&rec.FirewallClient), nil
}
func (m *Memory) UpdateFirewallClient(tenantID, id string, patch *FirewallClientPatch) (*FirewallClient, error) {
if patch == nil {
return nil, ErrInvalidInput
}
m.mu.Lock()
defer m.mu.Unlock()
rec, ok := m.firewallClients[id]
if !ok || rec.TenantID != tenantID {
return nil, ErrNotFound
}
if patch.Name != nil {
n := strings.TrimSpace(*patch.Name)
if n == "" {
return nil, ErrInvalidInput
}
rec.Name = n
}
if patch.Hostname != nil {
rec.Hostname = strings.TrimSpace(*patch.Hostname)
}
return firewallClientCopy(&rec.FirewallClient), nil
}
func (m *Memory) ApproveFirewallClient(tenantID, id, approverAPIKeyID string) (*FirewallClient, error) {
m.mu.Lock()
defer m.mu.Unlock()
rec, ok := m.firewallClients[id]
if !ok || rec.TenantID != tenantID {
return nil, ErrNotFound
}
if rec.Status == "revoked" {
return nil, ErrInvalidInput
}
now := time.Now().UTC()
rec.Status = "approved"
rec.ApprovedAt = &now
rec.ApprovedByAPIKeyID = strings.TrimSpace(approverAPIKeyID)
rec.RevokedAt = nil
return firewallClientCopy(&rec.FirewallClient), nil
}
func (m *Memory) RevokeFirewallClient(tenantID, id string) error {
m.mu.Lock()
defer m.mu.Unlock()
rec, ok := m.firewallClients[id]
if !ok || rec.TenantID != tenantID {
return ErrNotFound
}
now := time.Now().UTC()
rec.Status = "revoked"
rec.RevokedAt = &now
return nil
}
func (m *Memory) DeleteFirewallClient(tenantID, id string) error {
m.mu.Lock()
defer m.mu.Unlock()
rec, ok := m.firewallClients[id]
if !ok || rec.TenantID != tenantID {
return ErrNotFound
}
hashKey := hex.EncodeToString(rec.TokenHash)
delete(m.firewallHashIndex, hashKey)
delete(m.firewallClients, id)
for rid, rule := range m.firewallRules {
if rule.ClientID != nil && *rule.ClientID == id {
delete(m.firewallRules, rid)
}
}
return nil
}
func (m *Memory) LookupFirewallClientByTokenHash(hash []byte) (*FirewallClient, error) {
if len(hash) != 32 {
return nil, ErrNotFound
}
m.mu.RLock()
defer m.mu.RUnlock()
id, ok := m.firewallHashIndex[hex.EncodeToString(hash)]
if !ok {
return nil, ErrNotFound
}
rec, ok := m.firewallClients[id]
if !ok {
return nil, ErrNotFound
}
return firewallClientCopy(&rec.FirewallClient), nil
}
func (m *Memory) TouchFirewallClientLastSeen(id, source, clientIP, clientVersion string) error {
m.mu.Lock()
defer m.mu.Unlock()
rec, ok := m.firewallClients[id]
if !ok {
return ErrNotFound
}
now := time.Now().UTC()
rec.LastSeenAt = &now
rec.LastSeenAtSource = strings.TrimSpace(source)
rec.LastSeenIP = strings.TrimSpace(clientIP)
if v := strings.TrimSpace(clientVersion); v != "" {
rec.ClientVersion = v
}
return nil
}
func (m *Memory) TouchFirewallClientLastApply(id, source, status, errMsg string, prefixCount, ipCount int) error {
m.mu.Lock()
defer m.mu.Unlock()
rec, ok := m.firewallClients[id]
if !ok {
return ErrNotFound
}
now := time.Now().UTC()
rec.LastApplyAt = &now
rec.LastApplySource = strings.TrimSpace(source)
rec.LastApplyStatus = strings.TrimSpace(status)
rec.LastApplyError = strings.TrimSpace(errMsg)
rec.LastApplyPrefixCount = prefixCount
rec.LastApplyIPCount = ipCount
return nil
}
func (m *Memory) ListActiveFirewallClientHashes() ([]FirewallClientAuthRow, error) {
m.mu.RLock()
defer m.mu.RUnlock()
var out []FirewallClientAuthRow
for _, rec := range m.firewallClients {
if rec.Status != "approved" {
continue
}
out = append(out, FirewallClientAuthRow{
ID: rec.ID,
TenantID: rec.TenantID,
TokenHash: append([]byte(nil), rec.TokenHash...),
})
}
return out, nil
}
func (m *Memory) ListApprovedFirewallClientsForReplication(tenantID string) ([]FirewallClientReplicationRow, error) {
m.mu.RLock()
defer m.mu.RUnlock()
var out []FirewallClientReplicationRow
for _, rec := range m.firewallClients {
if rec.TenantID != tenantID || rec.Status != "approved" {
continue
}
out = append(out, FirewallClientReplicationRow{
ClientID: rec.ID,
Name: rec.Name,
TokenHashHex: hex.EncodeToString(rec.TokenHash),
})
}
sort.Slice(out, func(i, j int) bool { return out[i].ClientID < out[j].ClientID })
return out, nil
}
func (m *Memory) ListFirewallRules(tenantID string, clientID *string) ([]*FirewallRule, error) {
m.mu.RLock()
defer m.mu.RUnlock()
var out []*FirewallRule
for _, rule := range m.firewallRules {
if rule.TenantID != tenantID {
continue
}
if clientID == nil {
if rule.ClientID != nil {
continue
}
} else if rule.ClientID == nil || *rule.ClientID != *clientID {
continue
}
out = append(out, firewallRuleCopy(rule))
}
sort.Slice(out, func(i, j int) bool { return out[i].Priority < out[j].Priority })
return out, nil
}
func (m *Memory) ListAllFirewallRulesForClient(tenantID, clientID string) ([]*FirewallRule, error) {
tenantRules, err := m.ListFirewallRules(tenantID, nil)
if err != nil {
return nil, err
}
cid := clientID
clientRules, err := m.ListFirewallRules(tenantID, &cid)
if err != nil {
return nil, err
}
out := make([]*FirewallRule, 0, len(tenantRules)+len(clientRules))
out = append(out, clientRules...)
out = append(out, tenantRules...)
return out, nil
}
func (m *Memory) ListAllFirewallRulesForReplication(tenantID string) ([]*FirewallRule, error) {
m.mu.RLock()
defer m.mu.RUnlock()
var out []*FirewallRule
for _, rule := range m.firewallRules {
if rule.TenantID == tenantID {
out = append(out, firewallRuleCopy(rule))
}
}
sort.Slice(out, func(i, j int) bool {
ac, bc := "", ""
if out[i].ClientID != nil {
ac = *out[i].ClientID
}
if out[j].ClientID != nil {
bc = *out[j].ClientID
}
if ac != bc {
return ac < bc
}
return out[i].Priority < out[j].Priority
})
return out, nil
}
func (m *Memory) CreateFirewallRule(tenantID string, clientID *string, in *FirewallRuleCreate) (*FirewallRule, error) {
if in == nil || !ValidFirewallAction(in.Action) {
return nil, ErrInvalidInput
}
m.mu.Lock()
defer m.mu.Unlock()
if _, ok := m.tenants[tenantID]; !ok {
return nil, ErrTenantScope
}
if clientID != nil {
if rec, ok := m.firewallClients[*clientID]; !ok || rec.TenantID != tenantID {
return nil, ErrNotFound
}
}
priority := m.nextFirewallRulePriorityLocked(tenantID, clientID)
if in.Priority != nil && *in.Priority >= 1 {
priority = *in.Priority
}
if m.firewallRulePriorityTakenLocked(tenantID, clientID, priority, "") {
return nil, ErrInvalidInput
}
now := time.Now().UTC()
id := uuid.NewString()
rule := &FirewallRule{
ID: id,
TenantID: tenantID,
ClientID: clientID,
Priority: priority,
Action: strings.ToLower(strings.TrimSpace(in.Action)),
CommunityID: in.CommunityID,
Comment: strings.TrimSpace(in.Comment),
CreatedAt: now,
UpdatedAt: now,
}
m.firewallRules[id] = rule
return firewallRuleCopy(rule), nil
}
func (m *Memory) UpdateFirewallRule(tenantID, ruleID string, patch *FirewallRulePatch) (*FirewallRule, error) {
if patch == nil {
return nil, ErrInvalidInput
}
m.mu.Lock()
defer m.mu.Unlock()
rule, ok := m.firewallRules[ruleID]
if !ok || rule.TenantID != tenantID {
return nil, ErrNotFound
}
if patch.Action != nil {
if !ValidFirewallAction(*patch.Action) {
return nil, ErrInvalidInput
}
rule.Action = strings.ToLower(strings.TrimSpace(*patch.Action))
}
if patch.ClearCommunity {
rule.CommunityID = nil
} else if patch.CommunityID != nil {
rule.CommunityID = patch.CommunityID
}
if patch.Comment != nil {
rule.Comment = strings.TrimSpace(*patch.Comment)
}
rule.UpdatedAt = time.Now().UTC()
return firewallRuleCopy(rule), nil
}
func (m *Memory) DeleteFirewallRule(tenantID, ruleID string) error {
m.mu.Lock()
defer m.mu.Unlock()
rule, ok := m.firewallRules[ruleID]
if !ok || rule.TenantID != tenantID {
return ErrNotFound
}
delete(m.firewallRules, ruleID)
return nil
}
func (m *Memory) ReorderFirewallRules(tenantID string, clientID *string, orderedIDs []string) error {
m.mu.Lock()
defer m.mu.Unlock()
scope := make(map[string]*FirewallRule)
for id, rule := range m.firewallRules {
if rule.TenantID != tenantID {
continue
}
if clientID == nil {
if rule.ClientID != nil {
continue
}
} else if rule.ClientID == nil || *rule.ClientID != *clientID {
continue
}
scope[id] = rule
}
if len(orderedIDs) != len(scope) {
return ErrInvalidInput
}
now := time.Now().UTC()
for i, id := range orderedIDs {
rule, ok := scope[id]
if !ok {
return ErrInvalidInput
}
rule.Priority = i + 1
rule.UpdatedAt = now
}
return nil
}
func (m *Memory) nextFirewallRulePriorityLocked(tenantID string, clientID *string) int {
max := 0
for _, rule := range m.firewallRules {
if rule.TenantID != tenantID {
continue
}
if clientID == nil {
if rule.ClientID != nil {
continue
}
} else if rule.ClientID == nil || *rule.ClientID != *clientID {
continue
}
if rule.Priority > max {
max = rule.Priority
}
}
return max + 1
}
func (m *Memory) firewallRulePriorityTakenLocked(tenantID string, clientID *string, priority int, exceptID string) bool {
for id, rule := range m.firewallRules {
if id == exceptID || rule.TenantID != tenantID || rule.Priority != priority {
continue
}
if clientID == nil {
if rule.ClientID == nil {
return true
}
continue
}
if rule.ClientID != nil && *rule.ClientID == *clientID {
return true
}
}
return false
}
func firewallClientCopy(c *FirewallClient) *FirewallClient {
cp := *c
cp.LastSeenAt = cloneTime(c.LastSeenAt)
cp.LastApplyAt = cloneTime(c.LastApplyAt)
cp.ApprovedAt = cloneTime(c.ApprovedAt)
cp.RevokedAt = cloneTime(c.RevokedAt)
return &cp
}
func firewallRuleCopy(r *FirewallRule) *FirewallRule {
cp := *r
if r.ClientID != nil {
v := *r.ClientID
cp.ClientID = &v
}
if r.CommunityID != nil {
v := *r.CommunityID
cp.CommunityID = &v
}
return &cp
}
func cloneTime(t *time.Time) *time.Time {
if t == nil {
return nil
}
v := *t
return &v
}
+29 -11
View File
@@ -9,17 +9,21 @@ import (
// SpeakerMeta holds well-known keys from bgp_speaker.meta_json.
type SpeakerMeta struct {
AgentDomain string `json:"agent_domain,omitempty"`
AgentSecret string `json:"agent_secret,omitempty"`
AgentPort int `json:"agent_port,omitempty"`
NodeIPv4 string `json:"node_ipv4,omitempty"`
BirdBgpSourceIPv4 string `json:"bird_bgp_source_ipv4,omitempty"`
BirdBgpSourceIPv6 string `json:"bird_bgp_source_ipv6,omitempty"`
NodeEnrolledAt string `json:"node_enrolled_at,omitempty"`
LastDispatchAt string `json:"last_dispatch_at,omitempty"`
LastDispatchError string `json:"last_dispatch_error,omitempty"`
LastDispatchStatus string `json:"last_dispatch_status,omitempty"`
SyncStatus string `json:"sync_status,omitempty"`
AgentDomain string `json:"agent_domain,omitempty"`
AgentSecret string `json:"agent_secret,omitempty"`
AgentPort int `json:"agent_port,omitempty"`
NodeIPv4 string `json:"node_ipv4,omitempty"`
BirdBgpSourceIPv4 string `json:"bird_bgp_source_ipv4,omitempty"`
BirdBgpSourceIPv6 string `json:"bird_bgp_source_ipv6,omitempty"`
NodeEnrolledAt string `json:"node_enrolled_at,omitempty"`
LastDispatchAt string `json:"last_dispatch_at,omitempty"`
LastDispatchError string `json:"last_dispatch_error,omitempty"`
LastDispatchStatus string `json:"last_dispatch_status,omitempty"`
SyncStatus string `json:"sync_status,omitempty"`
FirewallFailover bool `json:"firewall_failover,omitempty"`
LastFirewallReplicateAt string `json:"last_firewall_replicate_at,omitempty"`
LastFirewallReplicateStatus string `json:"last_firewall_replicate_status,omitempty"`
LastFirewallReplicateError string `json:"last_firewall_replicate_error,omitempty"`
}
// ParseSpeakerMeta decodes meta_json object; unknown keys are ignored.
@@ -83,6 +87,20 @@ func MergeSpeakerMetaJSON(existing string, patch SpeakerMeta) string {
if patch.SyncStatus != "" {
cur.SyncStatus = patch.SyncStatus
}
if patch.FirewallFailover {
cur.FirewallFailover = true
}
if patch.LastFirewallReplicateAt != "" {
cur.LastFirewallReplicateAt = patch.LastFirewallReplicateAt
}
if patch.LastFirewallReplicateStatus == "ok" {
cur.LastFirewallReplicateError = ""
} else if patch.LastFirewallReplicateError != "" {
cur.LastFirewallReplicateError = patch.LastFirewallReplicateError
}
if patch.LastFirewallReplicateStatus != "" {
cur.LastFirewallReplicateStatus = patch.LastFirewallReplicateStatus
}
return SpeakerMetaJSON(cur)
}