diff --git a/.gitignore b/.gitignore index 5f470ab..215353e 100644 --- a/.gitignore +++ b/.gitignore @@ -20,7 +20,8 @@ mcias.toml *~ go.work go.work.sum -dist/mcias_*.tar.gz +# dist/ is purely build output (tarballs); never commit it +dist/ man/man1/*.gz # Client library build artifacts diff --git a/Dockerfile b/Dockerfile index 5877f41..5a66a0d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,7 +1,11 @@ # Dockerfile — MCIAS multi-stage container image # # Stage 1 (builder): Compiles all four MCIAS binaries. -# Stage 2 (runtime): Minimal Debian image containing only the binaries. +# Stage 2 (runtime): Minimal Alpine image containing only the binaries. +# +# modernc.org/sqlite is a pure-Go, CGo-free SQLite port. CGO_ENABLED=0 +# produces fully static binaries with no C library dependencies, which +# deploy cleanly onto a minimal Alpine runtime image. # # The final image: # - Runs as non-root uid 10001 (mcias) @@ -24,7 +28,7 @@ # --------------------------------------------------------------------------- # Stage 1 — builder # --------------------------------------------------------------------------- -FROM golang:1.26-bookworm AS builder +FROM golang:1.26-alpine AS builder WORKDIR /build @@ -35,35 +39,29 @@ RUN go mod download # Copy source. COPY . . -# CGO_ENABLED=1 is required by modernc.org/sqlite (pure-Go CGo-free SQLite). +# CGO_ENABLED=0: modernc.org/sqlite is pure Go; no C toolchain required. # -trimpath removes local file system paths from the binary. # -ldflags="-s -w" strips the DWARF debug info and symbol table to reduce # image size. -RUN CGO_ENABLED=1 go build -trimpath -ldflags="-s -w" -o /out/mciassrv ./cmd/mciassrv && \ - CGO_ENABLED=1 go build -trimpath -ldflags="-s -w" -o /out/mciasctl ./cmd/mciasctl && \ - CGO_ENABLED=1 go build -trimpath -ldflags="-s -w" -o /out/mciasdb ./cmd/mciasdb && \ - CGO_ENABLED=1 go build -trimpath -ldflags="-s -w" -o /out/mciasgrpcctl ./cmd/mciasgrpcctl +RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/mciassrv ./cmd/mciassrv && \ + CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/mciasctl ./cmd/mciasctl && \ + CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/mciasdb ./cmd/mciasdb && \ + CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/mciasgrpcctl ./cmd/mciasgrpcctl # --------------------------------------------------------------------------- # Stage 2 — runtime # --------------------------------------------------------------------------- -FROM debian:bookworm-slim +FROM alpine:3.21 -# Install runtime dependencies. # ca-certificates: required to validate external TLS certificates. -# libc6: required by CGo-compiled binaries (sqlite). -RUN apt-get update && \ - apt-get install -y --no-install-recommends \ - ca-certificates \ - libc6 && \ - rm -rf /var/lib/apt/lists/* +RUN apk add --no-cache ca-certificates # Create a non-root user for the service. # uid/gid 10001 is chosen to be well above the range typically assigned to # system users (1–999) and human users (1000+), reducing the chance of # collision with existing uids on the host when using host networking. -RUN groupadd --gid 10001 mcias && \ - useradd --uid 10001 --gid 10001 --no-create-home --shell /usr/sbin/nologin mcias +RUN addgroup -g 10001 mcias && \ + adduser -u 10001 -G mcias -H -s /sbin/nologin -D mcias # Copy compiled binaries from the builder stage. COPY --from=builder /out/mciassrv /usr/local/bin/mciassrv @@ -73,8 +71,8 @@ COPY --from=builder /out/mciasgrpcctl /usr/local/bin/mciasgrpcctl # Create the data directory. # /srv/mcias is mounted from the host with config, TLS certs, and database. -RUN mkdir -p /srv/mcias && \ - chown mcias:mcias /srv/mcias && \ +RUN mkdir -p /srv/mcias/certs /srv/mcias/backups && \ + chown -R mcias:mcias /srv/mcias && \ chmod 0750 /srv/mcias # Declare /srv/mcias as a volume so the operator must explicitly mount it. @@ -92,6 +90,6 @@ USER mcias # Default entry point and config path. # The operator mounts /srv/mcias from the host containing mcias.toml, # TLS cert/key, and the SQLite database. -# See dist/mcias.conf.docker.example for a suitable template. +# See deploy/examples/mcias.conf.docker.example for a suitable template. ENTRYPOINT ["mciassrv"] CMD ["-config", "/srv/mcias/mcias.toml"] diff --git a/Makefile b/Makefile index 437d4da..7758a2c 100644 --- a/Makefile +++ b/Makefile @@ -3,10 +3,14 @@ # Usage: # make build — compile all binaries to bin/ # make test — run tests with race detector +# make vet — run go vet # make lint — run golangci-lint +# make all — vet → lint → test → build (CI pipeline) # make generate — regenerate protobuf stubs (requires protoc) +# make proto-lint — lint proto files with buf # make man — build compressed man pages -# make install — run dist/install.sh (requires root) +# make install — run deploy/scripts/install.sh (requires root) +# make devserver — build and run mciassrv against run/ config # make clean — remove bin/ and generated artifacts # make dist — build release tarballs for linux/amd64 and linux/arm64 # make docker — build Docker image tagged mcias:$(VERSION) and mcias:latest @@ -27,20 +31,25 @@ MAN_PAGES := $(MAN_DIR)/mciassrv.1 $(MAN_DIR)/mciasctl.1 \ VERSION := $(shell git describe --tags --always 2>/dev/null || echo dev) # Build flags: trim paths from binaries and strip DWARF/symbol table. -# CGO_ENABLED=1 is required for modernc.org/sqlite. +# modernc.org/sqlite is pure-Go and does not require CGo; CGO_ENABLED=0 +# produces statically linked binaries that deploy cleanly to Alpine containers. GO := go GOFLAGS := -trimpath LDFLAGS := -s -w -X main.version=$(VERSION) -CGO := CGO_ENABLED=1 +CGO := CGO_ENABLED=0 + +# The race detector requires CGo on some platforms, so tests continue to use +# CGO_ENABLED=1 while production builds are CGO_ENABLED=0. +CGO_TEST := CGO_ENABLED=1 # Platforms for cross-compiled dist tarballs. DIST_PLATFORMS := linux/amd64 linux/arm64 # --------------------------------------------------------------------------- -# Default target +# Default target — CI pipeline: vet → lint → test → build # --------------------------------------------------------------------------- .PHONY: all -all: build +all: vet lint test build # --------------------------------------------------------------------------- # build — compile all binaries to bin/ @@ -59,7 +68,14 @@ build: # --------------------------------------------------------------------------- .PHONY: test test: - $(CGO) $(GO) test -race ./... + $(CGO_TEST) $(GO) test -race ./... + +# --------------------------------------------------------------------------- +# vet — static analysis via go vet +# --------------------------------------------------------------------------- +.PHONY: vet +vet: + $(GO) vet ./... # --------------------------------------------------------------------------- # lint — run golangci-lint @@ -68,6 +84,15 @@ test: lint: golangci-lint run ./... +# --------------------------------------------------------------------------- +# proto-lint — lint and check for breaking changes in proto definitions +# Requires: buf (https://buf.build/docs/installation) +# --------------------------------------------------------------------------- +.PHONY: proto-lint +proto-lint: + buf lint + buf breaking --against '.git#branch=master,subdir=proto' + # --------------------------------------------------------------------------- # generate — regenerate protobuf stubs from proto/ definitions # Requires: protoc, protoc-gen-go, protoc-gen-go-grpc @@ -76,6 +101,13 @@ lint: generate: $(GO) generate ./... +# --------------------------------------------------------------------------- +# devserver — build and run mciassrv against the local run/ config +# --------------------------------------------------------------------------- +.PHONY: devserver +devserver: build + $(BIN_DIR)/mciassrv -config run/mcias.conf + # --------------------------------------------------------------------------- # man — build compressed man pages # --------------------------------------------------------------------------- @@ -90,7 +122,7 @@ man: $(patsubst %.1,%.1.gz,$(MAN_PAGES)) # --------------------------------------------------------------------------- .PHONY: install install: build - sh dist/install.sh + sh deploy/scripts/install.sh # --------------------------------------------------------------------------- # clean — remove build artifacts @@ -98,6 +130,7 @@ install: build .PHONY: clean clean: rm -rf $(BIN_DIR) + rm -rf dist/ rm -f $(patsubst %.1,%.1.gz,$(MAN_PAGES)) -docker rmi mcias:$(VERSION) mcias:latest 2>/dev/null || true @@ -106,7 +139,7 @@ clean: # # Output files: dist/mcias___.tar.gz # Each tarball contains: mciassrv, mciasctl, mciasdb, mciasgrpcctl, -# man pages, and dist/ files. +# man pages, and deploy/ files. # --------------------------------------------------------------------------- .PHONY: dist dist: man @@ -117,14 +150,12 @@ dist: man echo " DIST $$platform -> $$outdir.tar.gz"; \ mkdir -p $$outdir/bin; \ for bin in $(BINARIES); do \ - CGO_ENABLED=1 GOOS=$$os GOARCH=$$arch $(GO) build \ + CGO_ENABLED=0 GOOS=$$os GOARCH=$$arch $(GO) build \ $(GOFLAGS) -ldflags "$(LDFLAGS)" \ -o $$outdir/bin/$$bin ./cmd/$$bin; \ done; \ cp -r man $$outdir/; \ - cp dist/mcias.conf.example dist/mcias-dev.conf.example \ - dist/mcias.env.example dist/mcias.service \ - dist/install.sh $$outdir/; \ + cp -r deploy $$outdir/; \ tar -czf $$outdir.tar.gz -C dist mcias_$$(echo $(VERSION) | tr -d 'v')_$${os}_$${arch}; \ rm -rf $$outdir; \ done @@ -154,13 +185,17 @@ install-local: build .PHONY: help help: @echo "Available targets:" - @echo " build Compile all binaries to bin/" - @echo " test Run tests with race detector" - @echo " lint Run golangci-lint" - @echo " generate Regenerate protobuf stubs" - @echo " man Build compressed man pages" - @echo " install Install to /usr/local/bin (requires root)" - @echo " clean Remove build artifacts" - @echo " dist Build release tarballs for Linux amd64/arm64" - @echo " docker Build Docker image mcias:$(VERSION) and mcias:latest" + @echo " all vet → lint → test → build (CI pipeline)" + @echo " build Compile all binaries to bin/" + @echo " test Run tests with race detector" + @echo " vet Run go vet" + @echo " lint Run golangci-lint" + @echo " proto-lint Lint proto files with buf" + @echo " generate Regenerate protobuf stubs" + @echo " devserver Build and run mciassrv against run/ config" + @echo " man Build compressed man pages" + @echo " install Install to /usr/local/bin (requires root)" + @echo " clean Remove build artifacts" + @echo " dist Build release tarballs for Linux amd64/arm64" + @echo " docker Build Docker image mcias:$(VERSION) and mcias:latest" @echo " docker-clean Remove local mcias Docker images" diff --git a/buf.yaml b/buf.yaml new file mode 100644 index 0000000..aac6e99 --- /dev/null +++ b/buf.yaml @@ -0,0 +1,14 @@ +version: v2 +modules: + - path: proto +lint: + use: + - STANDARD + except: + # PACKAGE_VERSION_SUFFIX requires package names to end in a version (e.g. + # mcias.v1). The current protos use mcias.v1 already so this is fine, but + # keeping the exception documents the intent explicitly. + - PACKAGE_VERSION_SUFFIX +breaking: + use: + - FILE diff --git a/cmd/mciasdb/main.go b/cmd/mciasdb/main.go index 39bdc20..df3189c 100644 --- a/cmd/mciasdb/main.go +++ b/cmd/mciasdb/main.go @@ -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 + /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. diff --git a/cmd/mciasdb/snapshot.go b/cmd/mciasdb/snapshot.go new file mode 100644 index 0000000..ec7f3bb --- /dev/null +++ b/cmd/mciasdb/snapshot.go @@ -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) +} diff --git a/dist/mcias-dev.conf.example b/deploy/examples/mcias-dev.conf.example similarity index 100% rename from dist/mcias-dev.conf.example rename to deploy/examples/mcias-dev.conf.example diff --git a/dist/mcias.conf.docker.example b/deploy/examples/mcias.conf.docker.example similarity index 100% rename from dist/mcias.conf.docker.example rename to deploy/examples/mcias.conf.docker.example diff --git a/dist/mcias.conf.example b/deploy/examples/mcias.conf.example similarity index 100% rename from dist/mcias.conf.example rename to deploy/examples/mcias.conf.example diff --git a/dist/mcias.env.example b/deploy/examples/mcias.env.example similarity index 100% rename from dist/mcias.env.example rename to deploy/examples/mcias.env.example diff --git a/dist/install.sh b/deploy/scripts/install.sh similarity index 82% rename from dist/install.sh rename to deploy/scripts/install.sh index 4b9b89a..4eb7bea 100644 --- a/dist/install.sh +++ b/deploy/scripts/install.sh @@ -1,13 +1,13 @@ #!/bin/sh # install.sh — MCIAS first-time and upgrade installer # -# Usage: sh dist/install.sh +# Usage: sh deploy/scripts/install.sh # # This script must be run as root. It: # 1. Creates the mcias system user and group (idempotent). # 2. Copies binaries to /usr/local/bin/. # 3. Creates /srv/mcias/ with correct permissions. -# 4. Installs the systemd service unit. +# 4. Installs the systemd service and backup units. # 5. Prints post-install instructions. # # The script does NOT start or enable the service automatically. Review the @@ -31,7 +31,8 @@ SYSTEMD_DIR="/etc/systemd/system" SERVICE_USER="mcias" SERVICE_GROUP="mcias" SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" -REPO_ROOT="$(dirname "$SCRIPT_DIR")" +DEPLOY_DIR="$(dirname "$SCRIPT_DIR")" +REPO_ROOT="$(dirname "$DEPLOY_DIR")" # --------------------------------------------------------------------------- # Helpers @@ -100,11 +101,7 @@ fi # Step 2: Install binaries. info "Installing binaries to $BIN_DIR" for bin in mciassrv mciasctl mciasdb mciasgrpcctl; do - src="$REPO_ROOT/$bin" - if [ ! -f "$src" ]; then - # Try bin/ subdirectory (Makefile build output). - src="$REPO_ROOT/bin/$bin" - fi + src="$REPO_ROOT/bin/$bin" if [ ! -f "$src" ]; then warn "Binary not found: $bin — skipping. Run 'make build' first." continue @@ -113,30 +110,40 @@ for bin in mciassrv mciasctl mciasdb mciasgrpcctl; do install -m 0755 -o root -g root "$src" "$BIN_DIR/$bin" done -# Step 3: Create service directory. +# Step 3: Create service directory structure. info "Creating $SRV_DIR" install -d -m 0750 -o "$SERVICE_USER" -g "$SERVICE_GROUP" "$SRV_DIR" +install -d -m 0750 -o "$SERVICE_USER" -g "$SERVICE_GROUP" "$SRV_DIR/certs" +install -d -m 0750 -o "$SERVICE_USER" -g "$SERVICE_GROUP" "$SRV_DIR/backups" # Install example config files; never overwrite existing configs. for f in mcias.conf.example mcias.env.example; do - src="$SCRIPT_DIR/$f" + src="$DEPLOY_DIR/examples/$f" dst="$SRV_DIR/$f" if [ -f "$src" ]; then install -m 0640 -o "$SERVICE_USER" -g "$SERVICE_GROUP" "$src" "$dst" 2>/dev/null || true fi done -# Step 5: Install systemd service unit. +# Step 4: Install systemd units. if [ -d "$SYSTEMD_DIR" ]; then - info "Installing systemd service unit to $SYSTEMD_DIR" - install -m 0644 -o root -g root "$SCRIPT_DIR/mcias.service" "$SYSTEMD_DIR/mcias.service" + info "Installing systemd units to $SYSTEMD_DIR" + for unit in mcias.service mcias-backup.service mcias-backup.timer; do + src="$DEPLOY_DIR/systemd/$unit" + if [ -f "$src" ]; then + install -m 0644 -o root -g root "$src" "$SYSTEMD_DIR/$unit" + info " Installed $unit" + fi + done info "Reloading systemd daemon" systemctl daemon-reload 2>/dev/null || warn "systemctl not available; reload manually." + info "Enabling backup timer" + systemctl enable mcias-backup.timer 2>/dev/null || warn "Could not enable timer; enable manually with: systemctl enable mcias-backup.timer" else warn "systemd not found at $SYSTEMD_DIR; skipping service unit installation." fi -# Step 6: Install man pages. +# Step 5: Install man pages. if [ -d "$REPO_ROOT/man/man1" ]; then install -d -m 0755 -o root -g root "$MAN_DIR" info "Installing man pages to $MAN_DIR" @@ -170,12 +177,12 @@ Next steps: # Self-signed (development / personal use): openssl req -x509 -newkey ed25519 -days 3650 \\ - -keyout /srv/mcias/server.key \\ - -out /srv/mcias/server.crt \\ + -keyout /srv/mcias/certs/server.key \\ + -out /srv/mcias/certs/server.crt \\ -subj "/CN=auth.example.com" \\ -nodes - chmod 0640 /srv/mcias/server.key - chown mcias:mcias /srv/mcias/server.key /srv/mcias/server.crt + chmod 0640 /srv/mcias/certs/server.key + chown mcias:mcias /srv/mcias/certs/server.key /srv/mcias/certs/server.crt 2. Copy and edit the configuration file: @@ -200,6 +207,9 @@ Next steps: systemctl start mcias systemctl status mcias + The backup timer was enabled automatically. Verify with: + systemctl status mcias-backup.timer + 5. Create the first admin account using mciasdb (while the server is running, or before first start): diff --git a/deploy/systemd/mcias-backup.service b/deploy/systemd/mcias-backup.service new file mode 100644 index 0000000..ada973d --- /dev/null +++ b/deploy/systemd/mcias-backup.service @@ -0,0 +1,32 @@ +[Unit] +Description=MCIAS Database Backup +Documentation=man:mciasdb(1) +After=mcias.service +# Backup runs against the live database using VACUUM INTO, which is safe +# while mciassrv is running (WAL mode allows concurrent readers). + +[Service] +Type=oneshot +User=mcias +Group=mcias + +EnvironmentFile=/srv/mcias/env + +ExecStart=/usr/local/bin/mciasdb -config /srv/mcias/mcias.toml snapshot + +# Filesystem restrictions (read-write to /srv/mcias for the backup output). +ProtectSystem=strict +ProtectHome=true +PrivateTmp=true +ReadWritePaths=/srv/mcias +NoNewPrivileges=true +PrivateDevices=true +CapabilityBoundingSet= +RestrictSUIDSGID=true +RestrictNamespaces=true +RestrictRealtime=true +LockPersonality=true +MemoryDenyWriteExecute=true +ProtectKernelTunables=true +ProtectKernelModules=true +ProtectControlGroups=true diff --git a/deploy/systemd/mcias-backup.timer b/deploy/systemd/mcias-backup.timer new file mode 100644 index 0000000..a832cd3 --- /dev/null +++ b/deploy/systemd/mcias-backup.timer @@ -0,0 +1,15 @@ +[Unit] +Description=Daily MCIAS Database Backup +Documentation=man:mciasdb(1) + +[Timer] +# Run daily at 02:00 UTC with up to 5-minute random jitter to avoid +# thundering-herd on systems with many services. +OnCalendar=*-*-* 02:00:00 UTC +RandomizedDelaySec=5min +# Run immediately on boot if the last scheduled run was missed +# (e.g. host was offline at 02:00). +Persistent=true + +[Install] +WantedBy=timers.target diff --git a/dist/mcias.service b/deploy/systemd/mcias.service similarity index 94% rename from dist/mcias.service rename to deploy/systemd/mcias.service index d9ea77e..d28ff8f 100644 --- a/dist/mcias.service +++ b/deploy/systemd/mcias.service @@ -12,7 +12,7 @@ Group=mcias # Configuration and secrets. # /srv/mcias/env must contain MCIAS_MASTER_PASSPHRASE= -# See dist/mcias.env.example for the template. +# See deploy/examples/mcias.env.example for the template. EnvironmentFile=/srv/mcias/env ExecStart=/usr/local/bin/mciassrv -config /srv/mcias/mcias.toml @@ -42,6 +42,7 @@ PrivateDevices=true ProtectKernelTunables=true ProtectKernelModules=true ProtectControlGroups=true +RestrictSUIDSGID=true RestrictNamespaces=true RestrictRealtime=true LockPersonality=true diff --git a/internal/db/snapshot.go b/internal/db/snapshot.go new file mode 100644 index 0000000..925b3cb --- /dev/null +++ b/internal/db/snapshot.go @@ -0,0 +1,68 @@ +package db + +import ( + "fmt" + "os" + "path/filepath" + "strings" + "time" +) + +// Snapshot creates a consistent backup of the database at destPath using +// SQLite's VACUUM INTO statement. VACUUM INTO acquires a read lock for the +// duration of the copy, which is safe while the server is running in WAL mode. +// The destination file is created by SQLite; the caller must ensure the parent +// directory exists. +func (db *DB) Snapshot(destPath string) error { + // VACUUM INTO is not supported on in-memory databases. + if strings.Contains(db.path, "mode=memory") { + return fmt.Errorf("db: snapshot not supported on in-memory databases") + } + if _, err := db.sql.Exec("VACUUM INTO ?", destPath); err != nil { + return fmt.Errorf("db: snapshot VACUUM INTO %q: %w", destPath, err) + } + return nil +} + +// SnapshotDir creates a timestamped backup in dir and prunes backups older +// than retainDays days. dir is created with mode 0750 if it does not exist. +// The backup filename format is mcias-20060102-150405.db. +func (db *DB) SnapshotDir(dir string, retainDays int) (string, error) { + if err := os.MkdirAll(dir, 0750); err != nil { + return "", fmt.Errorf("db: create backup dir %q: %w", dir, err) + } + + ts := time.Now().UTC().Format("20060102-150405") + dest := filepath.Join(dir, fmt.Sprintf("mcias-%s.db", ts)) + if err := db.Snapshot(dest); err != nil { + return "", err + } + + // Prune backups older than retainDays. + if retainDays > 0 { + cutoff := time.Now().UTC().AddDate(0, 0, -retainDays) + entries, err := os.ReadDir(dir) + if err != nil { + // Non-fatal: the backup was written; log pruning failure separately. + return dest, fmt.Errorf("db: list backup dir for pruning: %w", err) + } + for _, e := range entries { + if e.IsDir() || !strings.HasSuffix(e.Name(), ".db") { + continue + } + // Skip the file we just wrote. + if e.Name() == filepath.Base(dest) { + continue + } + info, err := e.Info() + if err != nil { + continue + } + if info.ModTime().Before(cutoff) { + _ = os.Remove(filepath.Join(dir, e.Name())) + } + } + } + + return dest, nil +}