From e517783c42c7f7aa2c5a9076576783803307001e Mon Sep 17 00:00:00 2001 From: Debian Date: Sat, 9 May 2026 23:52:01 +0200 Subject: [PATCH] =?UTF-8?q?feat(fw):=20Migration=200010=20=E2=80=94=20Fire?= =?UTF-8?q?wall-v2-Schema=20(Fortigate-Style)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase-1 firewall_rules (chain/match_expr raw nft) → Fortigate-Niveau: * firewall_address_objects (host/network/range/fqdn) * firewall_address_groups + members junction * firewall_services (proto+port range, builtin-Flag) * firewall_service_groups + members junction * firewall_rules komplett umgebaut: src_zone+addr/group/cidr, dst_zone+addr/group/cidr, service_object_id ODER service_group_id, action accept|drop|reject, log-Flag, priority+enabled * firewall_nat_rules (kind=dnat|snat|masquerade) als separate Tabelle Zonen kommen aus network_interfaces.role (wan|lan|dmz|mgmt|cluster + pseudo-Zone 'any'). Builtin-Inserts: 18 Standard-Services (HTTP/HTTPS/SSH/DNS/SMTP-Familie/ DBs/RDP/WireGuard/Ping) plus 5 Service-Groups (Web, Mail-Submit, Mail-Receive, DNS, Ping). Renderer (internal/firewall/firewall.go) lässt firewall_rules-Query für jetzt aus — Template fällt auf baseline + cluster-peer-set zurück. Volle Render-Logik mit den neuen Joins kommt mit Task #44. Models + Repos + Handlers + Frontend folgen in den nächsten Commits. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../database/migrations/0010_firewall_v2.sql | 249 ++++++++++++++++++ internal/firewall/firewall.go | 34 +-- internal/models/firewall_rule.go | 17 -- 3 files changed, 257 insertions(+), 43 deletions(-) create mode 100644 internal/database/migrations/0010_firewall_v2.sql delete mode 100644 internal/models/firewall_rule.go diff --git a/internal/database/migrations/0010_firewall_v2.sql b/internal/database/migrations/0010_firewall_v2.sql new file mode 100644 index 0000000..b9e0c1e --- /dev/null +++ b/internal/database/migrations/0010_firewall_v2.sql @@ -0,0 +1,249 @@ +-- +goose Up +-- +goose StatementBegin + +-- ════════════════════════════════════════════════════════════════════════ +-- Firewall v2 — Fortigate-style: +-- address objects + groups · service objects + groups · zone-aware +-- policy rules · separate NAT rules. +-- +-- Zonen sind nicht als Tabelle modelliert — sie ergeben sich aus +-- network_interfaces.role (wan|lan|dmz|mgmt|cluster), plus die +-- pseudo-Zone 'any' im Application-Layer. +-- +-- Die alte firewall_rules-Tabelle aus Phase 1 bekommt einen neuen +-- Aufbau. Bestehende Rows (es gibt im Moment keine in produktion) +-- werden gedroppt — TRUNCATE ist OK. +-- ════════════════════════════════════════════════════════════════════════ + +DROP TABLE IF EXISTS firewall_rules; + +-- ── Address Objects ──────────────────────────────────────────────────── +-- Wiederverwendbare Adress-Definitionen. kind=host (eine IP), +-- network (CIDR), range (a-b), fqdn (DNS-Name; wird zur render-time +-- aufgelöst und als Set materialisiert). +CREATE TABLE IF NOT EXISTS firewall_address_objects ( + id BIGSERIAL PRIMARY KEY, + name TEXT NOT NULL, + kind TEXT NOT NULL, + value TEXT NOT NULL, + description TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT firewall_address_objects_name_unique UNIQUE (name), + CONSTRAINT firewall_address_objects_kind_check CHECK (kind IN ('host', 'network', 'range', 'fqdn')) +); + +-- ── Address Groups ───────────────────────────────────────────────────── +-- Gruppen referenzieren primitive Objekte (kein Nesting in v1 — wir +-- können Group-in-Group später flach via DAG erlauben). +CREATE TABLE IF NOT EXISTS firewall_address_groups ( + id BIGSERIAL PRIMARY KEY, + name TEXT NOT NULL, + description TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT firewall_address_groups_name_unique UNIQUE (name) +); + +CREATE TABLE IF NOT EXISTS firewall_address_group_members ( + group_id BIGINT NOT NULL REFERENCES firewall_address_groups(id) ON DELETE CASCADE, + object_id BIGINT NOT NULL REFERENCES firewall_address_objects(id) ON DELETE CASCADE, + PRIMARY KEY (group_id, object_id) +); + +-- ── Services ─────────────────────────────────────────────────────────── +-- Protocol + Port Range. proto='any' = alle (kein Port-Match). +-- ICMP wird via proto='icmp'/'icmpv6' modelliert; port_start/end NULL. +CREATE TABLE IF NOT EXISTS firewall_services ( + id BIGSERIAL PRIMARY KEY, + name TEXT NOT NULL, + proto TEXT NOT NULL, + port_start INTEGER, + port_end INTEGER, + builtin BOOLEAN NOT NULL DEFAULT FALSE, + description TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT firewall_services_name_unique UNIQUE (name), + CONSTRAINT firewall_services_proto_check CHECK (proto IN ('tcp', 'udp', 'icmp', 'icmpv6', 'any')), + CONSTRAINT firewall_services_port_check CHECK ( + (proto IN ('tcp', 'udp') AND port_start IS NOT NULL AND port_end IS NOT NULL + AND port_start BETWEEN 1 AND 65535 AND port_end BETWEEN port_start AND 65535) + OR (proto NOT IN ('tcp', 'udp')) + ) +); + +CREATE TABLE IF NOT EXISTS firewall_service_groups ( + id BIGSERIAL PRIMARY KEY, + name TEXT NOT NULL, + description TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT firewall_service_groups_name_unique UNIQUE (name) +); + +CREATE TABLE IF NOT EXISTS firewall_service_group_members ( + group_id BIGINT NOT NULL REFERENCES firewall_service_groups(id) ON DELETE CASCADE, + service_id BIGINT NOT NULL REFERENCES firewall_services(id) ON DELETE CASCADE, + PRIMARY KEY (group_id, service_id) +); + +-- ── Policy Rules ─────────────────────────────────────────────────────── +-- Quelle und Ziel können je sein: +-- * src_address_object_id (FK) — referenziert ein primitives Objekt +-- * src_address_group_id (FK) — referenziert eine Gruppe +-- * src_cidr (TEXT) — Inline-Fallback für ad-hoc-Regeln +-- * (alle null = 'any', d.h. „beliebig") +-- +-- Genau eine der drei darf gesetzt sein (Anwendungs-Validation in der +-- Handler-Schicht; DB lässt die Kombi zu, weil expressive +-- CHECK-Constraints schwer zu warten sind). +-- +-- Service analog: object oder group oder „any". +CREATE TABLE IF NOT EXISTS firewall_rules ( + id BIGSERIAL PRIMARY KEY, + name TEXT, + priority INTEGER NOT NULL DEFAULT 100, + enabled BOOLEAN NOT NULL DEFAULT TRUE, + action TEXT NOT NULL, + + src_zone TEXT NOT NULL DEFAULT 'any', + src_address_object_id BIGINT REFERENCES firewall_address_objects(id) ON DELETE SET NULL, + src_address_group_id BIGINT REFERENCES firewall_address_groups(id) ON DELETE SET NULL, + src_cidr TEXT, + + dst_zone TEXT NOT NULL DEFAULT 'any', + dst_address_object_id BIGINT REFERENCES firewall_address_objects(id) ON DELETE SET NULL, + dst_address_group_id BIGINT REFERENCES firewall_address_groups(id) ON DELETE SET NULL, + dst_cidr TEXT, + + service_object_id BIGINT REFERENCES firewall_services(id) ON DELETE SET NULL, + service_group_id BIGINT REFERENCES firewall_service_groups(id) ON DELETE SET NULL, + + log BOOLEAN NOT NULL DEFAULT FALSE, + comment TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + CONSTRAINT firewall_rules_action_check CHECK (action IN ('accept', 'drop', 'reject')), + CONSTRAINT firewall_rules_src_zone_check CHECK (src_zone IN ('wan', 'lan', 'dmz', 'mgmt', 'cluster', 'any')), + CONSTRAINT firewall_rules_dst_zone_check CHECK (dst_zone IN ('wan', 'lan', 'dmz', 'mgmt', 'cluster', 'any')) +); + +CREATE INDEX IF NOT EXISTS idx_firewall_rules_priority ON firewall_rules (priority DESC, id ASC); +CREATE INDEX IF NOT EXISTS idx_firewall_rules_enabled ON firewall_rules (enabled) WHERE enabled; + +-- ── NAT Rules (separate Tabelle) ─────────────────────────────────────── +-- kind: +-- dnat — Destination NAT (port-forward von außen nach intern) +-- snat — Source NAT (rewrite source-IP zu fester Adresse) +-- masquerade — Source NAT zur Adresse des Out-Interfaces +-- (typisch lan→wan ohne fester ip) +-- +-- Felder pro kind: +-- dnat: in_zone + match_dst_port → target_addr [+ target_port] +-- snat: out_zone + match_src_cidr → target_addr +-- masquerade: out_zone + match_src_cidr (kein target — Iface-IP) +CREATE TABLE IF NOT EXISTS firewall_nat_rules ( + id BIGSERIAL PRIMARY KEY, + name TEXT, + priority INTEGER NOT NULL DEFAULT 100, + enabled BOOLEAN NOT NULL DEFAULT TRUE, + kind TEXT NOT NULL, + + in_zone TEXT, + out_zone TEXT, + proto TEXT, + match_src_cidr TEXT, + match_dst_cidr TEXT, + match_dport_start INTEGER, + match_dport_end INTEGER, + + target_addr TEXT, + target_port_start INTEGER, + target_port_end INTEGER, + + comment TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + CONSTRAINT firewall_nat_rules_kind_check CHECK (kind IN ('dnat', 'snat', 'masquerade')), + CONSTRAINT firewall_nat_rules_proto_check CHECK (proto IS NULL OR proto IN ('tcp', 'udp', 'any')), + CONSTRAINT firewall_nat_rules_zone_check CHECK ( + (in_zone IS NULL OR in_zone IN ('wan', 'lan', 'dmz', 'mgmt', 'cluster')) AND + (out_zone IS NULL OR out_zone IN ('wan', 'lan', 'dmz', 'mgmt', 'cluster')) + ) +); + +CREATE INDEX IF NOT EXISTS idx_firewall_nat_rules_priority ON firewall_nat_rules (priority DESC, id ASC); +CREATE INDEX IF NOT EXISTS idx_firewall_nat_rules_enabled ON firewall_nat_rules (enabled) WHERE enabled; + +-- ── Builtin-Services ─────────────────────────────────────────────────── +INSERT INTO firewall_services (name, proto, port_start, port_end, builtin, description) VALUES + ('HTTP', 'tcp', 80, 80, TRUE, 'Hypertext Transfer Protocol'), + ('HTTPS', 'tcp', 443, 443, TRUE, 'HTTP over TLS'), + ('SSH', 'tcp', 22, 22, TRUE, 'Secure Shell'), + ('DNS-TCP', 'tcp', 53, 53, TRUE, 'DNS over TCP (zone transfer / large responses)'), + ('DNS-UDP', 'udp', 53, 53, TRUE, 'DNS over UDP (standard query)'), + ('NTP', 'udp', 123, 123, TRUE, 'Network Time Protocol'), + ('SMTP', 'tcp', 25, 25, TRUE, 'Simple Mail Transfer Protocol'), + ('SMTPS', 'tcp', 465, 465, TRUE, 'SMTP over implicit TLS'), + ('Submission','tcp', 587, 587, TRUE, 'SMTP message submission'), + ('IMAPS', 'tcp', 993, 993, TRUE, 'IMAP over implicit TLS'), + ('POP3S', 'tcp', 995, 995, TRUE, 'POP3 over implicit TLS'), + ('MySQL', 'tcp', 3306, 3306, TRUE, 'MySQL / MariaDB'), + ('PostgreSQL','tcp', 5432, 5432, TRUE, 'PostgreSQL'), + ('Redis', 'tcp', 6379, 6379, TRUE, 'Redis / KeyDB'), + ('RDP', 'tcp', 3389, 3389, TRUE, 'Microsoft Remote Desktop'), + ('WireGuard', 'udp', 51820, 51820, TRUE, 'WireGuard VPN (default port)'), + ('PING-v4', 'icmp', NULL, NULL, TRUE, 'ICMP echo (ping)'), + ('PING-v6', 'icmpv6', NULL, NULL, TRUE, 'ICMPv6 echo (ping6)') +ON CONFLICT (name) DO NOTHING; + +-- ── Builtin Service-Groups ───────────────────────────────────────────── +INSERT INTO firewall_service_groups (name, description) VALUES + ('Web', 'HTTP + HTTPS'), + ('Mail-Submit', 'Outgoing mail submission'), + ('Mail-Receive', 'Incoming mail (SMTP family)'), + ('DNS', 'DNS over TCP and UDP'), + ('Ping', 'ICMP echo for IPv4 + IPv6') +ON CONFLICT (name) DO NOTHING; + +-- Members for the builtin groups. Joins via subquery so we don't +-- depend on auto-incremented IDs in the inserts above. +INSERT INTO firewall_service_group_members (group_id, service_id) +SELECT g.id, s.id FROM firewall_service_groups g, firewall_services s +WHERE (g.name = 'Web' AND s.name IN ('HTTP', 'HTTPS')) + OR (g.name = 'Mail-Submit' AND s.name IN ('SMTPS', 'Submission')) + OR (g.name = 'Mail-Receive' AND s.name IN ('SMTP', 'SMTPS', 'Submission')) + OR (g.name = 'DNS' AND s.name IN ('DNS-TCP', 'DNS-UDP')) + OR (g.name = 'Ping' AND s.name IN ('PING-v4', 'PING-v6')) +ON CONFLICT DO NOTHING; + +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +DROP TABLE IF EXISTS firewall_nat_rules; +DROP TABLE IF EXISTS firewall_rules; +DROP TABLE IF EXISTS firewall_service_group_members; +DROP TABLE IF EXISTS firewall_service_groups; +DROP TABLE IF EXISTS firewall_services; +DROP TABLE IF EXISTS firewall_address_group_members; +DROP TABLE IF EXISTS firewall_address_groups; +DROP TABLE IF EXISTS firewall_address_objects; + +-- Restore the Phase-1 firewall_rules shape so a `migrate down` +-- doesn't leave the DB without that table. +CREATE TABLE IF NOT EXISTS firewall_rules ( + id BIGSERIAL PRIMARY KEY, + chain TEXT NOT NULL, + priority INTEGER NOT NULL DEFAULT 100, + match_expr TEXT NOT NULL, + action TEXT NOT NULL, + comment TEXT, + active BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); +-- +goose StatementEnd diff --git a/internal/firewall/firewall.go b/internal/firewall/firewall.go index fbdf1a7..0867989 100644 --- a/internal/firewall/firewall.go +++ b/internal/firewall/firewall.go @@ -117,30 +117,12 @@ func (g *Generator) loadView(ctx context.Context) (*View, error) { return nil, err } - // Custom firewall_rules — only active, ordered by priority. - ruleRows, err := g.Pool.Query(ctx, ` -SELECT chain, match_expr, action, COALESCE(comment, '') -FROM firewall_rules -WHERE active -ORDER BY chain ASC, priority DESC, id ASC`) - if err != nil { - return nil, fmt.Errorf("query firewall_rules: %w", err) - } - defer ruleRows.Close() - for ruleRows.Next() { - var chain, match, action, comment string - if err := ruleRows.Scan(&chain, &match, &action, &comment); err != nil { - return nil, err - } - r := Rule{MatchExpr: match, Action: action, Comment: comment} - switch chain { - case "input": - view.CustomRulesInput = append(view.CustomRulesInput, r) - case "forward": - view.CustomRulesForward = append(view.CustomRulesForward, r) - case "output": - view.CustomRulesOutput = append(view.CustomRulesOutput, r) - } - } - return view, ruleRows.Err() + // Migration 0010 hat firewall_rules komplett umgebaut (Fortigate- + // Style mit address objects + service refs). Phase-2-Renderer + // kannte das alte chain/match_expr-Schema. Bis Task #44 die + // Render-Logik mit den neuen Joins ersetzt, geben wir hier + // keine custom-Rules aus — Output ist nur baseline + cluster set. + // Sicher, weil baseline default-deny ist; v2-Rules kommen mit + // dem nächsten Renderer-Patch. + return view, nil } diff --git a/internal/models/firewall_rule.go b/internal/models/firewall_rule.go deleted file mode 100644 index a0cd106..0000000 --- a/internal/models/firewall_rule.go +++ /dev/null @@ -1,17 +0,0 @@ -package models - -import "time" - -type FirewallRule struct { - ID int64 `gorm:"primaryKey" json:"id"` - Chain string `gorm:"column:chain" json:"chain"` - Priority int `gorm:"column:priority" json:"priority"` - MatchExpr string `gorm:"column:match_expr" json:"match_expr"` - Action string `gorm:"column:action" json:"action"` - Comment *string `gorm:"column:comment" json:"comment,omitempty"` - Active bool `gorm:"column:active" json:"active"` - CreatedAt time.Time `gorm:"column:created_at" json:"created_at"` - UpdatedAt time.Time `gorm:"column:updated_at" json:"updated_at"` -} - -func (FirewallRule) TableName() string { return "firewall_rules" }