ReviewOS

also looking at this

mail-os/mail

Harden encrypted mail storage, recovery, and forwarding

#8
Merged chris wants to merge codex/mail-storage-hardening into main
50 files +1701 -9001

Review threads live on the whole diff, not on one commit, so none are shown here - a thread's line means something in the branch's final form, and painting it into an intermediate step would put it on code it is not about.

docs/ENCRYPTION_AT_REST.mdadded+186-0
Changes to docs/ENCRYPTION_AT_REST.md
@@ -0,0 +1,186 @@
1# Encryption at rest
2
3Production installs encrypt persisted mail data by default with a dedicated
4LUKS2 filesystem on an independently attached Hetzner volume. The protected
5paths include Maildirs, SQLite databases, DKIM material, forwarding rules,
6local backups, certificate backups, Restic credentials, and the mail log.
7
8The volume uses AES-XTS with a 512-bit key (two AES-256 keys, as required by
9XTS). A cryptographically random volume key is generated during provisioning.
10Initial installs wrap it with `systemd-creds` as the machine-bound encrypted
11credential:
12
13```text
14/etc/credstore.encrypted/mail-storage-key.cred
15```
16
17The plaintext key exists only briefly in `/run` during initial provisioning.
18`mail-storage.service` receives it as a private runtime credential, unlocks the
19volume, and mounts it at `/var/lib/mail-storage` before `mail.service` starts.
20The unprivileged mail process can access the mounted data but cannot read the
21persistent key credential.
22
23The server retains its established paths under `/opt/mail`; those paths point
24into the encrypted mount so SMTP, IMAP, POP3, CalDAV, CLI tools, and backups all
25see the same data layout.
26
27## Verification
28
29```bash
30systemctl is-active mail-storage mail
31findmnt /var/lib/mail-storage
32cryptsetup status mail-storage
33systemd-creds decrypt --name=mail-storage-key \
34 /etc/credstore.encrypted/mail-storage-key.cred - >/dev/null
35```
36
37## Object storage
38
39Cloud deployments apply a customer-managed KMS key with automatic rotation to
40private email and backup buckets. This is separate from LUKS2 and Restic: LUKS
41protects live host data, Restic encrypts and authenticates backup content
42before upload, and KMS-backed bucket encryption adds provider-side protection.
43
44Create or reconcile the KMS key and bucket policies:
45
46```bash
47buddy mail:storage:kms:ensure --profile stacks
48```
49
50## External key mode
51
52Hetzner VMs currently expose no TPM. For protection against theft of the entire
53root disk, production can externalize the LUKS key into AWS Secrets Manager,
54where it is encrypted by AWS KMS:
55
56```bash
57buddy mail:storage:externalize --profile stacks
58```
59
60External mode removes the mail volume credential from the host. After a reboot,
61mail remains stopped until an authorized workstation streams the key from
62Secrets Manager directly into `cryptsetup`:
63
64```bash
65buddy mail:storage:status
66buddy mail:storage:key:verify --profile stacks
67buddy mail:storage:unlock --profile stacks
68buddy mail:storage:lock
69```
70
71The key is never placed in a command-line argument, environment variable, log,
72or persistent file on the mail host. `mail:storage:unlock` holds it in the
73operator process briefly and sends it over SSH stdin.
74
75An independent recovery key can be added to a second LUKS2 keyslot and stored
76in the operator's macOS Keychain:
77
78```bash
79buddy mail:storage:key:recovery:add --profile stacks
80buddy mail:storage:key:recovery:verify
81```
82
83The command streams both keys over SSH stdin, verifies the new keyslot, writes
84the recovery key to Keychain without placing it in argv, reads it back, and
85tests it against the LUKS header. If a failed enrollment leaves an unwanted
86keyslot, inspect the LUKS header and remove only the explicitly selected slot:
87
88```bash
89buddy mail:storage:key:slot:remove --slot <number> --profile stacks
90```
91
92The removal command refuses the primary slot and requires the remaining
93external key to unlock the volume, so it cannot silently remove the last
94usable credential.
95
96## Dedicated volume
97
98Move an existing root-disk LUKS image to a separately attached Hetzner volume:
99
100```bash
101buddy mail:storage:volume:migrate --size 20 --profile stacks
102```
103
104The operation creates or reuses `stacks-production-mail`, stops mail, performs
105a byte-verified sparse copy, preserves the LUKS UUID and keyslots, unlocks with
106the externally stored key, grows the encrypted filesystem, validates the
107service, and only then removes the root-disk rollback image. A failed
108validation automatically restores the original image.
109
110## Recovery
111
112Before externalization, the encrypted credential is bound to the machine's
113systemd host secret. A machine-image or disaster-recovery backup must retain
114both:
115
116```text
117/etc/credstore.encrypted/mail-storage-key.cred
118/var/lib/systemd/credential.secret
119```
120
121After `mail:storage:externalize`, neither file is sufficient to unlock the mail
122volume because the local credential has been removed. Recovery then uses the
123AWS Secrets Manager entry (by default
124`stacks/production/mail-storage-key`):
125
126```bash
127buddy mail:storage:key:verify --profile stacks
128buddy mail:storage:unlock --profile stacks
129```
130
131Back up the LUKS header separately after provisioning or changing key slots.
132The Buddy command streams it to S3 and requests KMS encryption without leaving
133a persistent local copy:
134
135```bash
136buddy mail:storage:header:backup --profile stacks
137```
138
139The header backup is not a volume key, but it is sensitive and must be kept in
140encrypted offline storage. Losing every valid external key or destroying the
141LUKS header makes the volume unrecoverable by design.
142
143## Backup operations
144
145Local backups are created inside the encrypted filesystem, use SQLite's online
146backup API, and verify the archive before publishing it:
147
148```bash
149buddy mail:storage:backup
150systemctl status mail-backup.timer
151```
152
153Cloud object storage encryption is provider-side and independent from LUKS.
154Keep bucket default encryption enabled even when uploaded archives came from
155the encrypted host volume.
156
157Authenticated off-host backups use Restic with a dedicated least-privilege IAM
158user. The Restic password and AWS credentials are stored both inside the
159unlocked encrypted filesystem for unattended backups and in the
160KMS-encrypted `stacks/production/mail-restic` recovery secret.
161
162```bash
163buddy mail:storage:restic:configure --profile stacks
164buddy mail:storage:restore:check --profile stacks
165```
166
167The daily job creates a verified SQLite-consistent local snapshot, uploads it
168through Restic, applies 7 daily / 5 weekly / 12 monthly retention, prunes,
169and performs a repository data check. The restore check downloads the newest
170snapshot to a temporary directory on the encrypted filesystem, validates both
171archives, runs SQLite `quick_check`, and confirms that mail and forwarding data
172are present. An hourly health timer fails when the encrypted mount is missing,
173usage crosses configured thresholds, or the newest successful off-host backup
174is stale.
175
176## Recovery order
177
1781. Attach and mount the Hetzner data volume.
1792. Retrieve either the AWS secret or independent Keychain recovery key.
1803. Unlock `/var/lib/mail-storage.luks`.
1814. Start `mail.service` and validate SQLite plus Maildir counts.
1825. If the volume is unavailable, restore the latest Restic snapshot and its
183 environment archive onto a newly encrypted volume.
184
185Implementation and operational follow-up are tracked in
186[mail-os/mail#4](https://github.com/mail-os/mail/issues/4).
packages/cloud/cloud.config.tsmodified+9-0
Changes to packages/cloud/cloud.config.ts
@@ -70,6 +70,7 @@ const mailConfig = {
7070 'python3',
7171 'python3-pip',
7272 'openssl',
73 'cryptsetup',
7374 'sqlite',
7475 'fail2ban',
7576 'bind-utils',
@@ -327,6 +328,14 @@ chown -R mail-server:mail-server ${cfg.paths.logDir}
327328chown -R mail-server:mail-server ${cfg.paths.mailDir}
328329chown -R mail-server:mail-server ${cfg.paths.installDir}/mail
329330
331# Encrypt every persistent mail path by default. The LUKS2 volume key is
332# machine-bound in systemd's encrypted credential store and is never exposed
333# to the mail-server service as a persistent plaintext file.
334chmod +x ${cfg.paths.installDir}/scripts/setup-encrypted-storage.sh
335MAIL_ROOT=${cfg.paths.installDir} \
336 MAIL_ENCRYPTED_IMAGE_SIZE="\${MAIL_ENCRYPTED_IMAGE_SIZE:-4G}" \
337 ${cfg.paths.installDir}/scripts/setup-encrypted-storage.sh
338
330339# Get instance metadata
331340echo "Getting instance metadata..."
332341TOKEN=\$(curl -s -X PUT "http://169.254.169.254/latest/api/token" -H "X-aws-ec2-metadata-token-ttl-seconds: 21600")
packages/zig/scripts/hetzner-provision.shmodified+15-14
Changes to packages/zig/scripts/hetzner-provision.sh
@@ -11,6 +11,8 @@ BACKUP_DIR="${BACKUP_DIR:-/opt/mail/backups}"
1111RETAIN_DAYS="${RETAIN_DAYS:-7}"
1212SWAP_GB="${SWAP_GB:-2}"
1313FAIL2BAN_IGNORE_IPS="${FAIL2BAN_IGNORE_IPS:-127.0.0.1/8 ::1}"
14SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
15REPO_ROOT="$(cd "${SCRIPT_DIR}/../../.." && pwd)"
1416
1517echo "== memory safety =="
1618# The production box is shared with Bun applications. Give short deployment
@@ -79,26 +81,25 @@ systemctl enable --now fail2ban >/dev/null 2>&1 || true
7981systemctl restart fail2ban || true
8082echo "fail2ban: $(systemctl is-active fail2ban)"
8183
84echo "== encrypted storage =="
85if ! command -v cryptsetup >/dev/null 2>&1; then
86 export DEBIAN_FRONTEND=noninteractive
87 apt-get update -qq && apt-get install -y -qq cryptsetup >/dev/null
88fi
89chmod +x "${REPO_ROOT}/scripts/setup-encrypted-storage.sh"
90MAIL_ROOT="${MAIL_DIR}" "${REPO_ROOT}/scripts/setup-encrypted-storage.sh"
91
8292echo "== backups =="
8393mkdir -p "$BACKUP_DIR"
84cat > "$MAIL_DIR/backup.sh" <<BK
85#!/usr/bin/env bash
86set -euo pipefail
87TS=\$(date +%Y%m%d-%H%M%S)
88OUT="$BACKUP_DIR/mail-backup-\$TS.tgz"
89# mailboxes + databases + DKIM key + config (exclude the backups dir itself)
90tar czf "\$OUT" \\
91 -C "$MAIL_DIR" mail smtp.db caldav.db dkim 2>/dev/null || true
92[ -f /etc/mail/mail.env ] && tar rf "\${OUT%.tgz}.env.tar" -C /etc/mail mail.env 2>/dev/null || true
93# prune old backups
94find "$BACKUP_DIR" -name 'mail-backup-*.tgz' -mtime +$RETAIN_DAYS -delete 2>/dev/null || true
95echo "backup written: \$OUT (\$(du -h "\$OUT" 2>/dev/null | cut -f1))"
96BK
97chmod +x "$MAIL_DIR/backup.sh"
94install -m 0755 "${REPO_ROOT}/scripts/mail-backup.sh" "$MAIL_DIR/backup.sh"
95install -m 0755 "${REPO_ROOT}/scripts/mail-forward-compile" /usr/local/sbin/mail-forward-compile
96install -m 0755 "${REPO_ROOT}/scripts/maildir-migrate.sh" /usr/local/sbin/maildir-migrate
9897
9998cat > /etc/systemd/system/mail-backup.service <<'U'
10099[Unit]
101100Description=Mail server backup
101Requires=mail-storage.service
102After=mail-storage.service
102103[Service]
103104Type=oneshot
104105ExecStart=/opt/mail/backup.sh
scripts/mail-backup.shadded+64-0
Changes to scripts/mail-backup.sh
@@ -0,0 +1,64 @@
1#!/usr/bin/env bash
2set -euo pipefail
3
4MAIL_DIR="${MAIL_DIR:-/opt/mail}"
5BACKUP_DIR="${BACKUP_DIR:-${MAIL_DIR}/backups}"
6RETAIN_DAYS="${RETAIN_DAYS:-7}"
7
8[[ "${MAIL_DIR}" == /* && "${MAIL_DIR}" != "/" ]] || {
9 echo "mail-backup: MAIL_DIR must be an absolute non-root path" >&2
10 exit 1
11}
12[[ "${BACKUP_DIR}" == /* && "${BACKUP_DIR}" != "/" ]] || {
13 echo "mail-backup: BACKUP_DIR must be an absolute non-root path" >&2
14 exit 1
15}
16
17install -d -m 0700 -o root -g root "${BACKUP_DIR}"
18
19timestamp="$(date -u +%Y%m%d-%H%M%S)"
20output="${BACKUP_DIR}/mail-backup-${timestamp}.tgz"
21partial="${output}.partial"
22work_dir="$(mktemp -d "${BACKUP_DIR}/.mail-backup-${timestamp}.XXXXXX")"
23
24cleanup() {
25 rm -f "${partial}"
26 find "${work_dir}" -depth -delete 2>/dev/null || true
27}
28trap cleanup EXIT
29
30declare -a snapshots=()
31for database in smtp.db caldav.db; do
32 if [[ -f "${MAIL_DIR}/${database}" ]]; then
33 sqlite3 "${MAIL_DIR}/${database}" ".backup '${work_dir}/${database}'"
34 sqlite3 -readonly "${work_dir}/${database}" "PRAGMA quick_check;" \
35 | grep -qx ok
36 snapshots+=("${database}")
37 fi
38done
39
40declare -a persistent=()
41for path in mail dkim forwards.json forwards.sieve cert-backups; do
42 [[ -e "${MAIL_DIR}/${path}" ]] && persistent+=("${path}")
43done
44
45tar --dereference --exclude='mail/*/.*' -czf "${partial}" \
46 -C "${MAIL_DIR}" "${persistent[@]}" \
47 -C "${work_dir}" "${snapshots[@]}"
48tar tzf "${partial}" >/dev/null
49chmod 0600 "${partial}"
50mv "${partial}" "${output}"
51
52if [[ -f /etc/mail/mail.env ]]; then
53 env_output="${output%.tgz}.env.tar"
54 tar cf "${env_output}.partial" -C /etc/mail mail.env
55 tar tf "${env_output}.partial" >/dev/null
56 chmod 0600 "${env_output}.partial"
57 mv "${env_output}.partial" "${env_output}"
58fi
59
60find "${BACKUP_DIR}" -maxdepth 1 -type f \
61 \( -name 'mail-backup-*.tgz' -o -name 'mail-backup-*.env.tar' \) \
62 -mtime "+${RETAIN_DAYS}" -delete
63
64echo "backup written: ${output} ($(du -h "${output}" | cut -f1))"
scripts/mail-restic-backup.shadded+73-0
Changes to scripts/mail-restic-backup.sh
@@ -0,0 +1,73 @@
1#!/usr/bin/env bash
2set -euo pipefail
3
4# Create a transactionally consistent local archive, then copy it into an
5# authenticated, client-side encrypted Restic repository. S3's SSE-KMS remains
6# enabled as a second encryption layer; Restic encryption happens first.
7
8MAIL_ROOT="${MAIL_ROOT:-/opt/mail}"
9RESTIC_CONFIG_DIR="${MAIL_RESTIC_CONFIG_DIR:-/var/lib/mail-storage/data/restic}"
10RESTIC_ENV="${MAIL_RESTIC_ENV:-${RESTIC_CONFIG_DIR}/restic.env}"
11BACKUP_SCRIPT="${MAIL_BACKUP_SCRIPT:-${MAIL_ROOT}/backup.sh}"
12STATUS_FILE="${MAIL_RESTIC_STATUS_FILE:-${RESTIC_CONFIG_DIR}/last-success}"
13CHECK_SUBSET="${MAIL_RESTIC_CHECK_SUBSET:-5%}"
14
15die() {
16 echo "mail-restic-backup: $*" >&2
17 exit 1
18}
19
20[[ "${EUID}" -eq 0 ]] || die "must run as root"
21mountpoint -q /var/lib/mail-storage || die "encrypted mail storage is not mounted"
22[[ -x "${BACKUP_SCRIPT}" ]] || die "${BACKUP_SCRIPT} is not executable"
23[[ -r "${RESTIC_ENV}" ]] || die "${RESTIC_ENV} is unavailable"
24command -v restic >/dev/null 2>&1 || die "restic is not installed"
25
26set -a
27# shellcheck disable=SC1090
28source "${RESTIC_ENV}"
29set +a
30
31: "${RESTIC_REPOSITORY:?RESTIC_REPOSITORY is required}"
32: "${RESTIC_PASSWORD_FILE:?RESTIC_PASSWORD_FILE is required}"
33: "${AWS_ACCESS_KEY_ID:?AWS_ACCESS_KEY_ID is required}"
34: "${AWS_SECRET_ACCESS_KEY:?AWS_SECRET_ACCESS_KEY is required}"
35
36umask 077
37"${BACKUP_SCRIPT}"
38
39latest="$(find -L "${MAIL_ROOT}/backups" -maxdepth 1 -type f -name 'mail-backup-*.tgz' \
40 -printf '%T@ %p\n' | sort -nr | head -1 | cut -d' ' -f2-)"
41[[ -n "${latest}" && -f "${latest}" ]] || die "no local backup archive was produced"
42tar -tzf "${latest}" >/dev/null
43
44declare -a inputs=("${latest}")
45env_archive="${latest%.tgz}.env.tar"
46[[ -f "${env_archive}" ]] && inputs+=("${env_archive}")
47
48restic backup \
49 --host "$(hostname -f 2>/dev/null || hostname)" \
50 --tag mail \
51 --tag production \
52 "${inputs[@]}"
53
54restic forget \
55 --tag mail \
56 --keep-daily 7 \
57 --keep-weekly 5 \
58 --keep-monthly 12 \
59 --prune
60
61restic check --read-data-subset="${CHECK_SUBSET}"
62
63install -d -m 0700 "$(dirname "${STATUS_FILE}")"
64status_tmp="${STATUS_FILE}.tmp"
65{
66 echo "timestamp=$(date -u +%s)"
67 echo "archive=$(basename "${latest}")"
68 echo "repository=${RESTIC_REPOSITORY}"
69} > "${status_tmp}"
70chmod 0600 "${status_tmp}"
71mv "${status_tmp}" "${STATUS_FILE}"
72
73echo "mail-restic-backup: uploaded and verified $(basename "${latest}")"
scripts/mail-restic-restore-check.shadded+58-0
Changes to scripts/mail-restic-restore-check.sh
@@ -0,0 +1,58 @@
1#!/usr/bin/env bash
2set -euo pipefail
3
4# Restore the newest Restic snapshot into a disposable directory on the
5# encrypted filesystem and validate the archive, databases, messages, and
6# forwarding configuration without touching live data.
7
8RESTIC_CONFIG_DIR="${MAIL_RESTIC_CONFIG_DIR:-/var/lib/mail-storage/data/restic}"
9RESTIC_ENV="${MAIL_RESTIC_ENV:-${RESTIC_CONFIG_DIR}/restic.env}"
10RESTORE_PARENT="${MAIL_RESTORE_CHECK_PARENT:-/var/lib/mail-storage}"
11
12die() {
13 echo "mail-restic-restore-check: $*" >&2
14 exit 1
15}
16
17[[ "${EUID}" -eq 0 ]] || die "must run as root"
18mountpoint -q /var/lib/mail-storage || die "encrypted mail storage is not mounted"
19[[ -r "${RESTIC_ENV}" ]] || die "${RESTIC_ENV} is unavailable"
20command -v restic >/dev/null 2>&1 || die "restic is not installed"
21
22set -a
23# shellcheck disable=SC1090
24source "${RESTIC_ENV}"
25set +a
26
27: "${RESTIC_REPOSITORY:?RESTIC_REPOSITORY is required}"
28: "${RESTIC_PASSWORD_FILE:?RESTIC_PASSWORD_FILE is required}"
29
30restore_dir="$(mktemp -d "${RESTORE_PARENT}/restic-restore-check.XXXXXX")"
31archive_dir="${restore_dir}/archive"
32unpack_dir="${restore_dir}/unpacked"
33cleanup() {
34 find "${restore_dir}" -depth -delete 2>/dev/null || true
35}
36trap cleanup EXIT
37
38install -d -m 0700 "${archive_dir}" "${unpack_dir}"
39restic restore latest --tag mail --target "${archive_dir}"
40
41archive="$(find "${archive_dir}" -type f -name 'mail-backup-*.tgz' \
42 -printf '%T@ %p\n' | sort -nr | head -1 | cut -d' ' -f2-)"
43[[ -n "${archive}" && -f "${archive}" ]] || die "restored snapshot has no mail archive"
44tar -tzf "${archive}" >/dev/null
45tar -xzf "${archive}" -C "${unpack_dir}"
46
47for database in smtp.db caldav.db; do
48 if [[ -f "${unpack_dir}/${database}" ]]; then
49 sqlite3 -readonly "${unpack_dir}/${database}" 'PRAGMA quick_check;' | grep -qx ok \
50 || die "${database} failed quick_check"
51 fi
52done
53
54message_count="$(find "${unpack_dir}/mail" -type f 2>/dev/null | wc -l)"
55[[ "${message_count}" -gt 0 ]] || die "restored archive contains no messages"
56[[ -f "${unpack_dir}/forwards.json" ]] || die "restored archive lacks forwarding rules"
57
58echo "mail-restic-restore-check: ok (${message_count} messages)"
scripts/mail-storage-external.shadded+253-0
Changes to scripts/mail-storage-external.sh
@@ -0,0 +1,253 @@
1#!/usr/bin/env bash
2set -euo pipefail
3
4# Manage an externally escrowed LUKS2 mail volume. In external mode no volume
5# key or cloud credential is stored on the mail host. `unlock` accepts the raw
6# key on stdin, passes it directly to cryptsetup, and never writes it to disk.
7
8COMMAND="${1:-status}"
9MAIL_SERVICE="${MAIL_SERVICE:-mail}"
10IMAGE_PATH="${MAIL_ENCRYPTED_IMAGE:-/var/lib/mail-storage.luks}"
11MOUNT_PATH="${MAIL_ENCRYPTED_MOUNT:-/var/lib/mail-storage}"
12MAPPER_NAME="${MAIL_ENCRYPTED_MAPPER:-mail-storage}"
13CREDENTIAL_PATH="${MAIL_ENCRYPTED_CREDENTIAL:-/etc/credstore.encrypted/mail-storage-key.cred}"
14UNIT_PATH="/etc/systemd/system/mail-storage.service"
15UNIT_BACKUP="/etc/systemd/system/mail-storage.service.machine-bound-backup"
16MAIL_DROPIN="/etc/systemd/system/${MAIL_SERVICE}.service.d/encrypted-storage.conf"
17BACKUP_DROPIN="/etc/systemd/system/mail-backup.service.d/encrypted-storage.conf"
18
19die() {
20 echo "mail-storage: $*" >&2
21 return 1
22}
23
24[[ "${EUID}" -eq 0 ]] || die "must run as root"
25[[ "${IMAGE_PATH}" == /* && "${IMAGE_PATH}" != "/" ]] || die "invalid image path"
26[[ "${MOUNT_PATH}" == /* && "${MOUNT_PATH}" != "/" ]] || die "invalid mount path"
27[[ "${MAPPER_NAME}" =~ ^[A-Za-z0-9_.+-]+$ ]] || die "invalid mapper name"
28
29CRYPTSETUP="$(command -v cryptsetup || true)"
30MOUNT_BIN="$(command -v mount || true)"
31UMOUNT_BIN="$(command -v umount || true)"
32MOUNTPOINT_BIN="$(command -v mountpoint || true)"
33LOSETUP_BIN="$(command -v losetup || true)"
34[[ -n "${CRYPTSETUP}" && -n "${MOUNT_BIN}" && -n "${UMOUNT_BIN}" && -n "${MOUNTPOINT_BIN}" && -n "${LOSETUP_BIN}" ]] \
35 || die "cryptsetup and mount utilities are required"
36
37configure_external() {
38 [[ -f "${IMAGE_PATH}" ]] || die "${IMAGE_PATH} does not exist"
39 [[ -f "${CREDENTIAL_PATH}" ]] || die "machine-bound credential is unavailable for rollback"
40
41 systemctl stop "${MAIL_SERVICE}.service" 2>/dev/null || true
42 systemctl stop mail-backup.service 2>/dev/null || true
43 systemctl stop mail-storage.service 2>/dev/null || true
44
45 if [[ -f "${UNIT_PATH}" && ! -f "${UNIT_BACKUP}" ]]; then
46 cp -p "${UNIT_PATH}" "${UNIT_BACKUP}"
47 fi
48
49 cat > "${UNIT_PATH}" <<EOF
50[Unit]
51Description=Externally unlocked encrypted mail data filesystem
52Before=${MAIL_SERVICE}.service mail-backup.service
53
54[Service]
55Type=oneshot
56RemainAfterExit=yes
57ExecStart=${MOUNTPOINT_BIN} -q ${MOUNT_PATH}
58ExecStop=-${UMOUNT_BIN} ${MOUNT_PATH}
59ExecStop=-${CRYPTSETUP} close ${MAPPER_NAME}
60TimeoutStopSec=120
61EOF
62
63 install -d -m 0755 "$(dirname "${MAIL_DROPIN}")" "$(dirname "${BACKUP_DROPIN}")"
64 cat > "${MAIL_DROPIN}" <<EOF
65[Unit]
66After=mail-storage.service
67
68[Service]
69ExecCondition=${MOUNTPOINT_BIN} -q ${MOUNT_PATH}
70EOF
71 cp "${MAIL_DROPIN}" "${BACKUP_DROPIN}"
72
73 systemctl disable mail-storage.service >/dev/null 2>&1 || true
74 systemctl daemon-reload
75 echo "mail-storage: external mode configured; volume is locked"
76}
77
78unlock_external() {
79 [[ -f "${IMAGE_PATH}" ]] || die "${IMAGE_PATH} does not exist"
80
81 if ! "${CRYPTSETUP}" status "${MAPPER_NAME}" >/dev/null 2>&1; then
82 backing_file="$(readlink -f "${IMAGE_PATH}")"
83 loop_device="$("${LOSETUP_BIN}" -j "${backing_file}" | awk -F: 'NR == 1 { print $1 }')"
84 created_loop=false
85 if [[ -z "${loop_device}" ]]; then
86 loop_device="$("${LOSETUP_BIN}" --find --show "${backing_file}")"
87 created_loop=true
88 fi
89 # cryptsetup consumes the raw key from this process's stdin. No shell
90 # variable, argument, environment entry, or persistent file holds it.
91 if ! "${CRYPTSETUP}" open --type luks2 --key-file - "${loop_device}" "${MAPPER_NAME}"; then
92 [[ "${created_loop}" == "true" ]] && "${LOSETUP_BIN}" -d "${loop_device}" || true
93 return 1
94 fi
95 else
96 # Consume stdin even when already unlocked so callers never get a broken
97 # pipe while streaming a secret.
98 dd of=/dev/null status=none
99 fi
100
101 if ! "${MOUNTPOINT_BIN}" -q "${MOUNT_PATH}"; then
102 install -d -m 0750 -o root -g mail-server "${MOUNT_PATH}"
103 "${MOUNT_BIN}" -o noatime,nodev,nosuid "/dev/mapper/${MAPPER_NAME}" "${MOUNT_PATH}"
104 fi
105
106 systemctl reset-failed mail-storage.service "${MAIL_SERVICE}.service" 2>/dev/null || true
107 systemctl start mail-storage.service
108 systemctl start "${MAIL_SERVICE}.service"
109 sleep 2
110 systemctl is-active --quiet mail-storage.service || die "storage unit is not active"
111 systemctl is-active --quiet "${MAIL_SERVICE}.service" || die "mail service is not active"
112 echo "mail-storage: unlocked; mail is active"
113}
114
115lock_external() {
116 systemctl stop mail-backup.service 2>/dev/null || true
117 systemctl stop "${MAIL_SERVICE}.service" 2>/dev/null || true
118 if systemctl is-active --quiet mail-storage.service; then
119 systemctl stop mail-storage.service
120 else
121 "${MOUNTPOINT_BIN}" -q "${MOUNT_PATH}" && "${UMOUNT_BIN}" "${MOUNT_PATH}" || true
122 "${CRYPTSETUP}" status "${MAPPER_NAME}" >/dev/null 2>&1 \
123 && "${CRYPTSETUP}" close "${MAPPER_NAME}" || true
124 fi
125 backing_file="$(readlink -f "${IMAGE_PATH}")"
126 while IFS=: read -r loop_device _; do
127 [[ -n "${loop_device}" ]] && "${LOSETUP_BIN}" -d "${loop_device}" 2>/dev/null || true
128 done < <("${LOSETUP_BIN}" -j "${backing_file}")
129 echo "mail-storage: locked; mail is stopped"
130}
131
132finalize_external() {
133 "${MOUNTPOINT_BIN}" -q "${MOUNT_PATH}" || die "refusing to remove local credential while storage is locked"
134 systemctl is-active --quiet "${MAIL_SERVICE}.service" \
135 || die "refusing to remove local credential while mail is inactive"
136 [[ -f "${UNIT_BACKUP}" ]] || die "machine-bound unit backup is missing"
137
138 shred -u "${CREDENTIAL_PATH}" 2>/dev/null || rm -f "${CREDENTIAL_PATH}"
139 chmod 0600 "${UNIT_BACKUP}"
140 echo "mail-storage: local volume credential removed; external mode finalized"
141}
142
143rollback_machine() {
144 [[ -f "${UNIT_BACKUP}" ]] || die "machine-bound unit backup is missing"
145 [[ -f "${CREDENTIAL_PATH}" ]] || die "machine-bound credential is missing"
146
147 systemctl stop "${MAIL_SERVICE}.service" 2>/dev/null || true
148 systemctl stop mail-storage.service 2>/dev/null || true
149 cp -p "${UNIT_BACKUP}" "${UNIT_PATH}"
150 cat > "${MAIL_DROPIN}" <<EOF
151[Unit]
152Requires=mail-storage.service
153After=mail-storage.service
154EOF
155 cp "${MAIL_DROPIN}" "${BACKUP_DROPIN}"
156 systemctl daemon-reload
157 systemctl enable --now mail-storage.service
158 systemctl reset-failed "${MAIL_SERVICE}.service" 2>/dev/null || true
159 systemctl start "${MAIL_SERVICE}.service"
160 echo "mail-storage: restored machine-bound unlock mode"
161}
162
163status_external() {
164 local mode="machine-bound"
165 if [[ ! -f "${CREDENTIAL_PATH}" ]]; then
166 mode="external"
167 elif ! grep -q '^LoadCredentialEncrypted=' "${UNIT_PATH}" 2>/dev/null; then
168 mode="external-pending-finalization"
169 fi
170 echo "mode=${mode}"
171 if [[ -f "${CREDENTIAL_PATH}" ]]; then
172 echo "local_credential=present"
173 else
174 echo "local_credential=absent"
175 fi
176 if "${CRYPTSETUP}" status "${MAPPER_NAME}" >/dev/null 2>&1; then
177 echo "mapper=active"
178 else
179 echo "mapper=locked"
180 fi
181 if "${MOUNTPOINT_BIN}" -q "${MOUNT_PATH}"; then
182 echo "mount=active"
183 else
184 echo "mount=inactive"
185 fi
186 echo "mail=$(systemctl is-active "${MAIL_SERVICE}.service" 2>/dev/null || true)"
187 echo "backup_timer=$(systemctl is-active mail-backup.timer 2>/dev/null || true)"
188}
189
190add_recovery_key() {
191 local key_dir current_key recovery_key
192 key_dir="$(mktemp -d /run/mail-storage-recovery.XXXXXX)"
193 current_key="${key_dir}/current"
194 recovery_key="${key_dir}/recovery"
195 cleanup_recovery_keys() {
196 [[ -n "${current_key:-}" ]] && shred -u "${current_key}" 2>/dev/null || true
197 [[ -n "${recovery_key:-}" ]] && shred -u "${recovery_key}" 2>/dev/null || true
198 [[ -n "${current_key:-}" ]] && rm -f "${current_key}" || true
199 [[ -n "${recovery_key:-}" ]] && rm -f "${recovery_key}" || true
200 [[ -n "${key_dir:-}" ]] && rmdir "${key_dir}" 2>/dev/null || true
201 }
202 trap cleanup_recovery_keys EXIT
203
204 dd if=/dev/stdin of="${current_key}" bs=64 count=1 iflag=fullblock status=none
205 dd if=/dev/stdin of="${recovery_key}" bs=64 count=1 iflag=fullblock status=none
206 [[ "$(stat -c %s "${current_key}")" -eq 64 ]] || die "invalid current key length"
207 [[ "$(stat -c %s "${recovery_key}")" -eq 64 ]] || die "invalid recovery key length"
208 chmod 0400 "${current_key}" "${recovery_key}"
209
210 "${CRYPTSETUP}" luksAddKey \
211 --key-file "${current_key}" \
212 --new-keyfile "${recovery_key}" \
213 "${IMAGE_PATH}"
214 "${CRYPTSETUP}" open \
215 --test-passphrase \
216 --type luks2 \
217 --key-file "${recovery_key}" \
218 "${IMAGE_PATH}"
219 echo "mail-storage: independent recovery keyslot added and verified"
220}
221
222remove_keyslot() {
223 local slot="${2:-}" key_dir current_key
224 [[ "${slot}" =~ ^[0-9]+$ ]] || die "a numeric LUKS keyslot is required"
225 key_dir="$(mktemp -d /run/mail-storage-keyslot.XXXXXX)"
226 current_key="${key_dir}/current"
227 cleanup_keyslot_key() {
228 [[ -n "${current_key:-}" ]] && shred -u "${current_key}" 2>/dev/null || true
229 [[ -n "${current_key:-}" ]] && rm -f "${current_key}" || true
230 [[ -n "${key_dir:-}" ]] && rmdir "${key_dir}" 2>/dev/null || true
231 }
232 trap cleanup_keyslot_key EXIT
233
234 dd if=/dev/stdin of="${current_key}" bs=64 count=1 iflag=fullblock status=none
235 [[ "$(stat -c %s "${current_key}")" -eq 64 ]] || die "invalid current key length"
236 chmod 0400 "${current_key}"
237 "${CRYPTSETUP}" luksKillSlot --key-file "${current_key}" "${IMAGE_PATH}" "${slot}"
238 echo "mail-storage: removed keyslot ${slot}"
239}
240
241case "${COMMAND}" in
242 configure-external) configure_external ;;
243 unlock) unlock_external ;;
244 lock) lock_external ;;
245 finalize-external) finalize_external ;;
246 rollback-machine) rollback_machine ;;
247 add-recovery-key) add_recovery_key ;;
248 remove-keyslot) remove_keyslot "$@" ;;
249 status) status_external ;;
250 *)
251 die "usage: $0 {configure-external|unlock|lock|finalize-external|rollback-machine|add-recovery-key|remove-keyslot <slot>|status}"
252 ;;
253esac
scripts/mail-storage-health.shadded+31-0
Changes to scripts/mail-storage-health.sh
@@ -0,0 +1,31 @@
1#!/usr/bin/env bash
2set -euo pipefail
3
4MOUNT_PATH="${MAIL_ENCRYPTED_MOUNT:-/var/lib/mail-storage}"
5STATUS_FILE="${MAIL_RESTIC_STATUS_FILE:-${MOUNT_PATH}/data/restic/last-success}"
6WARN_PERCENT="${MAIL_STORAGE_WARN_PERCENT:-70}"
7CRITICAL_PERCENT="${MAIL_STORAGE_CRITICAL_PERCENT:-85}"
8MAX_BACKUP_AGE="${MAIL_BACKUP_MAX_AGE_SECONDS:-90000}"
9
10die() {
11 echo "mail-storage-health: $*" >&2
12 exit 1
13}
14
15mountpoint -q "${MOUNT_PATH}" || die "${MOUNT_PATH} is not mounted"
16usage="$(df -P "${MOUNT_PATH}" | awk 'NR == 2 { gsub(/%/, "", $5); print $5 }')"
17[[ "${usage}" =~ ^[0-9]+$ ]] || die "could not read filesystem usage"
18
19if (( usage >= CRITICAL_PERCENT )); then
20 die "encrypted storage is ${usage}% full (critical threshold ${CRITICAL_PERCENT}%)"
21elif (( usage >= WARN_PERCENT )); then
22 echo "mail-storage-health: warning: encrypted storage is ${usage}% full" >&2
23fi
24
25[[ -r "${STATUS_FILE}" ]] || die "no successful off-host backup status exists"
26timestamp="$(awk -F= '$1 == "timestamp" { print $2; exit }' "${STATUS_FILE}")"
27[[ "${timestamp}" =~ ^[0-9]+$ ]] || die "backup status timestamp is invalid"
28age="$(( $(date -u +%s) - timestamp ))"
29(( age <= MAX_BACKUP_AGE )) || die "latest verified off-host backup is ${age}s old"
30
31echo "mail-storage-health: ok (usage=${usage}%, backup_age=${age}s)"
scripts/migrate-encrypted-storage-volume.shadded+109-0
Changes to scripts/migrate-encrypted-storage-volume.sh
@@ -0,0 +1,109 @@
1#!/usr/bin/env bash
2set -euo pipefail
3
4# Move the existing LUKS image onto a dedicated block volume without changing
5# its UUID, keyslots, or public path. `prepare` intentionally leaves mail
6# locked; the external-key controller must unlock and validate before
7# `finalize` removes the root-disk rollback image.
8
9COMMAND="${1:-status}"
10DEVICE="${MAIL_VOLUME_DEVICE:-${2:-}}"
11VOLUME_MOUNT="${MAIL_VOLUME_MOUNT:-/var/lib/mail-volume}"
12IMAGE_PATH="${MAIL_ENCRYPTED_IMAGE:-/var/lib/mail-storage.luks}"
13TARGET_SIZE="${MAIL_ENCRYPTED_IMAGE_SIZE:-16G}"
14VOLUME_IMAGE="${VOLUME_MOUNT}/mail-storage.luks"
15ROOT_BACKUP="${IMAGE_PATH}.root-backup"
16
17die() {
18 echo "mail-volume-migrate: $*" >&2
19 exit 1
20}
21
22[[ "${EUID}" -eq 0 ]] || die "must run as root"
23[[ "${VOLUME_MOUNT}" == /* && "${VOLUME_MOUNT}" != "/" ]] || die "invalid mount path"
24[[ "${IMAGE_PATH}" == /* && "${IMAGE_PATH}" != "/" ]] || die "invalid image path"
25
26ensure_volume_mount() {
27 [[ -n "${DEVICE}" ]] || die "MAIL_VOLUME_DEVICE or device argument is required"
28 [[ -b "${DEVICE}" ]] || die "${DEVICE} is not a block device"
29 install -d -m 0700 "${VOLUME_MOUNT}"
30
31 if ! blkid "${DEVICE}" >/dev/null 2>&1; then
32 mkfs.ext4 -q -L mail-volume "${DEVICE}"
33 fi
34 uuid="$(blkid -s UUID -o value "${DEVICE}")"
35 [[ -n "${uuid}" ]] || die "could not resolve volume UUID"
36
37 if ! grep -qE "^[^#]+[[:space:]]+${VOLUME_MOUNT//\//\\/}[[:space:]]" /etc/fstab; then
38 echo "UUID=${uuid} ${VOLUME_MOUNT} ext4 defaults,noatime,nodev,nosuid 0 2" >> /etc/fstab
39 fi
40 mountpoint -q "${VOLUME_MOUNT}" || mount "${VOLUME_MOUNT}"
41 chmod 0700 "${VOLUME_MOUNT}"
42}
43
44prepare() {
45 [[ -f "${IMAGE_PATH}" && ! -L "${IMAGE_PATH}" ]] \
46 || die "${IMAGE_PATH} must be the current root-disk image"
47 [[ ! -e "${ROOT_BACKUP}" ]] || die "${ROOT_BACKUP} already exists"
48 ensure_volume_mount
49
50 /usr/local/sbin/mail-storage-external lock
51 if [[ -e "${VOLUME_IMAGE}" ]]; then
52 root_size="$(stat -c %s "${IMAGE_PATH}")"
53 if ! cmp -n "${root_size}" "${IMAGE_PATH}" "${VOLUME_IMAGE}"; then
54 cp --sparse=always --reflink=auto "${IMAGE_PATH}" "${VOLUME_IMAGE}.new"
55 chmod 0600 "${VOLUME_IMAGE}.new"
56 cmp -s "${IMAGE_PATH}" "${VOLUME_IMAGE}.new" || die "refreshed image failed byte comparison"
57 sync "${VOLUME_IMAGE}.new"
58 mv "${VOLUME_IMAGE}.new" "${VOLUME_IMAGE}"
59 fi
60 else
61 cp --sparse=always --reflink=auto "${IMAGE_PATH}" "${VOLUME_IMAGE}.new"
62 chmod 0600 "${VOLUME_IMAGE}.new"
63 cmp -s "${IMAGE_PATH}" "${VOLUME_IMAGE}.new" || die "copied image failed byte comparison"
64 sync "${VOLUME_IMAGE}.new"
65 mv "${VOLUME_IMAGE}.new" "${VOLUME_IMAGE}"
66 fi
67 mv "${IMAGE_PATH}" "${ROOT_BACKUP}"
68 ln -s "${VOLUME_IMAGE}" "${IMAGE_PATH}"
69 truncate -s "${TARGET_SIZE}" "${VOLUME_IMAGE}"
70 sync "${VOLUME_IMAGE}"
71 echo "mail-volume-migrate: prepared; external unlock is required"
72}
73
74finalize() {
75 mountpoint -q /var/lib/mail-storage || die "encrypted mail storage is not mounted"
76 systemctl is-active --quiet mail.service || die "mail is not active"
77 [[ -L "${IMAGE_PATH}" && "$(readlink "${IMAGE_PATH}")" == "${VOLUME_IMAGE}" ]] \
78 || die "mail image does not point to dedicated volume"
79
80 loop_device="$(cryptsetup status mail-storage | awk '/device:/ { print $2; exit }')"
81 [[ -n "${loop_device}" ]] || die "could not identify loop device"
82 losetup -c "${loop_device}"
83 resize2fs /dev/mapper/mail-storage
84 rm -f "${ROOT_BACKUP}"
85 echo "mail-volume-migrate: finalized"
86}
87
88rollback() {
89 /usr/local/sbin/mail-storage-external lock
90 [[ -f "${ROOT_BACKUP}" ]] || die "root rollback image is unavailable"
91 [[ -L "${IMAGE_PATH}" ]] || die "${IMAGE_PATH} is not a symlink"
92 unlink "${IMAGE_PATH}"
93 mv "${ROOT_BACKUP}" "${IMAGE_PATH}"
94 echo "mail-volume-migrate: rolled back; external unlock is required"
95}
96
97status() {
98 findmnt "${VOLUME_MOUNT}" || true
99 ls -l "${IMAGE_PATH}" "${VOLUME_IMAGE}" "${ROOT_BACKUP}" 2>/dev/null || true
100 df -h "${VOLUME_MOUNT}" 2>/dev/null || true
101}
102
103case "${COMMAND}" in
104 prepare) prepare ;;
105 finalize) finalize ;;
106 rollback) rollback ;;
107 status) status ;;
108 *) die "usage: $0 {prepare|finalize|rollback|status} [device]" ;;
109esac
scripts/setup-encrypted-storage.shadded+256-0
Changes to scripts/setup-encrypted-storage.sh
@@ -0,0 +1,256 @@
1#!/usr/bin/env bash
2set -euo pipefail
3
4# Provision a LUKS2-backed filesystem for mail data and migrate an existing
5# /opt/mail installation without changing the paths used by the server.
6#
7# The random volume key is never left on persistent storage in plaintext.
8# systemd-creds wraps it with the host credential secret and exposes it only to
9# mail-storage.service through a private, read-only runtime credential.
10
11MAIL_ROOT="${MAIL_ROOT:-/opt/mail}"
12MAIL_SERVICE="${MAIL_SERVICE:-mail}"
13IMAGE_PATH="${MAIL_ENCRYPTED_IMAGE:-/var/lib/mail-storage.luks}"
14IMAGE_SIZE="${MAIL_ENCRYPTED_IMAGE_SIZE:-2G}"
15MOUNT_PATH="${MAIL_ENCRYPTED_MOUNT:-/var/lib/mail-storage}"
16MAPPER_NAME="${MAIL_ENCRYPTED_MAPPER:-mail-storage}"
17CREDENTIAL_PATH="${MAIL_ENCRYPTED_CREDENTIAL:-/etc/credstore.encrypted/mail-storage-key.cred}"
18UNIT_PATH="/etc/systemd/system/mail-storage.service"
19DROPIN_PATH="/etc/systemd/system/${MAIL_SERVICE}.service.d/encrypted-storage.conf"
20
21die() {
22 echo "encrypted-storage: $*" >&2
23 return 1
24}
25
26[[ "${EUID}" -eq 0 ]] || die "must run as root"
27[[ "${MAIL_ROOT}" == /* && "${MAIL_ROOT}" != "/" ]] || die "MAIL_ROOT must be an absolute non-root path"
28[[ "${IMAGE_PATH}" == /* && "${IMAGE_PATH}" != "/" ]] || die "MAIL_ENCRYPTED_IMAGE must be an absolute file path"
29[[ "${MOUNT_PATH}" == /* && "${MOUNT_PATH}" != "/" ]] || die "MAIL_ENCRYPTED_MOUNT must be an absolute non-root path"
30[[ "${MAPPER_NAME}" =~ ^[A-Za-z0-9_.+-]+$ ]] || die "invalid mapper name"
31
32CRYPTSETUP="$(command -v cryptsetup || true)"
33MKFS_EXT4="$(command -v mkfs.ext4 || true)"
34SYSTEMD_CREDS="$(command -v systemd-creds || true)"
35MOUNT_BIN="$(command -v mount || true)"
36UMOUNT_BIN="$(command -v umount || true)"
37
38[[ -n "${CRYPTSETUP}" ]] || die "cryptsetup is required"
39[[ -n "${MKFS_EXT4}" ]] || die "mkfs.ext4 is required"
40[[ -n "${SYSTEMD_CREDS}" ]] || die "systemd-creds is required"
41[[ -n "${MOUNT_BIN}" && -n "${UMOUNT_BIN}" ]] || die "mount and umount are required"
42
43install -d -m 0700 "$(dirname "${CREDENTIAL_PATH}")"
44install -d -m 0750 -o root -g mail-server "${MOUNT_PATH}"
45install -d -m 0755 "$(dirname "${IMAGE_PATH}")"
46
47created_image=0
48temporary_key=""
49cleanup_key() {
50 if [[ -n "${temporary_key}" && -f "${temporary_key}" ]]; then
51 shred -u "${temporary_key}" 2>/dev/null || rm -f "${temporary_key}"
52 fi
53}
54trap cleanup_key EXIT
55
56if [[ ! -f "${IMAGE_PATH}" ]]; then
57 [[ ! -e "${CREDENTIAL_PATH}" ]] || die "credential exists but encrypted image does not"
58
59 temporary_key="$(mktemp /run/mail-storage-key.XXXXXX)"
60 chmod 0400 "${temporary_key}"
61 dd if=/dev/urandom of="${temporary_key}" bs=64 count=1 status=none
62
63 echo "encrypted-storage: creating ${IMAGE_SIZE} LUKS2 image"
64 fallocate -l "${IMAGE_SIZE}" "${IMAGE_PATH}"
65 chmod 0600 "${IMAGE_PATH}"
66 "${CRYPTSETUP}" luksFormat \
67 --batch-mode \
68 --type luks2 \
69 --cipher aes-xts-plain64 \
70 --key-size 512 \
71 --hash sha512 \
72 --pbkdf argon2id \
73 --iter-time 5000 \
74 --key-file "${temporary_key}" \
75 "${IMAGE_PATH}"
76
77 "${CRYPTSETUP}" open --type luks2 --key-file "${temporary_key}" "${IMAGE_PATH}" "${MAPPER_NAME}"
78 "${MKFS_EXT4}" -q -L mail-storage "/dev/mapper/${MAPPER_NAME}"
79 "${CRYPTSETUP}" close "${MAPPER_NAME}"
80
81 "${SYSTEMD_CREDS}" encrypt \
82 --name=mail-storage-key \
83 "${temporary_key}" \
84 "${CREDENTIAL_PATH}"
85 chmod 0600 "${CREDENTIAL_PATH}"
86 created_image=1
87elif [[ ! -f "${CREDENTIAL_PATH}" ]]; then
88 die "encrypted image exists but ${CREDENTIAL_PATH} is missing"
89fi
90
91cat > "${UNIT_PATH}" <<EOF
92[Unit]
93Description=Encrypted mail data filesystem
94After=local-fs.target
95Before=${MAIL_SERVICE}.service
96
97[Service]
98Type=oneshot
99RemainAfterExit=yes
100LoadCredentialEncrypted=mail-storage-key:${CREDENTIAL_PATH}
101ExecStart=${CRYPTSETUP} open --type luks2 --key-file %d/mail-storage-key ${IMAGE_PATH} ${MAPPER_NAME}
102ExecStart=${MOUNT_BIN} -o noatime,nodev,nosuid /dev/mapper/${MAPPER_NAME} ${MOUNT_PATH}
103ExecStop=-${UMOUNT_BIN} ${MOUNT_PATH}
104ExecStop=-${CRYPTSETUP} close ${MAPPER_NAME}
105TimeoutStartSec=0
106TimeoutStopSec=120
107
108[Install]
109WantedBy=multi-user.target
110EOF
111
112install -d -m 0755 "$(dirname "${DROPIN_PATH}")"
113cat > "${DROPIN_PATH}" <<EOF
114[Unit]
115Requires=mail-storage.service
116After=mail-storage.service
117EOF
118
119systemctl daemon-reload
120systemctl enable mail-storage.service >/dev/null
121if ! systemctl is-active --quiet mail-storage.service; then
122 systemctl start mail-storage.service
123fi
124mountpoint -q "${MOUNT_PATH}" || die "${MOUNT_PATH} is not mounted"
125
126install -d -m 0750 -o mail-server -g mail-server "${MOUNT_PATH}/data"
127
128was_active=0
129if systemctl is-active --quiet "${MAIL_SERVICE}.service"; then
130 was_active=1
131 systemctl stop "${MAIL_SERVICE}.service"
132fi
133
134timestamp="$(date -u +%Y%m%dT%H%M%SZ)"
135plaintext_backup="${MAIL_ROOT}/.plaintext-mail-migration-${timestamp}"
136install -d -m 0700 "${plaintext_backup}"
137declare -a migrated=()
138
139rollback() {
140 local rel source backup
141 echo "encrypted-storage: migration failed; restoring plaintext paths" >&2
142 systemctl stop "${MAIL_SERVICE}.service" 2>/dev/null || true
143 for rel in "${migrated[@]}"; do
144 source="${MAIL_ROOT}/${rel}"
145 backup="${plaintext_backup}/${rel}"
146 if [[ -L "${source}" && -e "${backup}" ]]; then
147 unlink "${source}"
148 install -d -m 0700 "$(dirname "${source}")"
149 mv "${backup}" "${source}"
150 fi
151 done
152 if [[ "${was_active}" -eq 1 ]]; then
153 systemctl start "${MAIL_SERVICE}.service" 2>/dev/null || true
154 fi
155}
156trap rollback ERR
157
158declare -a paths=(mail dkim backups cert-backups mail.log)
159while IFS= read -r candidate; do
160 paths+=("$(basename "${candidate}")")
161done < <(
162 find "${MAIL_ROOT}" -maxdepth 1 -type f \
163 \( -name 'smtp.db*' -o -name 'caldav.db*' -o -name 'forwards.json*' -o -name 'forwards.sieve*' \) \
164 -print | sort
165)
166
167for rel in "${paths[@]}"; do
168 source="${MAIL_ROOT}/${rel}"
169 destination="${MOUNT_PATH}/data/${rel}"
170 backup="${plaintext_backup}/${rel}"
171
172 if [[ -L "${source}" ]]; then
173 [[ "$(readlink "${source}")" == "${destination}" ]] \
174 || die "${source} points somewhere other than encrypted storage"
175 continue
176 fi
177 [[ -e "${source}" ]] || continue
178 [[ ! -e "${destination}" ]] || die "${destination} already exists while ${source} is plaintext"
179
180 install -d -m 0750 "$(dirname "${destination}")"
181 cp -a "${source}" "${destination}.new"
182
183 if [[ -d "${source}" ]]; then
184 diff -qr "${source}" "${destination}.new" >/dev/null
185 else
186 cmp -s "${source}" "${destination}.new"
187 fi
188
189 mv "${source}" "${backup}"
190 mv "${destination}.new" "${destination}"
191 ln -s "${destination}" "${source}"
192 migrated+=("${rel}")
193done
194
195if [[ -d "${MOUNT_PATH}/data/mail" ]]; then
196 chown -R mail-server:mail-server "${MOUNT_PATH}/data/mail"
197 find "${MOUNT_PATH}/data/mail" -type d -exec chmod 0700 {} +
198 find "${MOUNT_PATH}/data/mail" -type f -exec chmod 0600 {} +
199fi
200
201for db in "${MOUNT_PATH}"/data/smtp.db* "${MOUNT_PATH}"/data/caldav.db*; do
202 [[ -e "${db}" ]] || continue
203 chown mail-server:mail-server "${db}"
204 chmod 0600 "${db}"
205done
206
207if [[ -d "${MOUNT_PATH}/data/dkim" ]]; then
208 chown -R mail-server:mail-server "${MOUNT_PATH}/data/dkim"
209 find "${MOUNT_PATH}/data/dkim" -type d -exec chmod 0700 {} +
210 find "${MOUNT_PATH}/data/dkim" -type f -exec chmod 0600 {} +
211fi
212
213for forwards in "${MOUNT_PATH}"/data/forwards.json* "${MOUNT_PATH}"/data/forwards.sieve*; do
214 [[ -e "${forwards}" ]] || continue
215 chown mail-server:mail-server "${forwards}"
216 chmod 0600 "${forwards}"
217done
218
219if [[ -d "${MOUNT_PATH}/data/backups" ]]; then
220 chown -R root:root "${MOUNT_PATH}/data/backups"
221 find "${MOUNT_PATH}/data/backups" -type d -exec chmod 0700 {} +
222 find "${MOUNT_PATH}/data/backups" -type f -exec chmod 0600 {} +
223fi
224
225if [[ -f /etc/mail/mail.env ]]; then
226 chown root:root /etc/mail/mail.env
227 chmod 0600 /etc/mail/mail.env
228fi
229
230sync
231
232if systemctl list-unit-files "${MAIL_SERVICE}.service" --no-legend 2>/dev/null | grep -q "${MAIL_SERVICE}.service"; then
233 systemctl start "${MAIL_SERVICE}.service"
234 sleep 2
235 systemctl is-active --quiet "${MAIL_SERVICE}.service" \
236 || die "${MAIL_SERVICE}.service did not restart after migration"
237fi
238
239# The encrypted copy has been byte-checked and the service restarted from it.
240# Remove the temporary plaintext rollback copy so message data is not left
241# exposed beside the LUKS image.
242find "${plaintext_backup}" -depth -delete
243trap - ERR
244
245echo "encrypted-storage: ready"
246echo "encrypted-storage: image=${IMAGE_PATH}"
247echo "encrypted-storage: mount=${MOUNT_PATH}"
248echo "encrypted-storage: credential=${CREDENTIAL_PATH}"
249if [[ "${created_image}" -eq 1 ]]; then
250 echo "encrypted-storage: generated a new machine-bound volume key"
251fi
252
253script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
254if [[ -f "${script_dir}/mail-storage-external.sh" ]]; then
255 install -m 0700 "${script_dir}/mail-storage-external.sh" /usr/local/sbin/mail-storage-external
256fi
scripts/setup-restic-backup.shadded+93-0
Changes to scripts/setup-restic-backup.sh
@@ -0,0 +1,93 @@
1#!/usr/bin/env bash
2set -euo pipefail
3
4SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
5RESTIC_CONFIG_DIR="${MAIL_RESTIC_CONFIG_DIR:-/var/lib/mail-storage/data/restic}"
6
7[[ "${EUID}" -eq 0 ]] || {
8 echo "setup-restic-backup: must run as root" >&2
9 exit 1
10}
11mountpoint -q /var/lib/mail-storage || {
12 echo "setup-restic-backup: encrypted mail storage is not mounted" >&2
13 exit 1
14}
15
16if ! command -v restic >/dev/null 2>&1; then
17 if command -v apt-get >/dev/null 2>&1; then
18 export DEBIAN_FRONTEND=noninteractive
19 apt-get update -qq
20 apt-get install -y -qq restic
21 elif command -v dnf >/dev/null 2>&1; then
22 dnf install -y restic
23 else
24 echo "setup-restic-backup: unsupported package manager" >&2
25 exit 1
26 fi
27fi
28
29install -d -m 0700 -o root -g root "${RESTIC_CONFIG_DIR}"
30install -m 0755 "${SCRIPT_DIR}/mail-restic-backup.sh" /usr/local/sbin/mail-restic-backup
31install -m 0755 "${SCRIPT_DIR}/mail-restic-restore-check.sh" /usr/local/sbin/mail-restic-restore-check
32install -m 0755 "${SCRIPT_DIR}/mail-storage-health.sh" /usr/local/sbin/mail-storage-health
33install -m 0755 "${SCRIPT_DIR}/maildir-migrate.sh" /usr/local/sbin/maildir-migrate
34install -m 0755 "${SCRIPT_DIR}/mail-forward-compile" /usr/local/sbin/mail-forward-compile
35
36cat > /etc/systemd/system/mail-restic-backup.service <<'UNIT'
37[Unit]
38Description=Authenticated off-host mail backup
39After=network-online.target mail-storage.service
40Wants=network-online.target
41ConditionPathIsMountPoint=/var/lib/mail-storage
42
43[Service]
44Type=oneshot
45ExecStart=/usr/local/sbin/mail-restic-backup
46Nice=10
47IOSchedulingClass=best-effort
48IOSchedulingPriority=7
49PrivateTmp=true
50ProtectSystem=strict
51ReadWritePaths=/var/lib/mail-storage /opt/mail
52UNIT
53
54cat > /etc/systemd/system/mail-restic-backup.timer <<'UNIT'
55[Unit]
56Description=Daily authenticated off-host mail backup
57
58[Timer]
59OnCalendar=*-*-* 03:20:00 UTC
60Persistent=true
61RandomizedDelaySec=15m
62
63[Install]
64WantedBy=timers.target
65UNIT
66
67cat > /etc/systemd/system/mail-storage-health.service <<'UNIT'
68[Unit]
69Description=Mail storage capacity and backup freshness check
70After=mail-storage.service
71ConditionPathIsMountPoint=/var/lib/mail-storage
72
73[Service]
74Type=oneshot
75ExecStart=/usr/local/sbin/mail-storage-health
76UNIT
77
78cat > /etc/systemd/system/mail-storage-health.timer <<'UNIT'
79[Unit]
80Description=Hourly mail storage health check
81
82[Timer]
83OnBootSec=10m
84OnUnitActiveSec=1h
85Persistent=true
86
87[Install]
88WantedBy=timers.target
89UNIT
90
91systemctl daemon-reload
92systemctl enable mail-restic-backup.timer mail-storage-health.timer >/dev/null
93echo "setup-restic-backup: installed"