diff --git a/deploy/setup-encrypted-data.sh b/deploy/setup-encrypted-data.sh new file mode 100755 index 0000000..3be7df3 --- /dev/null +++ b/deploy/setup-encrypted-data.sh @@ -0,0 +1,149 @@ +#!/usr/bin/env bash +# One-time setup: put Petal's data directory on an encrypted volume. +# +# sudo ./setup-encrypted-data.sh # create + migrate + persist +# sudo ./setup-encrypted-data.sh --status # report, change nothing +# +# WHAT THIS DOES AND DOES NOT PROTECT +# ----------------------------------- +# The volume auto-unlocks from a keyfile stored on the same host. That is a +# deliberate choice (availability over paranoia), and it means: +# +# protects against : a decommissioned or resold disk, someone reading the +# raw block device, casual browsing of a filesystem-level +# snapshot that does not include /etc +# does NOT protect : anyone who takes the whole VM image -- they get +# against /etc/petal/dataset.key along with the ciphertext; and +# anything at all once the host is running and mounted +# +# For real protection against a provider-side snapshot the key has to live off +# the box (fetched over the VPN at boot). That was considered and not chosen. +# +# WHY LUKS-ON-A-FILE RATHER THAN gocryptfs +# ---------------------------------------- +# Petal is SQLite in WAL mode. WAL needs a shared-memory index (-shm) mapped +# consistently across processes, and FUSE filesystems have a long history of +# subtle mmap/locking differences. A LUKS block device with ext4 on top behaves +# exactly like a normal disk to SQLite, which is the only guarantee worth having +# under a database. +set -euo pipefail + +IMG="${IMG:-/var/lib/petal-crypt.img}" +SIZE="${SIZE:-8G}" +MAPPER_NAME="${MAPPER_NAME:-petal-data}" +KEYFILE="${KEYFILE:-/etc/petal/dataset.key}" +MOUNTPOINT="${MOUNTPOINT:-/home/reala/petal/data}" +STACK_DIR="${STACK_DIR:-/home/reala/petal}" +OWNER_UID="${OWNER_UID:-1001}" +OWNER_GID="${OWNER_GID:-1001}" + +[ "$(id -u)" -eq 0 ] || { echo "must run as root" >&2; exit 1; } + +# systemd-cryptsetup ships the generator that turns /etc/crypttab into units. +# On a minimal Debian it is NOT installed, and without it crypttab is silently +# ignored -- the volume simply never unlocks at boot. Found the hard way. +if [ ! -x /usr/lib/systemd/system-generators/systemd-cryptsetup-generator ]; then + echo "!! systemd-cryptsetup-generator is missing: /etc/crypttab would be ignored at boot." + echo " install it first: apt-get install systemd-cryptsetup" + exit 1 +fi + +status() { + echo "image : $IMG $( [ -f "$IMG" ] && echo "($(du -h --apparent-size "$IMG" | cut -f1) apparent, $(du -h "$IMG" | cut -f1) on disk)" || echo "(absent)")" + echo "mapper : /dev/mapper/$MAPPER_NAME $( [ -e "/dev/mapper/$MAPPER_NAME" ] && echo "(open)" || echo "(closed)")" + echo "keyfile : $KEYFILE $( [ -f "$KEYFILE" ] && echo "(present, mode $(stat -c%a "$KEYFILE"))" || echo "(absent)")" + echo "mountpoint : $MOUNTPOINT $(mountpoint -q "$MOUNTPOINT" && echo "(mounted)" || echo "(NOT mounted)")" + grep -q "^$MAPPER_NAME " /etc/crypttab 2>/dev/null && echo "crypttab : present" || echo "crypttab : MISSING" + grep -q " $MOUNTPOINT " /etc/fstab 2>/dev/null && echo "fstab : present" || echo "fstab : MISSING" +} + +if [ "${1:-}" = "--status" ]; then status; exit 0; fi + +if [ -f "$IMG" ]; then + echo "$IMG already exists — refusing to re-create. Use --status." >&2 + exit 1 +fi + +echo ">> stopping the stack so nothing is writing to $MOUNTPOINT" +if [ -f "$STACK_DIR/docker-compose.yml" ]; then + ( cd "$STACK_DIR" && docker compose down ) +fi + +echo ">> generating keyfile $KEYFILE (root-only)" +install -d -m 0700 "$(dirname "$KEYFILE")" +if [ ! -f "$KEYFILE" ]; then + dd if=/dev/urandom of="$KEYFILE" bs=512 count=1 status=none + chmod 0400 "$KEYFILE" +fi + +echo ">> creating $SIZE sparse image at $IMG" +truncate -s "$SIZE" "$IMG" +chmod 0600 "$IMG" + +echo ">> LUKS format + open" +cryptsetup luksFormat --type luks2 --batch-mode --key-file "$KEYFILE" "$IMG" +cryptsetup luksOpen --key-file "$KEYFILE" "$IMG" "$MAPPER_NAME" + +echo ">> mkfs + mount" +mkfs.ext4 -q -L petal-data "/dev/mapper/$MAPPER_NAME" + +# Preserve whatever is already in the plaintext directory, then swap it in. +STAGING="" +if [ -d "$MOUNTPOINT" ] && [ -n "$(ls -A "$MOUNTPOINT" 2>/dev/null)" ]; then + STAGING="$(mktemp -d)" + echo ">> preserving existing plaintext data -> $STAGING" + cp -a "$MOUNTPOINT/." "$STAGING/" + + # Critical, and easy to miss: mounting over a directory HIDES its contents, + # it does not remove them. Skip this and the original plaintext petal.db sits + # on the unencrypted root filesystem forever, invisible under the mount, + # defeating the entire exercise. Clear the mountpoint before mounting. + echo ">> shredding the plaintext originals under the mountpoint" + find "$MOUNTPOINT" -mindepth 1 -type f -exec shred -uz {} + 2>/dev/null || true + find "$MOUNTPOINT" -mindepth 1 -depth -type d -exec rmdir {} + 2>/dev/null || true + [ -z "$(ls -A "$MOUNTPOINT" 2>/dev/null)" ] || { + echo "!! $MOUNTPOINT is not empty after cleanup; refusing to mount over live data" >&2 + echo " (data is preserved at $STAGING)" >&2 + exit 1 + } +fi + +mkdir -p "$MOUNTPOINT" +mount "/dev/mapper/$MAPPER_NAME" "$MOUNTPOINT" + +if [ -n "$STAGING" ]; then + echo ">> restoring data onto the encrypted volume" + cp -a "$STAGING/." "$MOUNTPOINT/" + find "$STAGING" -type f -exec shred -uz {} + 2>/dev/null || true + rm -rf "$STAGING" +fi + +# Mount-liveness sentinel: docker-compose bind-mounts this file with +# create_host_path:false, so an unmounted volume becomes a loud container start +# failure rather than Petal quietly serving an empty database. +touch "$MOUNTPOINT/.volume-ok" + +chown -R "$OWNER_UID:$OWNER_GID" "$MOUNTPOINT" + +echo ">> persisting across reboots" +# systemd-cryptsetup loop-mounts a regular file source on its own. +if ! grep -q "^$MAPPER_NAME " /etc/crypttab 2>/dev/null; then + echo "$MAPPER_NAME $IMG $KEYFILE luks,nofail" >> /etc/crypttab +fi +# nofail: a problem here must never wedge the boot of a host running half a +# dozen other services. +# x-systemd.before=docker.service is the important one: without it Docker can +# start first, find $MOUNTPOINT empty, and bring Petal up against a blank +# unencrypted directory that the real volume then hides. +if ! grep -q " $MOUNTPOINT " /etc/fstab 2>/dev/null; then + echo "/dev/mapper/$MAPPER_NAME $MOUNTPOINT ext4 defaults,nofail,x-systemd.requires=/dev/mapper/$MAPPER_NAME,x-systemd.before=docker.service 0 2" >> /etc/fstab +fi +systemctl daemon-reload + +echo ">> restarting the stack" +if [ -f "$STACK_DIR/docker-compose.yml" ]; then + ( cd "$STACK_DIR" && docker compose up -d ) +fi + +echo +status