feat(db): Phase 1 — DB-Schema, goose-Migrations, GORM-Models
Initialer Schema-Set (8 Migrationen, 13 Tabellen) für EdgeGuard v1: users + audit_log + system_settings, ha_nodes, backends/domains/ routing_rules/tls_certs, forward_proxy_acls, wireguard_peers, firewall_rules, dns_zones/dns_records, licenses. Migrations liegen in internal/database/migrations/ (analog mail-gateway) und werden per //go:embed ins Binary gepackt — keine separate SQL-Dateien im .deb. ValidateMigrations + Test schützen vor Duplicate-Versionen (mail-gateway 2026-05-08-Vorfall). GORM-Models für alle Tabellen, sensible Felder (password_hash, private_key_enc) sind json:"-". Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
56
internal/database/migrations/0001_init.sql
Normal file
56
internal/database/migrations/0001_init.sql
Normal file
@@ -0,0 +1,56 @@
|
||||
-- +goose Up
|
||||
-- +goose StatementBegin
|
||||
|
||||
-- Admin users (analog enconf admin_users). Auth is JWT-based; password
|
||||
-- is bcrypt'd via golang.org/x/crypto/bcrypt. role is a free-form
|
||||
-- text column today (only "admin" exists in v1) so future RBAC roles
|
||||
-- can be added without a CHECK-constraint migration.
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
email TEXT NOT NULL,
|
||||
password_hash TEXT NOT NULL,
|
||||
role TEXT NOT NULL DEFAULT 'admin',
|
||||
active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
last_login_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
CONSTRAINT users_email_unique UNIQUE (email)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_users_active ON users (active) WHERE active;
|
||||
|
||||
-- Append-only audit trail. Every config change, login, license event,
|
||||
-- upgrade trigger gets an entry. detail is freeform JSONB so each
|
||||
-- handler can attach whatever context is meaningful (e.g. diff of the
|
||||
-- domain row, apt versions before/after, cluster-join token hash).
|
||||
CREATE TABLE IF NOT EXISTS audit_log (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
actor TEXT NOT NULL,
|
||||
action TEXT NOT NULL,
|
||||
subject TEXT,
|
||||
detail JSONB,
|
||||
node_id TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_log_created_at ON audit_log (created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_log_subject ON audit_log (subject);
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_log_action ON audit_log (action);
|
||||
|
||||
-- Global key/value config. Avoids one-row tables for things like
|
||||
-- cluster FQDN, ACME contact e-mail, default WireGuard listen port.
|
||||
-- value is TEXT — JSON encoding is on the caller if needed.
|
||||
CREATE TABLE IF NOT EXISTS system_settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- +goose StatementEnd
|
||||
|
||||
-- +goose Down
|
||||
-- +goose StatementBegin
|
||||
DROP TABLE IF EXISTS system_settings;
|
||||
DROP TABLE IF EXISTS audit_log;
|
||||
DROP TABLE IF EXISTS users;
|
||||
-- +goose StatementEnd
|
||||
35
internal/database/migrations/0002_cluster.sql
Normal file
35
internal/database/migrations/0002_cluster.sql
Normal file
@@ -0,0 +1,35 @@
|
||||
-- +goose Up
|
||||
-- +goose StatementBegin
|
||||
|
||||
-- Cluster peers. Populated by edgeguard-ctl cluster-join. Used by:
|
||||
-- * internal/proxy → resolve current PG primary URL (also via KeyDB)
|
||||
-- * internal/aggregator → fan-out reads to peer APIs
|
||||
-- * internal/unbound → generate eg.cluster local-zone records
|
||||
-- * internal/firewall → open peer-internal ports per IP
|
||||
--
|
||||
-- id is a stable UUID (not BIGSERIAL) so a node keeps its identity
|
||||
-- across re-joins / re-images. fqdn is the externally addressable
|
||||
-- name; api_url is its 9443 endpoint via mTLS.
|
||||
CREATE TABLE IF NOT EXISTS ha_nodes (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
fqdn TEXT NOT NULL,
|
||||
api_url TEXT NOT NULL,
|
||||
public_ip INET,
|
||||
internal_ip INET,
|
||||
role TEXT NOT NULL DEFAULT 'peer',
|
||||
last_seen TIMESTAMPTZ,
|
||||
joined_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
CONSTRAINT ha_nodes_fqdn_unique UNIQUE (fqdn)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_ha_nodes_last_seen ON ha_nodes (last_seen DESC);
|
||||
|
||||
-- +goose StatementEnd
|
||||
|
||||
-- +goose Down
|
||||
-- +goose StatementBegin
|
||||
DROP TABLE IF EXISTS ha_nodes;
|
||||
-- +goose StatementEnd
|
||||
88
internal/database/migrations/0003_http_frontend.sql
Normal file
88
internal/database/migrations/0003_http_frontend.sql
Normal file
@@ -0,0 +1,88 @@
|
||||
-- +goose Up
|
||||
-- +goose StatementBegin
|
||||
|
||||
-- Upstream backends (target servers behind the reverse-proxy).
|
||||
CREATE TABLE IF NOT EXISTS backends (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
scheme TEXT NOT NULL DEFAULT 'http',
|
||||
address TEXT NOT NULL,
|
||||
port INTEGER NOT NULL,
|
||||
health_check_path TEXT,
|
||||
active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
CONSTRAINT backends_name_unique UNIQUE (name),
|
||||
CONSTRAINT backends_scheme_check CHECK (scheme IN ('http', 'https')),
|
||||
CONSTRAINT backends_port_check CHECK (port > 0 AND port < 65536)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_backends_active ON backends (active) WHERE active;
|
||||
|
||||
-- Public-facing domains. primary_backend_id is the default upstream
|
||||
-- when no path-rule matches.
|
||||
CREATE TABLE IF NOT EXISTS domains (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
primary_backend_id BIGINT REFERENCES backends(id) ON DELETE SET NULL,
|
||||
http_to_https BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
hsts_enabled BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
notes TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
CONSTRAINT domains_name_unique UNIQUE (name)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_domains_active ON domains (active) WHERE active;
|
||||
|
||||
-- Path-based routing rules. Higher priority wins; ties broken by id.
|
||||
CREATE TABLE IF NOT EXISTS routing_rules (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
domain_id BIGINT NOT NULL REFERENCES domains(id) ON DELETE CASCADE,
|
||||
path_prefix TEXT NOT NULL DEFAULT '/',
|
||||
backend_id BIGINT NOT NULL REFERENCES backends(id) ON DELETE RESTRICT,
|
||||
priority INTEGER NOT NULL DEFAULT 100,
|
||||
active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_routing_rules_domain ON routing_rules (domain_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_routing_rules_priority ON routing_rules (domain_id, priority DESC);
|
||||
|
||||
-- ACME-managed TLS certificates. status mirrors certbot's view:
|
||||
-- pending — issue requested, not yet completed
|
||||
-- active — issued, currently deployed
|
||||
-- renewing — renewal in progress
|
||||
-- expired — past not_after, awaiting cleanup
|
||||
-- error — last attempt failed (see audit_log for detail)
|
||||
CREATE TABLE IF NOT EXISTS tls_certs (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
domain TEXT NOT NULL,
|
||||
issuer TEXT NOT NULL DEFAULT 'letsencrypt',
|
||||
status TEXT NOT NULL DEFAULT 'pending',
|
||||
cert_path TEXT,
|
||||
key_path TEXT,
|
||||
not_before TIMESTAMPTZ,
|
||||
not_after TIMESTAMPTZ,
|
||||
last_renewed_at TIMESTAMPTZ,
|
||||
last_error TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
CONSTRAINT tls_certs_domain_unique UNIQUE (domain),
|
||||
CONSTRAINT tls_certs_status_check CHECK (status IN ('pending', 'active', 'renewing', 'expired', 'error'))
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_tls_certs_not_after ON tls_certs (not_after);
|
||||
CREATE INDEX IF NOT EXISTS idx_tls_certs_status ON tls_certs (status);
|
||||
|
||||
-- +goose StatementEnd
|
||||
|
||||
-- +goose Down
|
||||
-- +goose StatementBegin
|
||||
DROP TABLE IF EXISTS tls_certs;
|
||||
DROP TABLE IF EXISTS routing_rules;
|
||||
DROP TABLE IF EXISTS domains;
|
||||
DROP TABLE IF EXISTS backends;
|
||||
-- +goose StatementEnd
|
||||
32
internal/database/migrations/0004_forward_proxy.sql
Normal file
32
internal/database/migrations/0004_forward_proxy.sql
Normal file
@@ -0,0 +1,32 @@
|
||||
-- +goose Up
|
||||
-- +goose StatementBegin
|
||||
|
||||
-- Squid forward-proxy ACL entries. Generator merges all active rows
|
||||
-- into /etc/edgeguard/squid/squid.conf via internal/squid templates.
|
||||
--
|
||||
-- acl_type matches squid's vocabulary: src, dst, dstdomain, port,
|
||||
-- proto, time, url_regex, urlpath_regex, ...
|
||||
-- action is allow|deny.
|
||||
CREATE TABLE IF NOT EXISTS forward_proxy_acls (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
acl_type TEXT NOT NULL,
|
||||
value TEXT NOT NULL,
|
||||
action TEXT NOT NULL,
|
||||
priority INTEGER NOT NULL DEFAULT 100,
|
||||
active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
comment TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
CONSTRAINT forward_proxy_acls_action_check CHECK (action IN ('allow', 'deny'))
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_forward_proxy_acls_priority ON forward_proxy_acls (priority DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_forward_proxy_acls_active ON forward_proxy_acls (active) WHERE active;
|
||||
|
||||
-- +goose StatementEnd
|
||||
|
||||
-- +goose Down
|
||||
-- +goose StatementBegin
|
||||
DROP TABLE IF EXISTS forward_proxy_acls;
|
||||
-- +goose StatementEnd
|
||||
41
internal/database/migrations/0005_wireguard.sql
Normal file
41
internal/database/migrations/0005_wireguard.sql
Normal file
@@ -0,0 +1,41 @@
|
||||
-- +goose Up
|
||||
-- +goose StatementBegin
|
||||
|
||||
-- WireGuard peers. v1 uses Option A (shared server identity, see
|
||||
-- docs/architecture.md §8.1) — the local server peer is row 1, type
|
||||
-- 'server'; remote site-to-site peers and road-warrior clients are
|
||||
-- separate rows.
|
||||
--
|
||||
-- private_key_enc holds the encrypted server private key (NULL for
|
||||
-- non-server rows); decryption key lives in /var/lib/edgeguard/.wg_key
|
||||
-- (mode 0600). Never logged.
|
||||
CREATE TABLE IF NOT EXISTS wireguard_peers (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
peer_type TEXT NOT NULL,
|
||||
public_key TEXT NOT NULL,
|
||||
private_key_enc TEXT,
|
||||
preshared_key_enc TEXT,
|
||||
allowed_ips TEXT NOT NULL,
|
||||
endpoint TEXT,
|
||||
listen_port INTEGER,
|
||||
persistent_keepalive INTEGER,
|
||||
last_handshake_at TIMESTAMPTZ,
|
||||
active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
notes TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
CONSTRAINT wireguard_peers_name_unique UNIQUE (name),
|
||||
CONSTRAINT wireguard_peers_pubkey_unique UNIQUE (public_key),
|
||||
CONSTRAINT wireguard_peers_type_check CHECK (peer_type IN ('server', 's2s', 'roadwarrior'))
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_wireguard_peers_active ON wireguard_peers (active) WHERE active;
|
||||
CREATE INDEX IF NOT EXISTS idx_wireguard_peers_type ON wireguard_peers (peer_type);
|
||||
|
||||
-- +goose StatementEnd
|
||||
|
||||
-- +goose Down
|
||||
-- +goose StatementBegin
|
||||
DROP TABLE IF EXISTS wireguard_peers;
|
||||
-- +goose StatementEnd
|
||||
35
internal/database/migrations/0006_firewall.sql
Normal file
35
internal/database/migrations/0006_firewall.sql
Normal file
@@ -0,0 +1,35 @@
|
||||
-- +goose Up
|
||||
-- +goose StatementBegin
|
||||
|
||||
-- nftables custom rules. Generator merges these into the ruleset
|
||||
-- emitted by internal/firewall on top of the base inet edgeguard
|
||||
-- table. v1 has no CrowdSec/threat_intel sets (see architecture.md
|
||||
-- §8 — those entries entfallen ohne IDS/IPS).
|
||||
--
|
||||
-- chain examples: input, forward, output, prerouting, postrouting.
|
||||
-- match is the literal nft expression body, e.g.
|
||||
-- "tcp dport 22 ip saddr 10.0.0.0/8"
|
||||
-- action: accept | drop | reject | masquerade | dnat | snat
|
||||
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(),
|
||||
CONSTRAINT firewall_rules_chain_check CHECK (chain IN ('input', 'forward', 'output', 'prerouting', 'postrouting')),
|
||||
CONSTRAINT firewall_rules_action_check CHECK (action IN ('accept', 'drop', 'reject', 'masquerade', 'dnat', 'snat'))
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_firewall_rules_chain ON firewall_rules (chain, priority);
|
||||
CREATE INDEX IF NOT EXISTS idx_firewall_rules_active ON firewall_rules (active) WHERE active;
|
||||
|
||||
-- +goose StatementEnd
|
||||
|
||||
-- +goose Down
|
||||
-- +goose StatementBegin
|
||||
DROP TABLE IF EXISTS firewall_rules;
|
||||
-- +goose StatementEnd
|
||||
47
internal/database/migrations/0007_dns.sql
Normal file
47
internal/database/migrations/0007_dns.sql
Normal file
@@ -0,0 +1,47 @@
|
||||
-- +goose Up
|
||||
-- +goose StatementBegin
|
||||
|
||||
-- Unbound zones. zone_type 'local' = local-data records served
|
||||
-- authoritatively (e.g. eg.cluster). 'forward' = stub-zone forwarded
|
||||
-- to a specific upstream. The eg.cluster zone is auto-managed by
|
||||
-- internal/cluster; user-defined zones land here too.
|
||||
CREATE TABLE IF NOT EXISTS dns_zones (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
zone_type TEXT NOT NULL DEFAULT 'local',
|
||||
description TEXT,
|
||||
managed_by TEXT NOT NULL DEFAULT 'user',
|
||||
active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
CONSTRAINT dns_zones_name_unique UNIQUE (name),
|
||||
CONSTRAINT dns_zones_type_check CHECK (zone_type IN ('local', 'forward'))
|
||||
);
|
||||
|
||||
-- DNS records. record_type matches RFC 1035 / RFC 3596 mnemonics
|
||||
-- (A, AAAA, CNAME, TXT, MX, SRV, ...). value is the record's RDATA in
|
||||
-- text form (priority + value for MX/SRV are concatenated, e.g.
|
||||
-- "10 mail.example.com").
|
||||
CREATE TABLE IF NOT EXISTS dns_records (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
zone_id BIGINT NOT NULL REFERENCES dns_zones(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
record_type TEXT NOT NULL,
|
||||
value TEXT NOT NULL,
|
||||
ttl INTEGER NOT NULL DEFAULT 300,
|
||||
active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
CONSTRAINT dns_records_type_check CHECK (record_type IN ('A', 'AAAA', 'CNAME', 'TXT', 'MX', 'SRV', 'NS', 'PTR', 'CAA'))
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_dns_records_zone ON dns_records (zone_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_dns_records_active ON dns_records (active) WHERE active;
|
||||
|
||||
-- +goose StatementEnd
|
||||
|
||||
-- +goose Down
|
||||
-- +goose StatementBegin
|
||||
DROP TABLE IF EXISTS dns_records;
|
||||
DROP TABLE IF EXISTS dns_zones;
|
||||
-- +goose StatementEnd
|
||||
38
internal/database/migrations/0008_licenses.sql
Normal file
38
internal/database/migrations/0008_licenses.sql
Normal file
@@ -0,0 +1,38 @@
|
||||
-- +goose Up
|
||||
-- +goose StatementBegin
|
||||
|
||||
-- License state cache. The license-leader (KeyDB-locked, see
|
||||
-- architecture.md §12.1) calls https://license.netcell-it.com and
|
||||
-- mirrors the result here. Every node reads from this table for its
|
||||
-- "is the license valid?" check; the KeyDB key cluster:license-status
|
||||
-- is just the in-flight TTL cache layered on top.
|
||||
--
|
||||
-- payload holds the verbatim verify-response (limits, feature flags,
|
||||
-- expiry). active_domains_at_verify is the value that was sent to the
|
||||
-- license server — kept for audit / re-issue (so we can prove what
|
||||
-- we reported on a given day).
|
||||
CREATE TABLE IF NOT EXISTS licenses (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
license_key TEXT NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
valid_until TIMESTAMPTZ,
|
||||
last_verified_at TIMESTAMPTZ,
|
||||
last_verified_node TEXT,
|
||||
active_domains_at_verify INTEGER,
|
||||
payload JSONB,
|
||||
last_error TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
CONSTRAINT licenses_key_unique UNIQUE (license_key),
|
||||
CONSTRAINT licenses_status_check CHECK (status IN ('trial', 'active', 'expired', 'invalid', 'unknown'))
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_licenses_valid_until ON licenses (valid_until);
|
||||
CREATE INDEX IF NOT EXISTS idx_licenses_status ON licenses (status);
|
||||
|
||||
-- +goose StatementEnd
|
||||
|
||||
-- +goose Down
|
||||
-- +goose StatementBegin
|
||||
DROP TABLE IF EXISTS licenses;
|
||||
-- +goose StatementEnd
|
||||
Reference in New Issue
Block a user