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

3
.gitignore vendored
View File

@@ -20,7 +20,8 @@ mcias.toml
*~ *~
go.work go.work
go.work.sum go.work.sum
dist/mcias_*.tar.gz # dist/ is purely build output (tarballs); never commit it
dist/
man/man1/*.gz man/man1/*.gz
# Client library build artifacts # Client library build artifacts

View File

@@ -1,7 +1,11 @@
# Dockerfile — MCIAS multi-stage container image # Dockerfile — MCIAS multi-stage container image
# #
# Stage 1 (builder): Compiles all four MCIAS binaries. # 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: # The final image:
# - Runs as non-root uid 10001 (mcias) # - Runs as non-root uid 10001 (mcias)
@@ -24,7 +28,7 @@
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Stage 1 — builder # Stage 1 — builder
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
FROM golang:1.26-bookworm AS builder FROM golang:1.26-alpine AS builder
WORKDIR /build WORKDIR /build
@@ -35,35 +39,29 @@ RUN go mod download
# Copy source. # Copy source.
COPY . . 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. # -trimpath removes local file system paths from the binary.
# -ldflags="-s -w" strips the DWARF debug info and symbol table to reduce # -ldflags="-s -w" strips the DWARF debug info and symbol table to reduce
# image size. # image size.
RUN CGO_ENABLED=1 go build -trimpath -ldflags="-s -w" -o /out/mciassrv ./cmd/mciassrv && \ RUN CGO_ENABLED=0 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=0 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=0 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 CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/mciasgrpcctl ./cmd/mciasgrpcctl
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Stage 2 — runtime # Stage 2 — runtime
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
FROM debian:bookworm-slim FROM alpine:3.21
# Install runtime dependencies.
# ca-certificates: required to validate external TLS certificates. # ca-certificates: required to validate external TLS certificates.
# libc6: required by CGo-compiled binaries (sqlite). RUN apk add --no-cache ca-certificates
RUN apt-get update && \
apt-get install -y --no-install-recommends \
ca-certificates \
libc6 && \
rm -rf /var/lib/apt/lists/*
# Create a non-root user for the service. # Create a non-root user for the service.
# uid/gid 10001 is chosen to be well above the range typically assigned to # uid/gid 10001 is chosen to be well above the range typically assigned to
# system users (1999) and human users (1000+), reducing the chance of # system users (1999) and human users (1000+), reducing the chance of
# collision with existing uids on the host when using host networking. # collision with existing uids on the host when using host networking.
RUN groupadd --gid 10001 mcias && \ RUN addgroup -g 10001 mcias && \
useradd --uid 10001 --gid 10001 --no-create-home --shell /usr/sbin/nologin mcias adduser -u 10001 -G mcias -H -s /sbin/nologin -D mcias
# Copy compiled binaries from the builder stage. # Copy compiled binaries from the builder stage.
COPY --from=builder /out/mciassrv /usr/local/bin/mciassrv 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. # Create the data directory.
# /srv/mcias is mounted from the host with config, TLS certs, and database. # /srv/mcias is mounted from the host with config, TLS certs, and database.
RUN mkdir -p /srv/mcias && \ RUN mkdir -p /srv/mcias/certs /srv/mcias/backups && \
chown mcias:mcias /srv/mcias && \ chown -R mcias:mcias /srv/mcias && \
chmod 0750 /srv/mcias chmod 0750 /srv/mcias
# Declare /srv/mcias as a volume so the operator must explicitly mount it. # 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. # Default entry point and config path.
# The operator mounts /srv/mcias from the host containing mcias.toml, # The operator mounts /srv/mcias from the host containing mcias.toml,
# TLS cert/key, and the SQLite database. # 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"] ENTRYPOINT ["mciassrv"]
CMD ["-config", "/srv/mcias/mcias.toml"] CMD ["-config", "/srv/mcias/mcias.toml"]

View File

@@ -3,10 +3,14 @@
# Usage: # Usage:
# make build — compile all binaries to bin/ # make build — compile all binaries to bin/
# make test — run tests with race detector # make test — run tests with race detector
# make vet — run go vet
# make lint — run golangci-lint # make lint — run golangci-lint
# make all — vet → lint → test → build (CI pipeline)
# make generate — regenerate protobuf stubs (requires protoc) # make generate — regenerate protobuf stubs (requires protoc)
# make proto-lint — lint proto files with buf
# make man — build compressed man pages # 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 clean — remove bin/ and generated artifacts
# make dist — build release tarballs for linux/amd64 and linux/arm64 # make dist — build release tarballs for linux/amd64 and linux/arm64
# make docker — build Docker image tagged mcias:$(VERSION) and mcias:latest # 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) VERSION := $(shell git describe --tags --always 2>/dev/null || echo dev)
# Build flags: trim paths from binaries and strip DWARF/symbol table. # 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 GO := go
GOFLAGS := -trimpath GOFLAGS := -trimpath
LDFLAGS := -s -w -X main.version=$(VERSION) 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. # Platforms for cross-compiled dist tarballs.
DIST_PLATFORMS := linux/amd64 linux/arm64 DIST_PLATFORMS := linux/amd64 linux/arm64
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Default target # Default target — CI pipeline: vet → lint → test → build
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
.PHONY: all .PHONY: all
all: build all: vet lint test build
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# build — compile all binaries to bin/ # build — compile all binaries to bin/
@@ -59,7 +68,14 @@ build:
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
.PHONY: test .PHONY: test
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 # lint — run golangci-lint
@@ -68,6 +84,15 @@ test:
lint: lint:
golangci-lint run ./... 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 # generate — regenerate protobuf stubs from proto/ definitions
# Requires: protoc, protoc-gen-go, protoc-gen-go-grpc # Requires: protoc, protoc-gen-go, protoc-gen-go-grpc
@@ -76,6 +101,13 @@ lint:
generate: generate:
$(GO) 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 # man — build compressed man pages
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -90,7 +122,7 @@ man: $(patsubst %.1,%.1.gz,$(MAN_PAGES))
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
.PHONY: install .PHONY: install
install: build install: build
sh dist/install.sh sh deploy/scripts/install.sh
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# clean — remove build artifacts # clean — remove build artifacts
@@ -98,6 +130,7 @@ install: build
.PHONY: clean .PHONY: clean
clean: clean:
rm -rf $(BIN_DIR) rm -rf $(BIN_DIR)
rm -rf dist/
rm -f $(patsubst %.1,%.1.gz,$(MAN_PAGES)) rm -f $(patsubst %.1,%.1.gz,$(MAN_PAGES))
-docker rmi mcias:$(VERSION) mcias:latest 2>/dev/null || true -docker rmi mcias:$(VERSION) mcias:latest 2>/dev/null || true
@@ -106,7 +139,7 @@ clean:
# #
# Output files: dist/mcias_<version>_<os>_<arch>.tar.gz # Output files: dist/mcias_<version>_<os>_<arch>.tar.gz
# Each tarball contains: mciassrv, mciasctl, mciasdb, mciasgrpcctl, # Each tarball contains: mciassrv, mciasctl, mciasdb, mciasgrpcctl,
# man pages, and dist/ files. # man pages, and deploy/ files.
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
.PHONY: dist .PHONY: dist
dist: man dist: man
@@ -117,14 +150,12 @@ dist: man
echo " DIST $$platform -> $$outdir.tar.gz"; \ echo " DIST $$platform -> $$outdir.tar.gz"; \
mkdir -p $$outdir/bin; \ mkdir -p $$outdir/bin; \
for bin in $(BINARIES); do \ 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)" \ $(GOFLAGS) -ldflags "$(LDFLAGS)" \
-o $$outdir/bin/$$bin ./cmd/$$bin; \ -o $$outdir/bin/$$bin ./cmd/$$bin; \
done; \ done; \
cp -r man $$outdir/; \ cp -r man $$outdir/; \
cp dist/mcias.conf.example dist/mcias-dev.conf.example \ cp -r deploy $$outdir/; \
dist/mcias.env.example dist/mcias.service \
dist/install.sh $$outdir/; \
tar -czf $$outdir.tar.gz -C dist mcias_$$(echo $(VERSION) | tr -d 'v')_$${os}_$${arch}; \ tar -czf $$outdir.tar.gz -C dist mcias_$$(echo $(VERSION) | tr -d 'v')_$${os}_$${arch}; \
rm -rf $$outdir; \ rm -rf $$outdir; \
done done
@@ -154,10 +185,14 @@ install-local: build
.PHONY: help .PHONY: help
help: help:
@echo "Available targets:" @echo "Available targets:"
@echo " all vet → lint → test → build (CI pipeline)"
@echo " build Compile all binaries to bin/" @echo " build Compile all binaries to bin/"
@echo " test Run tests with race detector" @echo " test Run tests with race detector"
@echo " vet Run go vet"
@echo " lint Run golangci-lint" @echo " lint Run golangci-lint"
@echo " proto-lint Lint proto files with buf"
@echo " generate Regenerate protobuf stubs" @echo " generate Regenerate protobuf stubs"
@echo " devserver Build and run mciassrv against run/ config"
@echo " man Build compressed man pages" @echo " man Build compressed man pages"
@echo " install Install to /usr/local/bin (requires root)" @echo " install Install to /usr/local/bin (requires root)"
@echo " clean Remove build artifacts" @echo " clean Remove build artifacts"

14
buf.yaml Normal file
View File

@@ -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

View File

@@ -40,7 +40,7 @@
// pgcreds get --id UUID // pgcreds get --id UUID
// pgcreds set --id UUID --host H --port P --db D --user U // pgcreds set --id UUID --host H --port P --db D --user U
// //
// rekey // snapshot [--retain-days N]
package main package main
import ( import (
@@ -68,6 +68,14 @@ func main() {
command := args[0] command := args[0]
subArgs := args[1:] 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 // schema subcommands manage migrations themselves and must not trigger
// auto-migration on open (a dirty database would prevent the tool from // auto-migration on open (a dirty database would prevent the tool from
// opening at all, blocking recovery operations like "schema force"). // opening at all, blocking recovery operations like "schema force").
@@ -273,6 +281,11 @@ Commands:
rekey Re-encrypt all secrets under a new master passphrase rekey Re-encrypt all secrets under a new master passphrase
(prompts interactively; requires server to be stopped) (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 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. file. Use it only when the server is unavailable or for break-glass recovery.
All write operations are recorded in the audit log. 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)
}

View File

@@ -1,13 +1,13 @@
#!/bin/sh #!/bin/sh
# install.sh — MCIAS first-time and upgrade installer # 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: # This script must be run as root. It:
# 1. Creates the mcias system user and group (idempotent). # 1. Creates the mcias system user and group (idempotent).
# 2. Copies binaries to /usr/local/bin/. # 2. Copies binaries to /usr/local/bin/.
# 3. Creates /srv/mcias/ with correct permissions. # 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. # 5. Prints post-install instructions.
# #
# The script does NOT start or enable the service automatically. Review the # 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_USER="mcias"
SERVICE_GROUP="mcias" SERVICE_GROUP="mcias"
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
REPO_ROOT="$(dirname "$SCRIPT_DIR")" DEPLOY_DIR="$(dirname "$SCRIPT_DIR")"
REPO_ROOT="$(dirname "$DEPLOY_DIR")"
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Helpers # Helpers
@@ -100,11 +101,7 @@ fi
# Step 2: Install binaries. # Step 2: Install binaries.
info "Installing binaries to $BIN_DIR" info "Installing binaries to $BIN_DIR"
for bin in mciassrv mciasctl mciasdb mciasgrpcctl; do 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" src="$REPO_ROOT/bin/$bin"
fi
if [ ! -f "$src" ]; then if [ ! -f "$src" ]; then
warn "Binary not found: $bin — skipping. Run 'make build' first." warn "Binary not found: $bin — skipping. Run 'make build' first."
continue continue
@@ -113,30 +110,40 @@ for bin in mciassrv mciasctl mciasdb mciasgrpcctl; do
install -m 0755 -o root -g root "$src" "$BIN_DIR/$bin" install -m 0755 -o root -g root "$src" "$BIN_DIR/$bin"
done done
# Step 3: Create service directory. # Step 3: Create service directory structure.
info "Creating $SRV_DIR" 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"
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. # Install example config files; never overwrite existing configs.
for f in mcias.conf.example mcias.env.example; do for f in mcias.conf.example mcias.env.example; do
src="$SCRIPT_DIR/$f" src="$DEPLOY_DIR/examples/$f"
dst="$SRV_DIR/$f" dst="$SRV_DIR/$f"
if [ -f "$src" ]; then if [ -f "$src" ]; then
install -m 0640 -o "$SERVICE_USER" -g "$SERVICE_GROUP" "$src" "$dst" 2>/dev/null || true install -m 0640 -o "$SERVICE_USER" -g "$SERVICE_GROUP" "$src" "$dst" 2>/dev/null || true
fi fi
done done
# Step 5: Install systemd service unit. # Step 4: Install systemd units.
if [ -d "$SYSTEMD_DIR" ]; then if [ -d "$SYSTEMD_DIR" ]; then
info "Installing systemd service unit to $SYSTEMD_DIR" info "Installing systemd units to $SYSTEMD_DIR"
install -m 0644 -o root -g root "$SCRIPT_DIR/mcias.service" "$SYSTEMD_DIR/mcias.service" 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" info "Reloading systemd daemon"
systemctl daemon-reload 2>/dev/null || warn "systemctl not available; reload manually." 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 else
warn "systemd not found at $SYSTEMD_DIR; skipping service unit installation." warn "systemd not found at $SYSTEMD_DIR; skipping service unit installation."
fi fi
# Step 6: Install man pages. # Step 5: Install man pages.
if [ -d "$REPO_ROOT/man/man1" ]; then if [ -d "$REPO_ROOT/man/man1" ]; then
install -d -m 0755 -o root -g root "$MAN_DIR" install -d -m 0755 -o root -g root "$MAN_DIR"
info "Installing man pages to $MAN_DIR" info "Installing man pages to $MAN_DIR"
@@ -170,12 +177,12 @@ Next steps:
# Self-signed (development / personal use): # Self-signed (development / personal use):
openssl req -x509 -newkey ed25519 -days 3650 \\ openssl req -x509 -newkey ed25519 -days 3650 \\
-keyout /srv/mcias/server.key \\ -keyout /srv/mcias/certs/server.key \\
-out /srv/mcias/server.crt \\ -out /srv/mcias/certs/server.crt \\
-subj "/CN=auth.example.com" \\ -subj "/CN=auth.example.com" \\
-nodes -nodes
chmod 0640 /srv/mcias/server.key chmod 0640 /srv/mcias/certs/server.key
chown mcias:mcias /srv/mcias/server.key /srv/mcias/server.crt chown mcias:mcias /srv/mcias/certs/server.key /srv/mcias/certs/server.crt
2. Copy and edit the configuration file: 2. Copy and edit the configuration file:
@@ -200,6 +207,9 @@ Next steps:
systemctl start mcias systemctl start mcias
systemctl status 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 5. Create the first admin account using mciasdb (while the server is
running, or before first start): running, or before first start):

View File

@@ -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

View File

@@ -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

View File

@@ -12,7 +12,7 @@ Group=mcias
# Configuration and secrets. # Configuration and secrets.
# /srv/mcias/env must contain MCIAS_MASTER_PASSPHRASE=<passphrase> # /srv/mcias/env must contain MCIAS_MASTER_PASSPHRASE=<passphrase>
# See dist/mcias.env.example for the template. # See deploy/examples/mcias.env.example for the template.
EnvironmentFile=/srv/mcias/env EnvironmentFile=/srv/mcias/env
ExecStart=/usr/local/bin/mciassrv -config /srv/mcias/mcias.toml ExecStart=/usr/local/bin/mciassrv -config /srv/mcias/mcias.toml
@@ -42,6 +42,7 @@ PrivateDevices=true
ProtectKernelTunables=true ProtectKernelTunables=true
ProtectKernelModules=true ProtectKernelModules=true
ProtectControlGroups=true ProtectControlGroups=true
RestrictSUIDSGID=true
RestrictNamespaces=true RestrictNamespaces=true
RestrictRealtime=true RestrictRealtime=true
LockPersonality=true LockPersonality=true

68
internal/db/snapshot.go Normal file
View File

@@ -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
}