feat(fw): Migration 0010 — Firewall-v2-Schema (Fortigate-Style)

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) <noreply@anthropic.com>
This commit is contained in:
Debian
2026-05-09 23:52:01 +02:00
parent e096531df2
commit e517783c42
3 changed files with 257 additions and 43 deletions

View File

@@ -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

View File

@@ -117,30 +117,12 @@ func (g *Generator) loadView(ctx context.Context) (*View, error) {
return nil, err return nil, err
} }
// Custom firewall_rules — only active, ordered by priority. // Migration 0010 hat firewall_rules komplett umgebaut (Fortigate-
ruleRows, err := g.Pool.Query(ctx, ` // Style mit address objects + service refs). Phase-2-Renderer
SELECT chain, match_expr, action, COALESCE(comment, '') // kannte das alte chain/match_expr-Schema. Bis Task #44 die
FROM firewall_rules // Render-Logik mit den neuen Joins ersetzt, geben wir hier
WHERE active // keine custom-Rules aus — Output ist nur baseline + cluster set.
ORDER BY chain ASC, priority DESC, id ASC`) // Sicher, weil baseline default-deny ist; v2-Rules kommen mit
if err != nil { // dem nächsten Renderer-Patch.
return nil, fmt.Errorf("query firewall_rules: %w", err) return view, nil
}
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()
} }

View File

@@ -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" }