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>
189 lines
5.4 KiB
Go
189 lines
5.4 KiB
Go
// 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
|
|
}
|