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