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
}