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
|
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()
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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