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:
249
internal/database/migrations/0010_firewall_v2.sql
Normal file
249
internal/database/migrations/0010_firewall_v2.sql
Normal 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
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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" }
|
||||
Reference in New Issue
Block a user