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.sum
dist/mcias_*.tar.gz
# dist/ is purely build output (tarballs); never commit it
dist/
man/man1/*.gz
# Client library build artifacts

View File

@@ -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 (1999) 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"]

View File

@@ -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_<version>_<os>_<arch>.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,10 +185,14 @@ install-local: build
.PHONY: help
help:
@echo "Available targets:"
@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"

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

View File

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

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.
# /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
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

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
}