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:
Debian
2026-05-08 23:44:44 +02:00
parent 9f75eec756
commit b307a7b1f7
29 changed files with 900 additions and 27 deletions

188
internal/database/db.go Normal file
View File

@@ -0,0 +1,188 @@
// Package database owns the PostgreSQL connection pool and migration
// runner for edgeguard-api.
//
// All routes read and write through a *pgxpool.Pool (jackc/pgx v5).
// GORM (in internal/models) is used for struct tags / query convenience
// against the same DSN; the schema itself is owned by goose
// (// +goose Up/Down files in migrations/).
package database
import (
"context"
"embed"
"errors"
"fmt"
"os"
"path/filepath"
"regexp"
"strconv"
"strings"
"time"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/pressly/goose/v3"
_ "github.com/jackc/pgx/v5/stdlib" // register "pgx" driver for goose
)
//go:embed migrations/*.sql
var embeddedMigrations embed.FS
// ConnStringFromEnv returns the DSN for edgeguard-api. Priority:
//
// 1. EDGEGUARD_DB_URL as a full libpq/pgx URL or key=value DSN
// 2. /etc/edgeguard/api.env line "EDGEGUARD_DB_URL=..."
// 3. default: unix-socket peer auth against db "edgeguard"
//
// The default matches the layout edgeguard-ctl initdb sets up
// (see docs/architecture.md §6).
func ConnStringFromEnv() string {
if v := os.Getenv("EDGEGUARD_DB_URL"); v != "" {
return v
}
if data, err := os.ReadFile("/etc/edgeguard/api.env"); err == nil {
for _, line := range strings.Split(string(data), "\n") {
line = strings.TrimSpace(line)
if strings.HasPrefix(line, "EDGEGUARD_DB_URL=") {
return strings.TrimSpace(strings.TrimPrefix(line, "EDGEGUARD_DB_URL="))
}
}
}
return "host=/var/run/postgresql dbname=edgeguard sslmode=disable"
}
// Open constructs a pool and pings the server. Never returns a pool
// that failed its initial ping.
func Open(ctx context.Context, dsn string) (*pgxpool.Pool, error) {
if dsn == "" {
return nil, errors.New("empty DSN")
}
cfg, err := pgxpool.ParseConfig(dsn)
if err != nil {
return nil, fmt.Errorf("parse DSN: %w", err)
}
if cfg.MaxConns == 0 {
cfg.MaxConns = 10
}
cfg.MaxConnLifetime = 30 * time.Minute
pool, err := pgxpool.NewWithConfig(ctx, cfg)
if err != nil {
return nil, fmt.Errorf("open pool: %w", err)
}
pingCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
if err := pool.Ping(pingCtx); err != nil {
pool.Close()
return nil, fmt.Errorf("ping: %w", err)
}
return pool, nil
}
// Migrate runs goose `up` with the embedded migrations. Pass an empty
// dsnOverride to use ConnStringFromEnv().
//
// Pre-flight ValidateMigrations runs first so a duplicate version
// number fails with a clear message instead of goose's panic deep in
// migrate.go (mail-gateway 2026-05-08 incident).
func Migrate(ctx context.Context, dsnOverride string) error {
if err := ValidateMigrations(); err != nil {
return err
}
dsn := dsnOverride
if dsn == "" {
dsn = ConnStringFromEnv()
}
db, err := goose.OpenDBWithDriver("pgx", dsn)
if err != nil {
return fmt.Errorf("open db for migrate: %w", err)
}
defer db.Close()
goose.SetBaseFS(embeddedMigrations)
if err := goose.SetDialect("postgres"); err != nil {
return err
}
return goose.RunContext(ctx, "up", db, "migrations")
}
// MigrateDown rolls back the most recent migration. Used in tests and
// development; production is forward-only.
func MigrateDown(ctx context.Context, dsnOverride string) error {
dsn := dsnOverride
if dsn == "" {
dsn = ConnStringFromEnv()
}
db, err := goose.OpenDBWithDriver("pgx", dsn)
if err != nil {
return err
}
defer db.Close()
goose.SetBaseFS(embeddedMigrations)
if err := goose.SetDialect("postgres"); err != nil {
return err
}
return goose.RunContext(ctx, "down", db, "migrations")
}
// ValidateMigrations checks the embedded migration files for duplicate
// version prefixes. Called from Migrate() (runtime), from
// `edgeguard-ctl migrate check` (postinst pre-flight), and from
// TestEmbeddedMigrationsUnique (CI gate).
func ValidateMigrations() error {
entries, err := embeddedMigrations.ReadDir("migrations")
if err != nil {
return fmt.Errorf("read embedded migrations: %w", err)
}
versionToFile := map[int64]string{}
versionRe := regexp.MustCompile(`^(\d+)_[^/]+\.sql$`)
var problems []string
for _, e := range entries {
if e.IsDir() || !strings.HasSuffix(e.Name(), ".sql") {
continue
}
m := versionRe.FindStringSubmatch(e.Name())
if m == nil {
problems = append(problems, fmt.Sprintf("%s: filename does not match <version>_<name>.sql", e.Name()))
continue
}
v, err := strconv.ParseInt(m[1], 10, 64)
if err != nil {
problems = append(problems, fmt.Sprintf("%s: version is not an integer", e.Name()))
continue
}
if existing, dup := versionToFile[v]; dup {
problems = append(problems, fmt.Sprintf(
"duplicate migration version %d: %s and %s",
v, existing, e.Name()))
continue
}
versionToFile[v] = e.Name()
}
if len(problems) > 0 {
return fmt.Errorf("migration validation failed:\n %s", strings.Join(problems, "\n "))
}
return nil
}
// CopyEmbeddedMigrationsTo writes the embedded SQL files to dst. Used
// by edgeguard-ctl to dump the embedded set for manual inspection.
func CopyEmbeddedMigrationsTo(dst string) error {
if err := os.MkdirAll(dst, 0o755); err != nil {
return err
}
entries, err := embeddedMigrations.ReadDir("migrations")
if err != nil {
return err
}
for _, e := range entries {
data, err := embeddedMigrations.ReadFile(filepath.ToSlash(filepath.Join("migrations", e.Name())))
if err != nil {
return err
}
if err := os.WriteFile(filepath.Join(dst, e.Name()), data, 0o644); err != nil {
return err
}
}
return nil
}

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View File

@@ -0,0 +1,19 @@
package database
import "testing"
// TestEmbeddedMigrationsUnique guards against duplicate migration
// version prefixes. mail-gateway hit this on 2026-05-08 when two
// parallel agents both committed a 0092_*.sql; goose panicked at
// startup, the API restart-looped, the cluster rolling-upgrade hung.
//
// Cheap assertion that runs as part of `go test ./...` — fails the
// build before `make deb` ever produces an artefact, so the bad
// version never reaches the APT registry. Same logic also runs at
// service start via Migrate() and via `edgeguard-ctl migrate check`
// in postinst (defense in depth).
func TestEmbeddedMigrationsUnique(t *testing.T) {
if err := ValidateMigrations(); err != nil {
t.Fatalf("embedded migrations failed validation: %v", err)
}
}

18
internal/models/audit.go Normal file
View File

@@ -0,0 +1,18 @@
package models
import (
"encoding/json"
"time"
)
type AuditLog struct {
ID int64 `gorm:"primaryKey" json:"id"`
Actor string `gorm:"column:actor" json:"actor"`
Action string `gorm:"column:action" json:"action"`
Subject *string `gorm:"column:subject" json:"subject,omitempty"`
Detail json.RawMessage `gorm:"column:detail;type:jsonb" json:"detail,omitempty"`
NodeID *string `gorm:"column:node_id" json:"node_id,omitempty"`
CreatedAt time.Time `gorm:"column:created_at" json:"created_at"`
}
func (AuditLog) TableName() string { return "audit_log" }

View File

@@ -0,0 +1,17 @@
package models
import "time"
type Backend struct {
ID int64 `gorm:"primaryKey" json:"id"`
Name string `gorm:"column:name;uniqueIndex" json:"name"`
Scheme string `gorm:"column:scheme" json:"scheme"`
Address string `gorm:"column:address" json:"address"`
Port int `gorm:"column:port" json:"port"`
HealthCheckPath *string `gorm:"column:health_check_path" json:"health_check_path,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 (Backend) TableName() string { return "backends" }

30
internal/models/dns.go Normal file
View File

@@ -0,0 +1,30 @@
package models
import "time"
type DNSZone struct {
ID int64 `gorm:"primaryKey" json:"id"`
Name string `gorm:"column:name;uniqueIndex" json:"name"`
ZoneType string `gorm:"column:zone_type" json:"zone_type"`
Description *string `gorm:"column:description" json:"description,omitempty"`
ManagedBy string `gorm:"column:managed_by" json:"managed_by"`
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 (DNSZone) TableName() string { return "dns_zones" }
type DNSRecord struct {
ID int64 `gorm:"primaryKey" json:"id"`
ZoneID int64 `gorm:"column:zone_id" json:"zone_id"`
Name string `gorm:"column:name" json:"name"`
RecordType string `gorm:"column:record_type" json:"record_type"`
Value string `gorm:"column:value" json:"value"`
TTL int `gorm:"column:ttl" json:"ttl"`
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 (DNSRecord) TableName() string { return "dns_records" }

9
internal/models/doc.go Normal file
View File

@@ -0,0 +1,9 @@
// Package models holds the GORM data models for edgeguard-api: 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.
//
// Schema is owned by goose (internal/database/migrations/) — these
// structs only describe the row layout for query convenience; never
// rely on GORM's AutoMigrate against this package.
package models

17
internal/models/domain.go Normal file
View File

@@ -0,0 +1,17 @@
package models
import "time"
type Domain struct {
ID int64 `gorm:"primaryKey" json:"id"`
Name string `gorm:"column:name;uniqueIndex" json:"name"`
Active bool `gorm:"column:active" json:"active"`
PrimaryBackendID *int64 `gorm:"column:primary_backend_id" json:"primary_backend_id,omitempty"`
HTTPToHTTPS bool `gorm:"column:http_to_https" json:"http_to_https"`
HSTSEnabled bool `gorm:"column:hsts_enabled" json:"hsts_enabled"`
Notes *string `gorm:"column:notes" json:"notes,omitempty"`
CreatedAt time.Time `gorm:"column:created_at" json:"created_at"`
UpdatedAt time.Time `gorm:"column:updated_at" json:"updated_at"`
}
func (Domain) TableName() string { return "domains" }

View File

@@ -0,0 +1,17 @@
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" }

View File

@@ -0,0 +1,18 @@
package models
import "time"
type ForwardProxyACL struct {
ID int64 `gorm:"primaryKey" json:"id"`
Name string `gorm:"column:name" json:"name"`
ACLType string `gorm:"column:acl_type" json:"acl_type"`
Value string `gorm:"column:value" json:"value"`
Action string `gorm:"column:action" json:"action"`
Priority int `gorm:"column:priority" json:"priority"`
Active bool `gorm:"column:active" json:"active"`
Comment *string `gorm:"column:comment" json:"comment,omitempty"`
CreatedAt time.Time `gorm:"column:created_at" json:"created_at"`
UpdatedAt time.Time `gorm:"column:updated_at" json:"updated_at"`
}
func (ForwardProxyACL) TableName() string { return "forward_proxy_acls" }

View File

@@ -0,0 +1,19 @@
package models
import "time"
type HANode struct {
ID string `gorm:"column:id;primaryKey" json:"id"`
Name string `gorm:"column:name" json:"name"`
FQDN string `gorm:"column:fqdn;uniqueIndex" json:"fqdn"`
APIURL string `gorm:"column:api_url" json:"api_url"`
PublicIP *string `gorm:"column:public_ip;type:inet" json:"public_ip,omitempty"`
InternalIP *string `gorm:"column:internal_ip;type:inet" json:"internal_ip,omitempty"`
Role string `gorm:"column:role" json:"role"`
LastSeen *time.Time `gorm:"column:last_seen" json:"last_seen,omitempty"`
JoinedAt time.Time `gorm:"column:joined_at" json:"joined_at"`
CreatedAt time.Time `gorm:"column:created_at" json:"created_at"`
UpdatedAt time.Time `gorm:"column:updated_at" json:"updated_at"`
}
func (HANode) TableName() string { return "ha_nodes" }

View File

@@ -0,0 +1,22 @@
package models
import (
"encoding/json"
"time"
)
type License struct {
ID int64 `gorm:"primaryKey" json:"id"`
LicenseKey string `gorm:"column:license_key;uniqueIndex" json:"license_key"`
Status string `gorm:"column:status" json:"status"`
ValidUntil *time.Time `gorm:"column:valid_until" json:"valid_until,omitempty"`
LastVerifiedAt *time.Time `gorm:"column:last_verified_at" json:"last_verified_at,omitempty"`
LastVerifiedNode *string `gorm:"column:last_verified_node" json:"last_verified_node,omitempty"`
ActiveDomainsAtVerify *int `gorm:"column:active_domains_at_verify" json:"active_domains_at_verify,omitempty"`
Payload json.RawMessage `gorm:"column:payload;type:jsonb" json:"payload,omitempty"`
LastError *string `gorm:"column:last_error" json:"last_error,omitempty"`
CreatedAt time.Time `gorm:"column:created_at" json:"created_at"`
UpdatedAt time.Time `gorm:"column:updated_at" json:"updated_at"`
}
func (License) TableName() string { return "licenses" }

View File

@@ -0,0 +1,16 @@
package models
import "time"
type RoutingRule struct {
ID int64 `gorm:"primaryKey" json:"id"`
DomainID int64 `gorm:"column:domain_id" json:"domain_id"`
PathPrefix string `gorm:"column:path_prefix" json:"path_prefix"`
BackendID int64 `gorm:"column:backend_id" json:"backend_id"`
Priority int `gorm:"column:priority" json:"priority"`
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 (RoutingRule) TableName() string { return "routing_rules" }

View File

@@ -0,0 +1,11 @@
package models
import "time"
type SystemSetting struct {
Key string `gorm:"column:key;primaryKey" json:"key"`
Value string `gorm:"column:value" json:"value"`
UpdatedAt time.Time `gorm:"column:updated_at" json:"updated_at"`
}
func (SystemSetting) TableName() string { return "system_settings" }

View File

@@ -0,0 +1,20 @@
package models
import "time"
type TLSCert struct {
ID int64 `gorm:"primaryKey" json:"id"`
Domain string `gorm:"column:domain;uniqueIndex" json:"domain"`
Issuer string `gorm:"column:issuer" json:"issuer"`
Status string `gorm:"column:status" json:"status"`
CertPath *string `gorm:"column:cert_path" json:"cert_path,omitempty"`
KeyPath *string `gorm:"column:key_path" json:"key_path,omitempty"`
NotBefore *time.Time `gorm:"column:not_before" json:"not_before,omitempty"`
NotAfter *time.Time `gorm:"column:not_after" json:"not_after,omitempty"`
LastRenewedAt *time.Time `gorm:"column:last_renewed_at" json:"last_renewed_at,omitempty"`
LastError *string `gorm:"column:last_error" json:"last_error,omitempty"`
CreatedAt time.Time `gorm:"column:created_at" json:"created_at"`
UpdatedAt time.Time `gorm:"column:updated_at" json:"updated_at"`
}
func (TLSCert) TableName() string { return "tls_certs" }

16
internal/models/user.go Normal file
View File

@@ -0,0 +1,16 @@
package models
import "time"
type User struct {
ID int64 `gorm:"primaryKey" json:"id"`
Email string `gorm:"column:email;uniqueIndex" json:"email"`
PasswordHash string `gorm:"column:password_hash" json:"-"`
Role string `gorm:"column:role" json:"role"`
Active bool `gorm:"column:active" json:"active"`
LastLoginAt *time.Time `gorm:"column:last_login_at" json:"last_login_at,omitempty"`
CreatedAt time.Time `gorm:"column:created_at" json:"created_at"`
UpdatedAt time.Time `gorm:"column:updated_at" json:"updated_at"`
}
func (User) TableName() string { return "users" }

View File

@@ -0,0 +1,23 @@
package models
import "time"
type WireGuardPeer struct {
ID int64 `gorm:"primaryKey" json:"id"`
Name string `gorm:"column:name;uniqueIndex" json:"name"`
PeerType string `gorm:"column:peer_type" json:"peer_type"`
PublicKey string `gorm:"column:public_key;uniqueIndex" json:"public_key"`
PrivateKeyEnc *string `gorm:"column:private_key_enc" json:"-"`
PresharedKeyEnc *string `gorm:"column:preshared_key_enc" json:"-"`
AllowedIPs string `gorm:"column:allowed_ips" json:"allowed_ips"`
Endpoint *string `gorm:"column:endpoint" json:"endpoint,omitempty"`
ListenPort *int `gorm:"column:listen_port" json:"listen_port,omitempty"`
PersistentKeepalive *int `gorm:"column:persistent_keepalive" json:"persistent_keepalive,omitempty"`
LastHandshakeAt *time.Time `gorm:"column:last_handshake_at" json:"last_handshake_at,omitempty"`
Active bool `gorm:"column:active" json:"active"`
Notes *string `gorm:"column:notes" json:"notes,omitempty"`
CreatedAt time.Time `gorm:"column:created_at" json:"created_at"`
UpdatedAt time.Time `gorm:"column:updated_at" json:"updated_at"`
}
func (WireGuardPeer) TableName() string { return "wireguard_peers" }