From 106ef95f6d44c22eaea85197ecad5f5c5ea2945a Mon Sep 17 00:00:00 2001 From: Debian Date: Sat, 9 May 2026 08:18:55 +0200 Subject: [PATCH] feat(ctl): edgeguard-ctl migrate + initdb wired into postinst MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit migrate up|down|check|dump (1:1 nmg-ctl-Pattern, ruft internal/database Migrate/MigrateDown/ValidateMigrations/CopyEmbeddedMigrationsTo). initdb prüft pg_roles/pg_database und legt Role + DB idempotent via sudo -u postgres psql an, mit Identifier-Whitelist gegen Injection. postinst wirt die drei Schritte vor systemd-enable: migrate check (Pre-Flight ohne DB), initdb, migrate up (als edgeguard-User via Socket-Peer-Auth). cluster-join/promote/dump-config bleiben explizit Phase-3-Stubs. Co-Authored-By: Claude Opus 4.7 (1M context) --- cmd/edgeguard-ctl/initdb.go | 156 ++++++++++++++++++ cmd/edgeguard-ctl/main.go | 35 ++-- cmd/edgeguard-ctl/migrate.go | 63 +++++++ .../debian/edgeguard-api/DEBIAN/postinst | 27 ++- 4 files changed, 268 insertions(+), 13 deletions(-) create mode 100644 cmd/edgeguard-ctl/initdb.go create mode 100644 cmd/edgeguard-ctl/migrate.go diff --git a/cmd/edgeguard-ctl/initdb.go b/cmd/edgeguard-ctl/initdb.go new file mode 100644 index 0000000..434ed03 --- /dev/null +++ b/cmd/edgeguard-ctl/initdb.go @@ -0,0 +1,156 @@ +package main + +import ( + "bytes" + "fmt" + "os" + "os/exec" + "strings" +) + +// cmdInitDB creates the PostgreSQL role and database for edgeguard if +// they don't already exist. Idempotent — safe to re-run from postinst +// on every package upgrade. +// +// Authentication: the local postgres super-user via unix-socket peer +// auth. edgeguard-ctl initdb is invoked from postinst (running as +// root), so it shells out via `sudo -u postgres psql -tA -c ...`. +// +// The created role has LOGIN but no password — production access is +// peer-auth from the system user `edgeguard` over the unix socket +// (architecture.md §6). +// +// Flags: +// +// --db database name (default: edgeguard) +// --role role name (default: edgeguard) +func cmdInitDB(args []string) int { + dbName := "edgeguard" + roleName := "edgeguard" + for i := 0; i < len(args); i++ { + switch args[i] { + case "--db": + if i+1 < len(args) { + dbName = args[i+1] + i++ + } + case "--role": + if i+1 < len(args) { + roleName = args[i+1] + i++ + } + case "-h", "--help": + fmt.Println("Usage: edgeguard-ctl initdb [--db NAME] [--role NAME]") + return 0 + } + } + + if !looksLikeIdentifier(dbName) || !looksLikeIdentifier(roleName) { + fmt.Fprintln(os.Stderr, "initdb: --db and --role must match [a-z_][a-z0-9_]{0,62}") + return 2 + } + + roleExists, err := psqlBoolQuery(fmt.Sprintf( + "SELECT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = '%s')", roleName)) + if err != nil { + fmt.Fprintln(os.Stderr, "initdb: check role:", err) + return 1 + } + if roleExists { + fmt.Printf("role %q already exists — leaving it\n", roleName) + } else { + if err := psqlExec(fmt.Sprintf("CREATE ROLE %s LOGIN", roleName)); err != nil { + fmt.Fprintln(os.Stderr, "initdb: create role:", err) + return 1 + } + fmt.Printf("created role %q\n", roleName) + } + + dbExists, err := psqlBoolQuery(fmt.Sprintf( + "SELECT EXISTS (SELECT 1 FROM pg_database WHERE datname = '%s')", dbName)) + if err != nil { + fmt.Fprintln(os.Stderr, "initdb: check database:", err) + return 1 + } + if dbExists { + fmt.Printf("database %q already exists — leaving it\n", dbName) + } else { + if err := psqlExec(fmt.Sprintf("CREATE DATABASE %s OWNER %s", dbName, roleName)); err != nil { + fmt.Fprintln(os.Stderr, "initdb: create database:", err) + return 1 + } + fmt.Printf("created database %q owned by %q\n", dbName, roleName) + } + + return 0 +} + +// looksLikeIdentifier guards against shell- and SQL-injection in the +// role/db flags. Only allow the standard PG identifier shape; we +// interpolate into psql -c so a quoted user-supplied string is +// hostile. +func looksLikeIdentifier(s string) bool { + if s == "" || len(s) > 63 { + return false + } + if !(s[0] == '_' || (s[0] >= 'a' && s[0] <= 'z')) { + return false + } + for _, r := range s[1:] { + ok := r == '_' || + (r >= 'a' && r <= 'z') || + (r >= '0' && r <= '9') + if !ok { + return false + } + } + return true +} + +// psqlBoolQuery runs a one-shot SELECT EXISTS query as the postgres +// super-user and returns true/false. Tuples-only + unaligned mode +// (-tA) so we get just the literal "t" or "f". +func psqlBoolQuery(sql string) (bool, error) { + out, err := psqlRun([]string{"-tA", "-c", sql}) + if err != nil { + return false, err + } + return strings.TrimSpace(string(out)) == "t", nil +} + +// psqlExec runs a CREATE ROLE / CREATE DATABASE statement as the +// postgres super-user. ON_ERROR_STOP makes psql return non-zero on +// any SQL error. +func psqlExec(sql string) error { + if _, err := psqlRun([]string{"-v", "ON_ERROR_STOP=1", "-c", sql}); err != nil { + return err + } + return nil +} + +// psqlRun shells out to `sudo -u postgres psql` with the given args. +// When edgeguard-ctl initdb is already running as the postgres user +// (e.g. an admin re-running it manually), skip sudo so it works +// without sudoers configuration. +func psqlRun(args []string) ([]byte, error) { + cmd := buildPsqlCmd(args) + var stderr bytes.Buffer + cmd.Stderr = &stderr + out, err := cmd.Output() + if err != nil { + msg := strings.TrimSpace(stderr.String()) + if msg != "" { + return nil, fmt.Errorf("%w: %s", err, msg) + } + return nil, err + } + return out, nil +} + +func buildPsqlCmd(args []string) *exec.Cmd { + if u := os.Getenv("USER"); u == "postgres" { + return exec.Command("psql", args...) + } + full := append([]string{"-n", "-u", "postgres", "psql"}, args...) + return exec.Command("sudo", full...) +} diff --git a/cmd/edgeguard-ctl/main.go b/cmd/edgeguard-ctl/main.go index 85caece..2439115 100644 --- a/cmd/edgeguard-ctl/main.go +++ b/cmd/edgeguard-ctl/main.go @@ -1,3 +1,7 @@ +// Command edgeguard-ctl is the admin CLI for setup, migrations and +// (later) cluster ops. v1 wires migrate + initdb so postinst can +// initialise a fresh node; cluster-* and promote remain stubs until +// Phase 3. package main import ( @@ -13,14 +17,15 @@ Usage: edgeguard-ctl [args] Commands: - version Print version - initdb Initialise PostgreSQL database and user (idempotent) - migrate up Apply pending migrations - migrate down Roll back last migration (dev only) - cluster-join Join an existing cluster (--from URL --token TOKEN) - cluster-leave Leave the cluster cleanly - promote Promote this node's PG to primary - dump-config Print effective config to stdout + version Print version and exit + migrate up Apply pending migrations + migrate down Roll back the most recent migration (dev only) + migrate check Validate embedded migrations (no DB connect) + migrate dump [dir] Write embedded SQL files to dir (default: ./migrations) + initdb Create PostgreSQL role + database (idempotent) + cluster-join Join an existing cluster (Phase 3, not yet implemented) + promote Promote this node's PG to primary (Phase 3, not yet implemented) + dump-config Print effective config (Phase 3, not yet implemented) ` func main() { @@ -29,12 +34,20 @@ func main() { os.Exit(2) } switch os.Args[1] { - case "version": - fmt.Println(version) case "-h", "--help", "help": fmt.Print(usage) + case "version", "--version": + fmt.Println(version) + case "migrate": + os.Exit(cmdMigrate(os.Args[2:])) + case "initdb": + os.Exit(cmdInitDB(os.Args[2:])) + case "cluster-join", "cluster-leave", "promote", "dump-config": + fmt.Fprintf(os.Stderr, "edgeguard-ctl: %q is a Phase-3 stub — not yet implemented\n", os.Args[1]) + os.Exit(1) default: - fmt.Fprintf(os.Stderr, "edgeguard-ctl: command %q not yet implemented\n", os.Args[1]) + fmt.Fprintf(os.Stderr, "edgeguard-ctl: unknown command %q\n", os.Args[1]) + fmt.Fprint(os.Stderr, usage) os.Exit(2) } } diff --git a/cmd/edgeguard-ctl/migrate.go b/cmd/edgeguard-ctl/migrate.go new file mode 100644 index 0000000..a4d7386 --- /dev/null +++ b/cmd/edgeguard-ctl/migrate.go @@ -0,0 +1,63 @@ +package main + +import ( + "context" + "fmt" + "os" + "time" + + "git.netcell-it.de/projekte/edgeguard-native/internal/database" +) + +// cmdMigrate fans out to the subcommand handlers. `check` is offline +// (filename validation against the embedded FS) and is the safe +// pre-flight call from postinst — it catches duplicate version +// prefixes before the DB ever gets touched. +func cmdMigrate(args []string) int { + if len(args) == 0 { + fmt.Fprintln(os.Stderr, "Usage: edgeguard-ctl migrate up|down|check|dump [dir]") + return 2 + } + if args[0] == "check" { + if err := database.ValidateMigrations(); err != nil { + fmt.Fprintln(os.Stderr, "migrate check:", err) + return 1 + } + fmt.Println("embedded migrations OK") + return 0 + } + + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + + switch args[0] { + case "up": + if err := database.Migrate(ctx, ""); err != nil { + fmt.Fprintln(os.Stderr, "migrate up:", err) + return 1 + } + fmt.Println("migrations applied") + return 0 + case "down": + if err := database.MigrateDown(ctx, ""); err != nil { + fmt.Fprintln(os.Stderr, "migrate down:", err) + return 1 + } + fmt.Println("one migration rolled back") + return 0 + case "dump": + dst := "./migrations" + if len(args) >= 2 { + dst = args[1] + } + if err := database.CopyEmbeddedMigrationsTo(dst); err != nil { + fmt.Fprintln(os.Stderr, "migrate dump:", err) + return 1 + } + fmt.Println("embedded migrations written to", dst) + return 0 + default: + fmt.Fprintln(os.Stderr, "Usage: edgeguard-ctl migrate up|down|check|dump [dir]") + return 2 + } +} diff --git a/packaging/debian/edgeguard-api/DEBIAN/postinst b/packaging/debian/edgeguard-api/DEBIAN/postinst index 86eaefa..eb1e0a5 100755 --- a/packaging/debian/edgeguard-api/DEBIAN/postinst +++ b/packaging/debian/edgeguard-api/DEBIAN/postinst @@ -1,7 +1,7 @@ #!/bin/bash # postinst for edgeguard-api — creates system user, filesystem layout, -# enables systemd units. DB init + migrations run lazily on first start -# of edgeguard-api. +# initialises PostgreSQL (role + db + migrations), enables systemd +# units. Each step idempotent; safe to re-run on every upgrade. set -e export LC_ALL=C @@ -28,6 +28,29 @@ case "$1" in install -d -m 0750 -o "$EG_USER" -g "$EG_USER" "$d" done + # ── Pre-flight: validate embedded migration set ────────────── + # Catches duplicate version prefixes BEFORE we touch the DB, + # so a broken upgrade can't half-apply migrations and leave + # the cluster wedged (mail-gateway 2026-05-08 incident). + if ! /usr/bin/edgeguard-ctl migrate check; then + echo "postinst: embedded migrations failed validation — aborting" >&2 + exit 1 + fi + + # ── PostgreSQL: ensure role + database exist ───────────────── + # Requires postgresql-16 (or -17) running locally — guaranteed + # by Depends. Idempotent — re-runs on upgrade are no-ops. + if ! /usr/bin/edgeguard-ctl initdb; then + echo "postinst: edgeguard-ctl initdb failed — aborting" >&2 + exit 1 + fi + + # ── Apply pending schema migrations ────────────────────────── + if ! sudo -n -u "$EG_USER" /usr/bin/edgeguard-ctl migrate up; then + echo "postinst: edgeguard-ctl migrate up failed — aborting" >&2 + exit 1 + fi + # ── systemd ────────────────────────────────────────────────── systemctl daemon-reload systemctl enable --now edgeguard-api.service edgeguard-scheduler.service || true