Align with engineering standards (steps 1-5)

- Rename dist/ -> deploy/ with subdirs examples/, scripts/,
  systemd/ per standard repository layout
- Update .gitignore: gitignore all of dist/ (build output only)
- Makefile: all target is now vet->lint->test->build; add vet,
  proto-lint, devserver targets; CGO_ENABLED=0 for builds
  (modernc.org/sqlite is pure-Go, no C toolchain needed);
  CGO_ENABLED=1 retained for tests (race detector)
- Dockerfile: builder -> golang:1.26-alpine, runtime ->
  alpine:3.21; drop libc6 dep; add /srv/mcias/certs and
  /srv/mcias/backups to image
- deploy/systemd/mcias.service: add RestrictSUIDSGID=true
- deploy/systemd/mcias-backup.service: new oneshot backup unit
- deploy/systemd/mcias-backup.timer: daily 02:00 UTC, 5m jitter
- deploy/scripts/install.sh: install backup units and enable
  timer; create certs/ and backups/ subdirs in /srv/mcias
- buf.yaml: add proto linting config for proto-lint target
- internal/db: add Snapshot and SnapshotDir methods (VACUUM INTO)
- cmd/mciasdb: add snapshot subcommand; no master key required
This commit is contained in:
2026-03-16 20:26:43 -07:00
parent 446b3df52d
commit b0afe3b993
15 changed files with 293 additions and 62 deletions

View File

@@ -40,7 +40,7 @@
// pgcreds get --id UUID
// pgcreds set --id UUID --host H --port P --db D --user U
//
// rekey
// snapshot [--retain-days N]
package main
import (
@@ -68,6 +68,14 @@ func main() {
command := args[0]
subArgs := args[1:]
// snapshot loads only the config (no master key needed — VACUUM INTO does
// not access encrypted columns) and must be handled before openDB, which
// requires the master key passphrase env var.
if command == "snapshot" {
runSnapshot(*configPath, subArgs)
return
}
// schema subcommands manage migrations themselves and must not trigger
// auto-migration on open (a dirty database would prevent the tool from
// opening at all, blocking recovery operations like "schema force").
@@ -273,6 +281,11 @@ Commands:
rekey Re-encrypt all secrets under a new master passphrase
(prompts interactively; requires server to be stopped)
snapshot Write a timestamped VACUUM INTO backup to
<db-dir>/backups/; prune backups older than
--retain-days days (default 30, 0 = keep all).
Does not require the master key passphrase.
NOTE: mciasdb bypasses the mciassrv API and operates directly on the SQLite
file. Use it only when the server is unavailable or for break-glass recovery.
All write operations are recorded in the audit log.

44
cmd/mciasdb/snapshot.go Normal file
View File

@@ -0,0 +1,44 @@
package main
import (
"flag"
"fmt"
"path/filepath"
"git.wntrmute.dev/kyle/mcias/internal/config"
"git.wntrmute.dev/kyle/mcias/internal/db"
)
// runSnapshot handles the "snapshot" command.
//
// It opens the database read-only (no master key derivation needed — VACUUM
// INTO does not access encrypted columns) and writes a timestamped backup to
// /srv/mcias/backups/ (or the directory adjacent to the configured DB path).
// Backups older than --retain-days are pruned.
func runSnapshot(configPath string, args []string) {
fs := flag.NewFlagSet("snapshot", flag.ExitOnError)
retainDays := fs.Int("retain-days", 30, "prune backups older than this many days (0 = keep all)")
if err := fs.Parse(args); err != nil {
fatalf("snapshot: %v", err)
}
cfg, err := config.Load(configPath)
if err != nil {
fatalf("snapshot: load config: %v", err)
}
database, err := db.Open(cfg.Database.Path)
if err != nil {
fatalf("snapshot: open database: %v", err)
}
defer func() { _ = database.Close() }()
// Place backups in a "backups" directory adjacent to the database file.
backupDir := filepath.Join(filepath.Dir(cfg.Database.Path), "backups")
dest, err := database.SnapshotDir(backupDir, *retainDays)
if err != nil {
fatalf("snapshot: %v", err)
}
fmt.Printf("snapshot written: %s\n", dest)
}