Files
EvoBGP/internal/repository/postgres_firewall.go
T
Denozordec 7a3eae98b1
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
feat(firewall): implement firewall blocklist feature with client management and policy rules
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.
2026-07-08 16:37:27 +07:00

495 lines
16 KiB
Go

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
}