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.
150 lines
6.4 KiB
Bash
Executable File
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
|