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.
This commit is contained in:
Executable
+149
@@ -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
|
||||||
Reference in New Issue
Block a user