From b5860daf11ac353049cb1654b9414a129e5cfb96 Mon Sep 17 00:00:00 2001 From: Jan Tuomi Date: Wed, 13 May 2026 00:13:57 +0300 Subject: Rework --- roles/host_prod/templates/etc_aliases.j2 | 1 + roles/host_prod/templates/etc_dma_auth.conf.j2 | 1 + roles/host_prod/templates/etc_dma_dma.conf.j2 | 8 + roles/host_prod/templates/etc_fstab.j2 | 7 + roles/host_prod/templates/root_ssh_config.j2 | 4 + .../host_prod/templates/usr_local_bin_backup.sh.j2 | 365 +++++++++++++++++++++ roles/host_prod/templates/usr_local_bin_safepf.sh | 36 ++ 7 files changed, 422 insertions(+) create mode 100644 roles/host_prod/templates/etc_aliases.j2 create mode 100644 roles/host_prod/templates/etc_dma_auth.conf.j2 create mode 100644 roles/host_prod/templates/etc_dma_dma.conf.j2 create mode 100644 roles/host_prod/templates/etc_fstab.j2 create mode 100644 roles/host_prod/templates/root_ssh_config.j2 create mode 100644 roles/host_prod/templates/usr_local_bin_backup.sh.j2 create mode 100644 roles/host_prod/templates/usr_local_bin_safepf.sh (limited to 'roles/host_prod/templates') diff --git a/roles/host_prod/templates/etc_aliases.j2 b/roles/host_prod/templates/etc_aliases.j2 new file mode 100644 index 0000000..4fe3e7f --- /dev/null +++ b/roles/host_prod/templates/etc_aliases.j2 @@ -0,0 +1 @@ +*: {{ dma_to_address }} diff --git a/roles/host_prod/templates/etc_dma_auth.conf.j2 b/roles/host_prod/templates/etc_dma_auth.conf.j2 new file mode 100644 index 0000000..a1000ea --- /dev/null +++ b/roles/host_prod/templates/etc_dma_auth.conf.j2 @@ -0,0 +1 @@ +{{ smtp_user }}|{{ smtp_host }}:{{ smtp_password }} diff --git a/roles/host_prod/templates/etc_dma_dma.conf.j2 b/roles/host_prod/templates/etc_dma_dma.conf.j2 new file mode 100644 index 0000000..dfaa04f --- /dev/null +++ b/roles/host_prod/templates/etc_dma_dma.conf.j2 @@ -0,0 +1,8 @@ +SMARTHOST {{ smtp_host }} +PORT {{ smtp_port }} +AUTHPATH /etc/dma/auth.conf +SECURETRANSFER +STARTTLS +MAILNAME {{ dma_mail_hostname }} +MASQUERADE {{ ansible_hostname }}@{{ dma_mail_hostname }} +#NULLCLIENT diff --git a/roles/host_prod/templates/etc_fstab.j2 b/roles/host_prod/templates/etc_fstab.j2 new file mode 100644 index 0000000..95d3fa0 --- /dev/null +++ b/roles/host_prod/templates/etc_fstab.j2 @@ -0,0 +1,7 @@ +# Device Mountpoint FStype Options Dump Pass# +/dev/gpt/efiboot0 /boot/efi msdosfs rw 2 2 +/dev/nda0p3.eli none swap sw 0 0 +/dev/nda1p3.eli none swap sw 0 0 +/dev/nda2p3.eli none swap sw 0 0 +/dev/nda3p3.eli none swap sw 0 0 +tmpfs /tmp tmpfs rw,mode=777,size=2g 0 0 diff --git a/roles/host_prod/templates/root_ssh_config.j2 b/roles/host_prod/templates/root_ssh_config.j2 new file mode 100644 index 0000000..96f78f2 --- /dev/null +++ b/roles/host_prod/templates/root_ssh_config.j2 @@ -0,0 +1,4 @@ +Host backup + HostName {{ backup_ssh_host }} + User {{ backup_ssh_user }} + IdentityFile /root/.ssh/backup diff --git a/roles/host_prod/templates/usr_local_bin_backup.sh.j2 b/roles/host_prod/templates/usr_local_bin_backup.sh.j2 new file mode 100644 index 0000000..f6a0e2c --- /dev/null +++ b/roles/host_prod/templates/usr_local_bin_backup.sh.j2 @@ -0,0 +1,365 @@ +{% raw %}#!/usr/bin/env bash +set -euo pipefail + +# ===== CONFIG (filled by Ansible) ===== +KEEP_LOCAL=30 +KEEP_REMOTE=10 +KEYFILE="/root/.ssh/backup" +HOST_DIR="backup" +DATASET="{% endraw %}{{ backup_zfs_dataset }}{% raw %}" +BACKUP_EXCLUDE_PROP="{% endraw %}{{ backup_zfs_exclude_property | default('com.pursotin:backup') }}{% raw %}" +USER="{% endraw %}{{ backup_ssh_user }}{% raw %}" +HOST="{% endraw %}{{ backup_ssh_host }}{% raw %}" +EMAIL_TO=root +# ===================================== + +# ----- Globals for notification ----- +STARTED_AT="$(date '+%Y-%m-%dT%H:%M:%S%z')" +FINISHED_AT="" +MESSAGE_LOG="" +BACKUP_NAME="" # e.g. zroot@2025-09-30-12-00-00-0300.enc +BACKUP_SIZE_BYTES="" # numeric bytes + +# ----- Helpers ----- +die() { echo "Error: $*" >&2; exit 1; } + +require_cmds() { + local cmds=("$@") + for c in "${cmds[@]}"; do command -v "$c" >/dev/null 2>&1 || die "Missing command: $c"; done +} + +log_note() { + # Echo to console and append to message buffer + local msg="$1" + echo "$msg" + MESSAGE_LOG+="$msg"$'\n' +} + +timestamp() { + # Replace '+' with '-' so timezone is filename-safe and lexicographically sortable within TZ. + date +%Y-%m-%d-%H-%M-%S%z | tr '+' '-' +} + +humanize_bytes() { + local bytes="$1" + if [[ "${bytes}" =~ ^[0-9]+$ ]]; then + awk -v b="${bytes}" ' + BEGIN { + unit_count = split("B KiB MiB GiB TiB PiB EiB", units, " ") + i = 1 + while (b >= 1024 && i < unit_count) { + b = b / 1024 + i++ + } + if (i == 1) { + printf "%.0f %s\n", b, units[i] + } else { + printf "%.2f %s\n", b, units[i] + } + } + ' + else + echo "unknown" + fi +} + +latest_snapshot_for_dataset() { + # Latest snapshot on the TOP dataset only (newest first). Returns e.g. zroot@2025-09-30-... + zfs list -t snapshot -o name -S creation "$DATASET" 2>/dev/null \ + | awk 'NR==2{print; exit}' +} + +list_top_snapshots_newest_first() { + # Only list snapshots on the top dataset (not children), newest first. + zfs list -t snapshot -o name -S creation "$DATASET" 2>/dev/null \ + | awk 'NR>1{print $1}' +} + +list_excluded_datasets() { + # List datasets (including inherited values) where backup property is explicitly false. + zfs get -r -H -o name,value "${BACKUP_EXCLUDE_PROP}" "${DATASET}" 2>/dev/null \ + | awk 'tolower($2)=="false"{print $1}' +} + +list_remote_backups_sorted() { + # We store files under ${HOST_DIR}/@.enc + echo "ls ${HOST_DIR}" \ + | sftp -q -i "${KEYFILE}" "${USER}@${HOST}" 2>/dev/null \ + | tail -n +2 | sort +} + +# Upload a local file to the storage box path "${HOST_DIR}/" +sftp_put() { + local local_file="$1" + local remote_name="$2" # just the filename, no directory + sftp -i "${KEYFILE}" "${USER}@${HOST}" </dev/null 2>&1; then + echo "Remote directory already exists: ${HOST_DIR}" + log_note "init-remote: verified remote directory '${HOST_DIR}'" + return 0 + fi + + echo "Creating remote directory: ${HOST_DIR}" + set +e + sftp -i "${KEYFILE}" "${USER}@${HOST}" </dev/null 2>&1; then + echo "Remote directory created: ${HOST_DIR}" + log_note "init-remote: created remote directory '${HOST_DIR}'" + else + die "Remote directory ${HOST_DIR} not found after creation" + fi +} + +cmd_snapshot() { + echo "" + echo "[snapshot]" + require_cmds zfs date tr + local ts snap + ts="$(timestamp)" + snap="${DATASET}@${ts}" + echo "Taking recursive snapshot \"${snap}\"" + (set -x; zfs snapshot -r "${snap}") + echo "Recursive snapshot \"${snap}\" created" + log_note "snapshot: created recursive snapshot '${snap}'" +} + +cmd_send_to_remote() { + echo "" + echo "[send-to-remote]" + require_cmds zfs age sftp mktemp stat awk sort + + local snap base target tmp size_bytes ds parent skip + local -a send_args excluded_raw excluded + snap="$(latest_snapshot_for_dataset)" + [[ -n "${snap}" ]] || die "No snapshot found to send. Run 'snapshot' first or ensure the dataset has snapshots." + + base="$(basename "${snap}")" # e.g., zroot@2025-09-30-12-00-00-0300 + target="${base}.enc" + tmp="$(mktemp -t backup_send.XXXXXX)" + trap 'rm -f "${tmp}"' EXIT + + send_args=(-Rvc) + mapfile -t excluded_raw < <(list_excluded_datasets | sort) + excluded=() + for ds in "${excluded_raw[@]}"; do + skip=0 + [[ -n "${ds}" ]] || continue + for parent in "${excluded[@]}"; do + if [[ "${ds}" == "${parent}" || "${ds}" == "${parent}/"* ]]; then + skip=1 + break + fi + done + [[ "${skip}" -eq 1 ]] || excluded+=("${ds}") + done + + if [[ "${#excluded[@]}" -gt 0 ]]; then + echo "Excluding datasets where ${BACKUP_EXCLUDE_PROP}=false:" + for ds in "${excluded[@]}"; do + if [[ "${ds}" == "${DATASET}" ]]; then + die "Top dataset ${DATASET} is excluded via ${BACKUP_EXCLUDE_PROP}=false; refusing to create an empty backup stream." + fi + echo " - ${ds}" + send_args+=(-X "${ds}") + done + log_note "send-to-remote: excluded ${#excluded[@]} dataset(s) using ${BACKUP_EXCLUDE_PROP}=false" + fi + + echo "Creating encrypted replication stream to temp file: ${tmp}" + # -R: recursive hierarchy, -v: progress to stderr, -c: send compressed (keeps on-disk compression) + (set -x; zfs send "${send_args[@]}" "${snap}" | age -e -i "${KEYFILE}" > "${tmp}") + + size_bytes="$(stat -f %z "${tmp}" 2>/dev/null || stat -c %s "${tmp}" 2>/dev/null || echo "unknown")" + echo "Local stream size: ${size_bytes} bytes" + + echo "Uploading via SFTP to ${HOST}:${HOST_DIR}/${target}" + if sftp_put "${tmp}" "${target}"; then + echo "Upload complete" + else + die "SFTP upload failed (does the remote directory '${HOST_DIR}' exist? Run 'init-remote')" + fi + + # Set globals for notify() + BACKUP_NAME="${target}" + BACKUP_SIZE_BYTES="${size_bytes}" + + log_note "send-to-remote: uploaded '${snap}' as '${target}' (${size_bytes} bytes) to ${HOST}:${HOST_DIR}" + rm -f "${tmp}" + trap - EXIT +} + +cmd_prune_remote() { + echo "" + echo "[prune-remote]" + require_cmds sftp awk sort wc + + echo "Fetching remote backup listing from sftp://${HOST}/${HOST_DIR}" + BACKUPS="$(list_remote_backups_sorted || true)" + mapfile -t BACKUP_ARR < <(printf "%s\n" "${BACKUPS}") + local count="${#BACKUP_ARR[@]}" + + if [[ "${count}" -le "${KEEP_REMOTE}" ]]; then + echo "Remote backups (${count}) <= KEEP_REMOTE (${KEEP_REMOTE}); nothing to prune." + log_note "prune-remote: kept ${count} (<= ${KEEP_REMOTE}); no deletions" + return 0 + fi + + echo "Pruning remote backups, keeping latest ${KEEP_REMOTE} (will delete $(("${count}" - "${KEEP_REMOTE}")))" + local to_delete_count=$((count - KEEP_REMOTE)) + local deleted=0 + for ((i=0; i /etc/pf.conf +pfctl -f /etc/pf.conf +service pf restart -- cgit v1.3