Files
petal/deploy/setup-encrypted-data.sh
T
prosolis 33e49ddb62 Encrypted data volume for the VPS, and two bugs the rehearsal caught
Petal's data directory (petal.db, uploaded images, and the TTS cache --
which is synthesized audio of her sentences) now lives on a LUKS volume.
LUKS-on-a-file rather than gocryptfs because Petal is SQLite in WAL mode:
WAL needs a shared-memory index mapped consistently across processes, and
FUSE has a long history of subtle mmap/locking differences. A block
device with ext4 behaves exactly like a disk to SQLite.

The key sits on the same host, which is a deliberate availability
tradeoff and is documented as such: it stops a decommissioned disk or a
raw block-device read, not anyone holding the whole VM image.

Two bugs found by rehearsing a reboot rather than trusting the setup:

- Mounting over a directory HIDES its contents, it does not remove them.
  The first run left the original plaintext petal.db and WAL sitting on
  the unencrypted root filesystem, invisible under the mount -- exactly
  what the exercise was meant to eliminate. Now shredded before the
  mount, with a refusal if the mountpoint will not come up empty.

- systemd-cryptsetup was not installed on the host, so /etc/crypttab was
  being ignored entirely and the volume would never have unlocked at
  boot. The script now refuses to run without the generator present.
2026-07-27 06:27:55 -07:00

150 lines
6.4 KiB
Bash
Executable File

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