From b1844d805a8f190af00faf3f6e5bed7d997ecaae Mon Sep 17 00:00:00 2001 From: Lukasz Kasprzak Date: Thu, 19 Mar 2026 15:05:32 +0100 Subject: created sorter for sorting downloads with custom rules; created straper for recreation of server --- straper/README.md | 123 ++++++++ straper/common.sh | 330 +++++++++++++++++++++ straper/doctor.sh | 222 ++++++++++++++ straper/install-base.sh | 342 ++++++++++++++++++++++ straper/rebuild.conf.example | 10 + straper/restore-configs.sh | 521 +++++++++++++++++++++++++++++++++ straper/sanctum-rebuild-toolkit.tar.gz | Bin 0 -> 14623 bytes 7 files changed, 1548 insertions(+) create mode 100644 straper/README.md create mode 100644 straper/common.sh create mode 100644 straper/doctor.sh create mode 100644 straper/install-base.sh create mode 100644 straper/rebuild.conf.example create mode 100644 straper/restore-configs.sh create mode 100644 straper/sanctum-rebuild-toolkit.tar.gz (limited to 'straper') diff --git a/straper/README.md b/straper/README.md new file mode 100644 index 0000000..a076f8f --- /dev/null +++ b/straper/README.md @@ -0,0 +1,123 @@ +# sanctum / labunix rebuild toolkit + +A modular, idempotent, Unix-style rebuild toolkit designed to reduce recovery time from weeks to hours. + +## Design goals + +- Separate **install**, **restore**, and **verify**. +- Keep base connectivity stable before touching overlay networking. +- Make secrets and identities opt-in. +- Never abort the whole run because one restore item failed. +- Back up before overwrite and write a machine-readable report. +- Work on both **VM** and **real hardware**. + +## Files + +- `lib/common.sh` — shared helpers +- `install-base.sh` — installs packages and base structure only +- `restore-configs.sh` — restores configs from `capture-full.sh` database +- `doctor.sh` — read-only health and readiness checks + +## Defaults + +- Safe by default +- No automatic identity transplant +- No blind `/etc/network` overwrite unless explicitly requested +- No immutable `/etc/resolv.conf` +- No automatic WireGuard activation unless explicitly requested + +## Roles + +- `lab` — VM / test clone, least invasive +- `hardware` — real machine, still conservative +- `replacement` — real disaster-recovery target, identities allowed when requested + +## Recommended recovery order + +### 1. Base install + +```bash +sudo ./install-base.sh --role replacement --profile core --start-safe-services +``` + +### 2. Restore essentials first + +```bash +sudo ./restore-configs.sh \ + --role replacement \ + --category system-basics \ + --category users \ + --category ssh \ + --category apt-sources \ + --start-services +``` + +### 3. Restore network/DNS/firewall carefully + +```bash +sudo ./restore-configs.sh \ + --role replacement \ + --category network-base \ + --category dns \ + --category firewall \ + --network-mode source \ + --dns-mode chain \ + --start-services +``` + +### 4. Restore services + +```bash +sudo ./restore-configs.sh \ + --role replacement \ + --category nginx \ + --category mariadb \ + --category postfix \ + --category prosody \ + --category docker \ + --restore-secrets \ + --start-services +``` + +### 5. Restore identities last + +```bash +sudo ./restore-configs.sh \ + --role replacement \ + --category privacy \ + --category tls \ + --category identities \ + --restore-secrets \ + --restore-identities \ + --start-services +``` + +### 6. Verify + +```bash +sudo ./doctor.sh --role replacement --strict +``` + +## Reports and state + +Each run writes: + +- `/var/log/labunix-rebuild/report-.tsv` +- `/var/log/labunix-rebuild/doctor-.txt` +- `/var/lib/labunix-rebuild/state-.env` +- `/var/lib/labunix-rebuild/backups//...` + +## Notes + +- `install-base.sh` does **not** restore your server identity. +- `restore-configs.sh` is intentionally interactive unless `--yes` is used. +- `doctor.sh` is read-only. +- For network restore, use local console access when possible. + + +## Latest patch notes + +- v0.1.1 fixes early exit in `load_optional_config()` under `set -e` +- `install-base.sh` now has a bootstrap DNS fallback for broken `systemd-resolved` stub setups +- initial `apt-get update` is now validated and fails loudly instead of being reported as success +- locale handling no longer requires locale tools before base packages install them diff --git a/straper/common.sh b/straper/common.sh new file mode 100644 index 0000000..32f3581 --- /dev/null +++ b/straper/common.sh @@ -0,0 +1,330 @@ +#!/usr/bin/env bash +# shellcheck shell=bash +# Shared helpers for the labunix/sanctum rebuild toolkit. + +COMMON_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +TOOLKIT_DIR="$(cd -- "${COMMON_DIR}/.." && pwd)" + +TOOLKIT_NAME="labunix-rebuild" +TOOLKIT_VERSION="0.1.1" + +: "${DB_DIR:=/srv/sanctum-rebuild}" +: "${PUBLIC_DIR:=${DB_DIR}/db/public}" +: "${SECRET_DIR:=${DB_DIR}/db/secret}" +: "${ROLE:=lab}" # lab | hardware | replacement +: "${PROFILE:=core}" # minimal | core | full +: "${RUN_ID:=$(date +%Y%m%dT%H%M%S)}" +: "${STATE_DIR:=/var/lib/${TOOLKIT_NAME}}" +: "${LOG_DIR:=/var/log/${TOOLKIT_NAME}}" +: "${BACKUP_DIR:=${STATE_DIR}/backups/${RUN_ID}}" +: "${REPORT_FILE:=${LOG_DIR}/report-${RUN_ID}.tsv}" +: "${STATE_FILE:=${STATE_DIR}/state-${RUN_ID}.env}" +: "${DRY_RUN:=false}" +: "${ASSUME_YES:=false}" +: "${VERBOSE:=false}" +: "${START_SERVICES:=false}" +: "${RESTORE_IDENTITIES:=false}" +: "${RESTORE_SECRETS:=false}" +: "${NETWORK_MODE:=safe}" # safe | source +: "${DNS_MODE:=auto}" # auto | resolved | dnsmasq | unbound | chain +: "${RESTORE_PURGE:=false}" + +umask 022 + +now_iso() { date +"%Y-%m-%dT%H:%M:%S%z"; } +log() { printf '[%s] %s\n' "$(now_iso)" "$*"; } +warn() { printf '[%s] WARN: %s\n' "$(now_iso)" "$*" >&2; } +die() { printf '[%s] ERROR: %s\n' "$(now_iso)" "$*" >&2; exit 1; } + +ensure_root() { + [[ ${EUID} -eq 0 ]] || die "must be run as root" +} + +ensure_runtime_dirs() { + mkdir -p -- "${STATE_DIR}" "${LOG_DIR}" "${BACKUP_DIR}" + touch -- "${REPORT_FILE}" "${STATE_FILE}" + chmod 700 -- "${STATE_DIR}" "${BACKUP_DIR}" + chmod 755 -- "${LOG_DIR}" + if [[ ! -s ${REPORT_FILE} ]]; then + printf 'timestamp\tscript\tcategory\titem\taction\tstatus\tnote\tbackup\n' > "${REPORT_FILE}" + fi +} + +load_optional_config() { + local conf + for conf in /etc/labunix/rebuild.conf /root/.config/labunix/rebuild.conf; do + if [[ -f ${conf} ]]; then + # shellcheck disable=SC1090 + source "${conf}" + fi + done + return 0 +} + +require_cmd() { + local c + for c in "$@"; do + command -v -- "${c}" >/dev/null 2>&1 || die "required command not found: ${c}" + done +} + +have_cmd() { + command -v -- "$1" >/dev/null 2>&1 +} + +set_state() { + local key="$1" value="$2" tmp + tmp="$(mktemp)" + if [[ -f ${STATE_FILE} ]]; then + grep -v -E "^${key}=" "${STATE_FILE}" > "${tmp}" || true + fi + printf '%s=%q\n' "${key}" "${value}" >> "${tmp}" + mv -- "${tmp}" "${STATE_FILE}" + chmod 600 -- "${STATE_FILE}" +} + +get_state() { + local key="$1" + [[ -f ${STATE_FILE} ]] || return 1 + awk -F= -v k="${key}" '$1==k {sub(/^[^=]*=/,""); print; found=1} END {exit(found?0:1)}' "${STATE_FILE}" +} + +report() { + local script="$1" category="$2" item="$3" action="$4" status="$5" note="$6" backup="${7:-}" + printf '%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n' \ + "$(now_iso)" "${script}" "${category}" "${item}" "${action}" "${status}" "${note}" "${backup}" \ + >> "${REPORT_FILE}" +} + +run() { + if [[ ${DRY_RUN} == true ]]; then + printf '[dry] %s\n' "$*" + return 0 + fi + "$@" +} + +run_capture() { + local outfile="$1"; shift + if [[ ${DRY_RUN} == true ]]; then + printf '[dry] capture %s <= %s\n' "${outfile}" "$*" + return 0 + fi + "$@" > "${outfile}" 2>&1 +} + +backup_target() { + local target="$1" rel backup + [[ -e ${target} || -L ${target} ]] || return 1 + rel="${target#/}" + backup="${BACKUP_DIR}/${rel}" + mkdir -p -- "$(dirname -- "${backup}")" + cp -a -- "${target}" "${backup}" + printf '%s\n' "${backup}" +} + +files_equal() { + local src="$1" dst="$2" + [[ -f ${src} && -f ${dst} ]] || return 1 + cmp -s -- "${src}" "${dst}" +} + +dirs_equal() { + local src="$1" dst="$2" + [[ -d ${src} && -d ${dst} ]] || return 1 + diff -qr -- "${src}" "${dst}" >/dev/null 2>&1 +} + +copy_path() { + local src="$1" dst="$2" + if [[ -d ${src} ]]; then + mkdir -p -- "${dst}" + if [[ ${RESTORE_PURGE} == true ]]; then + rsync -a --delete -- "${src}/" "${dst}/" + else + rsync -a -- "${src}/" "${dst}/" + fi + else + mkdir -p -- "$(dirname -- "${dst}")" + cp -a -- "${src}" "${dst}" + fi +} + +restore_path() { + local script="$1" category="$2" item="$3" src="$4" dst="$5" mode="${6:-}" owner="${7:-}" group="${8:-}" + local backup="" changed_note="restored" + + if [[ ! -e ${src} && ! -L ${src} ]]; then + report "${script}" "${category}" "${item}" "restore" "skipped" "source missing: ${src}" "" + return 0 + fi + + if [[ -f ${src} && -f ${dst} ]] && files_equal "${src}" "${dst}"; then + if [[ -z ${mode} && -z ${owner} && -z ${group} ]]; then + report "${script}" "${category}" "${item}" "restore" "ok" "already up to date" "" + return 0 + fi + changed_note="metadata normalized" + fi + + if [[ -d ${src} && -d ${dst} ]] && dirs_equal "${src}" "${dst}"; then + if [[ -z ${mode} && -z ${owner} && -z ${group} ]]; then + report "${script}" "${category}" "${item}" "restore" "ok" "already up to date" "" + return 0 + fi + changed_note="metadata normalized" + fi + + if [[ -e ${dst} || -L ${dst} ]]; then + if [[ ${DRY_RUN} == true ]]; then + backup="${BACKUP_DIR}/${dst#/}" + printf '[dry] backup %s -> %s\n' "${dst}" "${backup}" + else + backup="$(backup_target "${dst}")" + fi + fi + + if [[ ${DRY_RUN} == true ]]; then + printf '[dry] restore %s -> %s\n' "${src}" "${dst}" + [[ -n ${mode} ]] && printf '[dry] chmod %s %s\n' "${mode}" "${dst}" + [[ -n ${owner} ]] && printf '[dry] chown -R %s%s %s\n' "${owner}" "${group:+:${group}}" "${dst}" + else + if [[ "${changed_note}" == "metadata normalized" ]]; then + : + else + copy_path "${src}" "${dst}" + fi + [[ -n ${mode} ]] && chmod "${mode}" -- "${dst}" 2>/dev/null || true + [[ -n ${owner} ]] && chown -R "${owner}${group:+:${group}}" -- "${dst}" 2>/dev/null || true + fi + + report "${script}" "${category}" "${item}" "restore" "changed" "${changed_note}" "${backup}" + return 0 +} + +prompt_yes_no() { + local prompt="$1" default="${2:-no}" answer="" + if [[ ${ASSUME_YES} == true ]]; then + return 0 + fi + case "${default}" in + yes) read -r -p "${prompt} [Y/n]: " answer ;; + no) read -r -p "${prompt} [y/N]: " answer ;; + *) read -r -p "${prompt} [y/n]: " answer ;; + esac + answer="${answer:-${default}}" + [[ ${answer} =~ ^([Yy]|[Yy][Ee][Ss])$ ]] +} + +list_from_file() { + local f="$1" + [[ -f ${f} ]] || return 0 + grep -Ev '^(#|$)' "${f}" +} + +apt_install_if_missing() { + local pkgs=() pkg + for pkg in "$@"; do + dpkg -s -- "${pkg}" >/dev/null 2>&1 || pkgs+=("${pkg}") + done + [[ ${#pkgs[@]} -eq 0 ]] && return 0 + if [[ ${DRY_RUN} == true ]]; then + printf '[dry] apt-get install -y --no-install-recommends %s\n' "${pkgs[*]}" + else + DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends "${pkgs[@]}" + fi +} + +systemctl_safe_enable() { + local unit="$1" + if ! systemctl list-unit-files --type=service --type=target --type=timer --type=socket | awk '{print $1}' | grep -qx -- "${unit}"; then + return 1 + fi + run systemctl enable "${unit}" +} + +systemctl_safe_start() { + local unit="$1" + if ! systemctl list-unit-files --type=service --type=target --type=timer --type=socket | awk '{print $1}' | grep -qx -- "${unit}"; then + return 1 + fi + run systemctl restart "${unit}" +} + +validate_sshd() { have_cmd sshd && sshd -t; } +validate_nginx() { have_cmd nginx && nginx -t; } +validate_nft() { [[ -f /etc/nftables.conf ]] && have_cmd nft && nft -c -f /etc/nftables.conf; } +validate_unbound() { have_cmd unbound-checkconf && unbound-checkconf; } +validate_dnsmasq() { have_cmd dnsmasq && dnsmasq --test; } +validate_postfix() { have_cmd postfix && postfix check; } +validate_mariadb() { have_cmd mariadbd && mariadbd --verbose --help >/dev/null 2>&1; } + +service_is_active() { + systemctl is-active --quiet "$1" +} + +service_is_enabled() { + systemctl is-enabled --quiet "$1" +} + +detect_virtualization() { + if have_cmd systemd-detect-virt; then + systemd-detect-virt || true + else + true + fi +} + +primary_iface() { + ip route get 1.1.1.1 2>/dev/null | awk '/dev/ {for(i=1;i<=NF;i++) if ($i=="dev") {print $(i+1); exit}}' +} + +primary_src_ip() { + ip route get 1.1.1.1 2>/dev/null | awk '/src/ {for(i=1;i<=NF;i++) if ($i=="src") {print $(i+1); exit}}' +} + +has_default_route() { + ip route show default | grep -q . +} + +can_reach_ip() { + local ip="$1" + ping -c1 -W3 "${ip}" >/dev/null 2>&1 +} + +can_resolve_name() { + local name="$1" + getent ahostsv4 "${name}" >/dev/null 2>&1 +} + +guess_dns_owner() { + if service_is_active unbound 2>/dev/null && service_is_active dnsmasq 2>/dev/null; then + printf 'chain\n' + elif service_is_active unbound 2>/dev/null; then + printf 'unbound\n' + elif service_is_active dnsmasq 2>/dev/null; then + printf 'dnsmasq\n' + elif service_is_active systemd-resolved 2>/dev/null; then + printf 'resolved\n' + else + printf 'unknown\n' + fi +} + +role_allows_identities() { + [[ ${ROLE} == replacement ]] +} + +role_allows_overlay() { + [[ ${ROLE} == replacement || ${ROLE} == hardware ]] +} + +mark_manual() { + local script="$1" category="$2" item="$3" note="$4" + report "${script}" "${category}" "${item}" "manual" "manual" "${note}" "" +} + +note_failure() { + local script="$1" category="$2" item="$3" action="$4" note="$5" backup="${6:-}" + report "${script}" "${category}" "${item}" "${action}" "failed" "${note}" "${backup}" +} diff --git a/straper/doctor.sh b/straper/doctor.sh new file mode 100644 index 0000000..6aed53e --- /dev/null +++ b/straper/doctor.sh @@ -0,0 +1,222 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +SCRIPT_DIR="$(cd -- "$(dirname -- "$0")" && pwd)" +# shellcheck disable=SC1091 +source "${SCRIPT_DIR}/lib/common.sh" + +SCRIPT_NAME="doctor.sh" +STRICT=false +OUTFILE="" + +usage() { + cat < "${OUTFILE}" + +FAILS=0 +WARNS=0 +OKS=0 +MANUALS=0 + +emit() { + printf '%s\n' "$*" | tee -a "${OUTFILE}" +} + +ok_line() { + ((OKS+=1)) + emit "OK | $1" + report "${SCRIPT_NAME}" doctor "$1" check ok "$1" "" +} +warn_line() { + ((WARNS+=1)) + emit "WARN | $1" + report "${SCRIPT_NAME}" doctor "$1" check warn "$1" "" +} +fail_line() { + ((FAILS+=1)) + emit "FAIL | $1" + report "${SCRIPT_NAME}" doctor "$1" check failed "$1" "" +} +manual_line() { + ((MANUALS+=1)) + emit "MANUAL| $1" + report "${SCRIPT_NAME}" doctor "$1" manual manual "$1" "" +} + +service_report() { + local svc="$1" + local unit="$svc" + local load active enabled + + [[ "${unit}" == *.* ]] || unit="${unit}.service" + + load="$(systemctl show -P LoadState "${unit}" 2>/dev/null || true)" + if [[ -z "${load}" || "${load}" == "not-found" ]]; then + warn_line "service ${unit} not installed" + return 0 + fi + + active="$(systemctl show -P ActiveState "${unit}" 2>/dev/null || true)" + enabled="$(systemctl show -P UnitFileState "${unit}" 2>/dev/null || true)" + + if [[ "${active}" == "active" ]]; then + ok_line "service ${unit} active (${enabled})" + else + warn_line "service ${unit} ${active:-unknown} (${enabled:-unknown})" + fi +} + +emit "labunix/sanctum rebuild doctor" +emit "timestamp: $(now_iso)" +emit "role: ${ROLE}" +emit "db: ${DB_DIR}" +emit "" + +virt="$(detect_virtualization)" +if [[ -n ${virt} ]]; then + warn_line "virtualization detected: ${virt}" +else + ok_line "bare metal or virtualization not detected" +fi + +current_host="$(hostname -s 2>/dev/null || true)" +captured_host="$(head -n1 "${PUBLIC_DIR}/system/etc-hostname" 2>/dev/null || true)" +if [[ -n ${captured_host} && ${current_host} == ${captured_host} ]]; then + ok_line "hostname matches DB (${current_host})" +elif [[ -n ${captured_host} ]]; then + warn_line "hostname differs: current=${current_host} captured=${captured_host}" +else + warn_line "captured hostname not found in DB" +fi + +if has_default_route; then + ok_line "default route present" +else + fail_line "no default route" +fi + +iface="$(primary_iface)" +src_ip="$(primary_src_ip)" +[[ -n ${iface} ]] && ok_line "primary route uses iface=${iface} src=${src_ip}" || fail_line "could not determine primary interface via ip route get 1.1.1.1" + +if can_reach_ip 1.1.1.1; then + ok_line "outbound IP reachability to 1.1.1.1" +else + fail_line "cannot reach 1.1.1.1" +fi + +if can_resolve_name deb.debian.org; then + ok_line "DNS resolves deb.debian.org" +else + fail_line "DNS resolution failed for deb.debian.org" +fi + +owner="$(guess_dns_owner)" +case "${owner}" in + resolved) ok_line "DNS owner appears to be systemd-resolved" ;; + dnsmasq) ok_line "DNS owner appears to be dnsmasq" ;; + unbound) ok_line "DNS owner appears to be unbound" ;; + chain) warn_line "DNS owner appears chained (dnsmasq + unbound); verify port ownership intentionally" ;; + *) warn_line "DNS owner unclear" ;; +esac + +failed_units="$(systemctl list-units --state=failed --no-legend --plain 2>/dev/null | awk '{print $1}')" +if [[ -z ${failed_units} ]]; then + ok_line "no failed systemd units" +else + fail_line "failed units present: $(echo "${failed_units}" | paste -sd, -)" +fi + +# Config validators +have_cmd sshd && validate_sshd && ok_line "sshd config validates" || warn_line "sshd config validation failed or sshd missing" +have_cmd nginx && validate_nginx && ok_line "nginx config validates" || warn_line "nginx config validation failed or nginx missing" +have_cmd nft && [[ -f /etc/nftables.conf ]] && validate_nft && ok_line "nftables config validates" || warn_line "nftables config validation failed or /etc/nftables.conf missing" +have_cmd unbound-checkconf && validate_unbound && ok_line "unbound config validates" || warn_line "unbound config validation failed or unbound missing" +have_cmd dnsmasq && validate_dnsmasq && ok_line "dnsmasq config validates" || warn_line "dnsmasq config validation failed or dnsmasq missing" +have_cmd postfix && validate_postfix && ok_line "postfix config validates" || warn_line "postfix config validation failed or postfix missing" + +emit "" +emit "Service snapshot" +service_report ssh +service_report systemd-resolved +service_report dnsmasq +service_report unbound +service_report nftables +service_report nginx +service_report mariadb +service_report postfix +service_report prosody +service_report docker +service_report prometheus +service_report grafana-server +service_report tor +service_report i2pd + +emit "" +emit "Listening ports snapshot" +ss -tlnp 2>/dev/null | tee -a "${OUTFILE}" >/dev/null || true + +if have_cmd docker; then + emit "" + emit "Docker snapshot" + docker ps --format 'table {{.Names}}\t{{.Status}}\t{{.Ports}}' 2>/dev/null | tee -a "${OUTFILE}" >/dev/null || warn_line "docker present but docker ps failed" +fi + +if [[ -d ${PUBLIC_DIR}/docker/compose-redacted || -d ${SECRET_DIR}/docker/compose-full ]]; then + local_count="$(find /srv /opt /home /root -maxdepth 4 \( -name 'docker-compose.yml' -o -name 'docker-compose.yaml' -o -name 'compose.yml' -o -name 'compose.yaml' \) 2>/dev/null | wc -l | tr -d ' ')" + ok_line "compose files found locally: ${local_count}" +fi + +if [[ -f /etc/resolv.conf ]]; then + emit "" + emit "/etc/resolv.conf" + sed -n '1,20p' /etc/resolv.conf | tee -a "${OUTFILE}" >/dev/null || true +fi + +if [[ -f ${STATE_DIR}/state-${RUN_ID}.env ]]; then + manual_line "current run state file: ${STATE_DIR}/state-${RUN_ID}.env" +fi + +emit "" +emit "Summary: ok=${OKS} warn=${WARNS} fail=${FAILS} manual=${MANUALS}" + +set_state doctor_done true +report "${SCRIPT_NAME}" run finish exit ok "ok=${OKS}; warn=${WARNS}; fail=${FAILS}; manual=${MANUALS}" "" + +if [[ ${STRICT} == true && ${FAILS} -gt 0 ]]; then + exit 1 +fi diff --git a/straper/install-base.sh b/straper/install-base.sh new file mode 100644 index 0000000..e2a2d42 --- /dev/null +++ b/straper/install-base.sh @@ -0,0 +1,342 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +SCRIPT_DIR="$(cd -- "$(dirname -- "$0")" && pwd)" +# shellcheck disable=SC1091 +source "${SCRIPT_DIR}/lib/common.sh" + +SCRIPT_NAME="install-base.sh" +USE_DB_APT_SOURCES=false +FROM_DB_MANUAL=false +HOSTNAME_WANTED="" +TIMEZONE_WANTED="Europe/Warsaw" +LOCALE_WANTED="en_US.UTF-8" + +usage() { + cat </dev/null 2>&1; then + report "${SCRIPT_NAME}" "dns" "bootstrap" "check" "ok" "resolver already working" "" + return 0 + fi + + if ! ping -c 1 -W 3 1.1.1.1 >/dev/null 2>&1; then + note_failure "${SCRIPT_NAME}" "dns" "bootstrap" "check" "no outbound IP connectivity" + die "bootstrap DNS check failed: no outbound IP connectivity" + fi + + if command -v dig >/dev/null 2>&1 && dig +time=2 +tries=1 +short @1.1.1.1 "${test_host}" >/dev/null 2>&1; then + [[ -e /etc/resolv.conf && ! -e "${backup}" ]] && cp -a /etc/resolv.conf "${backup}" || true + printf 'nameserver 1.1.1.1 +nameserver 9.9.9.9 +' > /etc/resolv.conf + chmod 644 /etc/resolv.conf || true + + if getent ahostsv4 "${test_host}" >/dev/null 2>&1; then + report "${SCRIPT_NAME}" "dns" "bootstrap" "fallback" "changed" "static resolv.conf applied" "${backup}" + return 0 + fi + fi + + note_failure "${SCRIPT_NAME}" "dns" "bootstrap" "check" "DNS still broken after fallback attempt" + die "bootstrap DNS check failed" +} + +install_pkg_group() { + local group_name="$1"; shift + local pkgs=("$@") + log "installing package group: ${group_name}" + if apt_install_if_missing "${pkgs[@]}"; then + report "${SCRIPT_NAME}" "packages" "${group_name}" "install" "ok" "group processed" "" + else + note_failure "${SCRIPT_NAME}" "packages" "${group_name}" "install" "apt install failed" + die "package group failed: ${group_name}" + fi +} + +apply_hostname_locale_timezone() { + if [[ -n ${HOSTNAME_WANTED} ]]; then + if [[ ${DRY_RUN} == true ]]; then + printf '[dry] hostnamectl set-hostname %s\n' "${HOSTNAME_WANTED}" + else + hostnamectl set-hostname "${HOSTNAME_WANTED}" + printf '%s\n' "${HOSTNAME_WANTED}" > /etc/hostname + fi + report "${SCRIPT_NAME}" "system" "hostname" "set" "changed" "${HOSTNAME_WANTED}" "" + fi + + if [[ -f /etc/locale.gen ]] && grep -Eq "^#?\s*${LOCALE_WANTED}\b" /etc/locale.gen 2>/dev/null; then + if [[ ${DRY_RUN} == true ]]; then + printf '[dry] enable locale %s in /etc/locale.gen\n' "${LOCALE_WANTED}" + else + if have_cmd locale-gen && have_cmd update-locale; then + sed -i "s/^# *\(${LOCALE_WANTED}\b.*\)/\1/" /etc/locale.gen + locale-gen "${LOCALE_WANTED}" >/dev/null + update-locale LANG="${LOCALE_WANTED}" >/dev/null + else + report "${SCRIPT_NAME}" "system" "locale" "manual" "manual" "locale tools missing; install locales first or rerun after base packages" "" + return 0 + fi + fi + report "${SCRIPT_NAME}" "system" "locale" "set" "changed" "${LOCALE_WANTED}" "" + else + report "${SCRIPT_NAME}" "system" "locale" "manual" "manual" "locale missing in /etc/locale.gen: ${LOCALE_WANTED}" "" + fi + + if [[ ${DRY_RUN} == true ]]; then + printf '[dry] set timezone %s\n' "${TIMEZONE_WANTED}" + else + ln -sf "/usr/share/zoneinfo/${TIMEZONE_WANTED}" /etc/localtime + dpkg-reconfigure -f noninteractive tzdata >/dev/null + fi + report "${SCRIPT_NAME}" "system" "timezone" "set" "changed" "${TIMEZONE_WANTED}" "" +} + +restore_apt_sources_from_db() { + local src="${PUBLIC_DIR}/packages/apt" + [[ -d ${src} ]] || { mark_manual "${SCRIPT_NAME}" "apt" "sources" "db/public/packages/apt missing"; return 0; } + + if ! prompt_yes_no "Restore APT sources from DB now?" no; then + report "${SCRIPT_NAME}" "apt" "sources" "restore" "skipped" "operator skipped" "" + return 0 + fi + + restore_path "${SCRIPT_NAME}" "apt" "sources.list" "${src}/sources.list" "/etc/apt/sources.list" + restore_path "${SCRIPT_NAME}" "apt" "sources.list.d" "${src}/sources.list.d" "/etc/apt/sources.list.d" + restore_path "${SCRIPT_NAME}" "apt" "preferences" "${src}/preferences" "/etc/apt/preferences" + restore_path "${SCRIPT_NAME}" "apt" "preferences.d" "${src}/preferences.d" "/etc/apt/preferences.d" + restore_path "${SCRIPT_NAME}" "apt" "apt.conf.d" "${src}/apt.conf.d" "/etc/apt/apt.conf.d" + + if [[ ${DRY_RUN} == true ]]; then + printf '[dry] apt-get update\n' + else + if apt-get update; then + report "${SCRIPT_NAME}" "apt" "update-after-restore" "update" "ok" "apt metadata refreshed" "" + else + note_failure "${SCRIPT_NAME}" "apt" "update-after-restore" "update" "apt-get update failed after restoring sources" + fi + fi +} + +package_available() { + local pkg="$1" + apt-cache show -- "${pkg}" >/dev/null 2>&1 +} + +install_from_db_manual() { + local f="${PUBLIC_DIR}/packages/apt-mark-manual.txt" pkg installed=0 skipped=0 failed=0 + [[ -f ${f} ]] || { mark_manual "${SCRIPT_NAME}" "packages" "db-manual" "apt-mark-manual.txt missing"; return 0; } + + while IFS= read -r pkg; do + [[ -n ${pkg} ]] || continue + [[ ${pkg} =~ ^# ]] && continue + if dpkg -s -- "${pkg}" >/dev/null 2>&1; then + ((installed+=1)) + continue + fi + if ! package_available "${pkg}"; then + ((skipped+=1)) + report "${SCRIPT_NAME}" "packages" "${pkg}" "install" "skipped" "package not available in current apt sources" "" + continue + fi + if [[ ${DRY_RUN} == true ]]; then + printf '[dry] apt-get install -y --no-install-recommends %s\n' "${pkg}" + ((installed+=1)) + continue + fi + if DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends "${pkg}"; then + ((installed+=1)) + report "${SCRIPT_NAME}" "packages" "${pkg}" "install" "changed" "installed from apt-mark-manual" "" + else + ((failed+=1)) + note_failure "${SCRIPT_NAME}" "packages" "${pkg}" "install" "apt install failed" + fi + done < <(list_from_file "${f}") + + report "${SCRIPT_NAME}" "packages" "db-manual-summary" "install" "ok" "installed=${installed} skipped=${skipped} failed=${failed}" "" +} +normalize_service_baseline() { + local keep_active=( + ssh.service + systemd-resolved.service + fail2ban.service + ) + + local stop_disable=( + dnsmasq.service + unbound.service + unbound-resolvconf.service + nginx.service + mariadb.service + postfix.service + prosody.service + prometheus.service + tor.service + i2pd.service + mumble-server.service + pygopherd.service + ) + + local unit + + for unit in "${stop_disable[@]}"; do + if systemctl list-unit-files --type=service --no-legend --no-pager 2>/dev/null | awk '{print $1}' | grep -qx -- "${unit}"; then + systemctl disable --now "${unit}" >/dev/null 2>&1 || true + fi + done + + systemctl reset-failed "${stop_disable[@]}" >/dev/null 2>&1 || true + + for unit in "${keep_active[@]}"; do + if systemctl list-unit-files --type=service --no-legend --no-pager 2>/dev/null | awk '{print $1}' | grep -qx -- "${unit}"; then + systemctl enable "${unit}" >/dev/null 2>&1 || true + systemctl restart "${unit}" >/dev/null 2>&1 || true + fi + done + + report "${SCRIPT_NAME}" "services" "baseline" "normalize" "changed" "safe services kept; app/conflicting services stopped" "" +} +start_safe_services() { + local svc + for svc in ssh systemd-resolved fail2ban; do + systemctl_safe_enable "${svc}" || true + systemctl_safe_start "${svc}" || true + done + + case "${DNS_MODE}" in + resolved) + systemctl_safe_enable systemd-resolved || true + systemctl_safe_start systemd-resolved || true + ;; + esac + report "${SCRIPT_NAME}" "services" "safe-start" "start" "ok" "safe services processed" "" +} + +if [[ ${DRY_RUN} == false ]]; then + bootstrap_dns_prepare + if apt-get update; then + report "${SCRIPT_NAME}" "apt" "update" "update" "ok" "initial apt update" "" + else + note_failure "${SCRIPT_NAME}" "apt" "update" "update" "initial apt-get update failed" + die "initial apt-get update failed" + fi +else + report "${SCRIPT_NAME}" "apt" "update" "update" "ok" "initial apt update (dry-run)" "" +fi + +install_pkg_group base "${BASE_PACKAGES[@]}" +case "${PROFILE}" in + minimal) ;; + core) install_pkg_group core "${CORE_PACKAGES[@]}" ;; + full) + install_pkg_group core "${CORE_PACKAGES[@]}" + install_pkg_group full-extra "${FULL_EXTRA_PACKAGES[@]}" + ;; +esac + +normalize_service_baseline +apply_hostname_locale_timezone + +if [[ ${USE_DB_APT_SOURCES} == true ]]; then + restore_apt_sources_from_db +fi + +if [[ ${FROM_DB_MANUAL} == true ]]; then + install_from_db_manual +fi + +install -d -m 0755 /srv /srv/www /srv/docs /etc/wireguard /etc/labunix /var/lib/labunix +report "${SCRIPT_NAME}" "filesystem" "base-dirs" "create" "ok" "/srv /etc/wireguard /etc/labunix prepared" "" + +if [[ ${START_SERVICES} == true ]]; then + start_safe_services +else + report "${SCRIPT_NAME}" "services" "safe-start" "start" "skipped" "--start-safe-services not requested" "" +fi + +set_state install_base done +report "${SCRIPT_NAME}" "run" "finish" "exit" "ok" "completed" "" +log "${SCRIPT_NAME}: done; report=${REPORT_FILE}" diff --git a/straper/rebuild.conf.example b/straper/rebuild.conf.example new file mode 100644 index 0000000..ba1e9ab --- /dev/null +++ b/straper/rebuild.conf.example @@ -0,0 +1,10 @@ +# Example: /etc/labunix/rebuild.conf +DB_DIR=/srv/sanctum-rebuild +ROLE=lab +PROFILE=core +DNS_MODE=auto +NETWORK_MODE=safe +START_SERVICES=false +RESTORE_SECRETS=false +RESTORE_IDENTITIES=false +ASSUME_YES=false diff --git a/straper/restore-configs.sh b/straper/restore-configs.sh new file mode 100644 index 0000000..db81487 --- /dev/null +++ b/straper/restore-configs.sh @@ -0,0 +1,521 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +SCRIPT_DIR="$(cd -- "$(dirname -- "$0")" && pwd)" +# shellcheck disable=SC1091 +source "${SCRIPT_DIR}/lib/common.sh" + +SCRIPT_NAME="restore-configs.sh" +CATEGORIES=() +LIST_CATEGORIES=false + +usage() { + cat </dev/null 2>&1; then + report "${SCRIPT_NAME}" "users" "sudoers-perms" "fix" "changed" "sudoers ownership and permissions normalized" "" + return 0 + fi + + note_failure "${SCRIPT_NAME}" "users" "sudoers-perms" "fix" "visudo validation failed after permission normalization" + return 1 +} + +maybe_restore() { + local category="$1" item="$2" src="$3" dst="$4" + local prompt_text="Restore ${category}/${item} -> ${dst}?" + + if ! prompt_yes_no "${prompt_text}" yes; then + report "${SCRIPT_NAME}" "${category}" "${item}" "restore" "skipped" "operator skipped" "" + return 0 + fi + + restore_path "${SCRIPT_NAME}" "${category}" "${item}" "${src}" "${dst}" || true +} + +category_enabled() { + local want="$1" x + for x in "${CATEGORIES[@]}"; do + [[ "${x}" == "${want}" ]] && return 0 + done + return 1 +} + +restore_system_basics() { + local base="${PUBLIC_DIR}/system" + [[ -d "${base}" ]] || { mark_manual "${SCRIPT_NAME}" "system-basics" "db" "missing ${base}"; return 0; } + + if ! prompt_yes_no "Restore category 'system-basics'?" yes; then + report "${SCRIPT_NAME}" "system-basics" "category" "restore" "skipped" "operator skipped category" "" + return 0 + fi + + maybe_restore "system-basics" "etc-hostname" "${base}/etc-hostname" "/etc/hostname" + maybe_restore "system-basics" "etc-hosts" "${base}/etc-hosts" "/etc/hosts" + maybe_restore "system-basics" "etc-environment" "${base}/etc-environment" "/etc/environment" + maybe_restore "system-basics" "locale.gen" "${base}/locale.gen" "/etc/locale.gen" + maybe_restore "system-basics" "timezone" "${base}/timezone" "/etc/timezone" + + if [[ -f "${base}/etc-hostname" ]]; then + local captured_hostname + captured_hostname="$(head -n1 "${base}/etc-hostname" 2>/dev/null || true)" + if [[ -n "${captured_hostname}" ]]; then + if prompt_yes_no "Apply captured hostname now?" yes; then + if [[ ${DRY_RUN} == true ]]; then + printf '[dry] hostnamectl set-hostname %s\n' "${captured_hostname}" + else + hostnamectl set-hostname "${captured_hostname}" || note_failure "${SCRIPT_NAME}" "system-basics" "hostnamectl" "set" "hostnamectl failed" + fi + report "${SCRIPT_NAME}" "system-basics" "hostnamectl" "set" "changed" "${captured_hostname}" "" + else + report "${SCRIPT_NAME}" "system-basics" "hostnamectl" "set" "skipped" "operator skipped hostnamectl" "" + fi + fi + fi +} + +restore_users() { + local base="${PUBLIC_DIR}/users" + [[ -d "${base}" ]] || { mark_manual "${SCRIPT_NAME}" "users" "db" "missing ${base}"; return 0; } + + if ! prompt_yes_no "Restore category 'users'?" yes; then + report "${SCRIPT_NAME}" "users" "category" "restore" "skipped" "operator skipped category" "" + return 0 + fi + + maybe_restore "users" "sudoers" "${base}/sudoers" "/etc/sudoers" + maybe_restore "users" "sudoers.d" "${base}/sudoers.d" "/etc/sudoers.d" + fix_sudoers_perms || true + maybe_restore "users" "shells" "${base}/shells" "/etc/shells" + maybe_restore "users" "login.defs" "${base}/login.defs" "/etc/login.defs" +} + +restore_ssh() { + local pub_users="${PUBLIC_DIR}/users" + local sec_ssh="${SECRET_DIR}/ssh" + + if ! prompt_yes_no "Restore category 'ssh'?" yes; then + report "${SCRIPT_NAME}" "ssh" "category" "restore" "skipped" "operator skipped category" "" + return 0 + fi + + maybe_restore "ssh" "sshd_config" "${pub_users}/sshd_config" "/etc/ssh/sshd_config" + maybe_restore "ssh" "sshd_config.d" "${pub_users}/sshd_config.d" "/etc/ssh/sshd_config.d" + + if [[ "${ROLE}" == "replacement" || "${ROLE}" == "hardware" ]]; then + if [[ -d "${sec_ssh}/etc-ssh" ]]; then + if prompt_yes_no "Restore SSH host keys from secret DB?" no; then + restore_path "${SCRIPT_NAME}" "ssh" "host-keys" "${sec_ssh}/etc-ssh" "/etc/ssh" || true + else + report "${SCRIPT_NAME}" "ssh" "host-keys" "restore" "skipped" "operator skipped host keys" "" + fi + fi + else + report "${SCRIPT_NAME}" "ssh" "host-keys" "restore" "skipped" "lab role: host keys not restored" "" + fi + + if [[ -d "${sec_ssh}/user-lukasz" ]]; then + if prompt_yes_no "Restore lukasz user SSH material from secret DB?" no; then + restore_path "${SCRIPT_NAME}" "ssh" "user-lukasz" "${sec_ssh}/user-lukasz" "/home/lukasz/.ssh" || true + if [[ ${DRY_RUN} == false ]]; then + chown -R lukasz:lukasz /home/lukasz/.ssh 2>/dev/null || true + chmod 700 /home/lukasz/.ssh 2>/dev/null || true + find /home/lukasz/.ssh -maxdepth 1 -type f -exec chmod 600 {} \; 2>/dev/null || true + fi + else + report "${SCRIPT_NAME}" "ssh" "user-lukasz" "restore" "skipped" "operator skipped user ssh material" "" + fi + fi + + if have_cmd sshd; then + if [[ ${DRY_RUN} == true ]]; then + printf '[dry] sshd -t\n' + report "${SCRIPT_NAME}" "ssh" "validate" "check" "ok" "dry-run only" "" + else + if sshd -t; then + report "${SCRIPT_NAME}" "ssh" "validate" "check" "ok" "sshd config validates" "" + systemctl restart ssh >/dev/null 2>&1 || true + else + note_failure "${SCRIPT_NAME}" "ssh" "validate" "check" "sshd validation failed" + fi + fi + fi +} + +restore_network() { + local base="${PUBLIC_DIR}/network" + [[ -d "${base}" ]] || { mark_manual "${SCRIPT_NAME}" "network" "db" "missing ${base}"; return 0; } + + if ! prompt_yes_no "Restore category 'network'? This can disrupt connectivity." no; then + report "${SCRIPT_NAME}" "network" "category" "restore" "skipped" "operator skipped category" "" + return 0 + fi + + if [[ "${ROLE}" == "lab" ]]; then + manual_line="lab role: full network restore intentionally skipped" + report "${SCRIPT_NAME}" "network" "category" "manual" "manual" "${manual_line}" "" + return 0 + fi + + maybe_restore "network" "etc-network" "${base}/etc-network" "/etc/network" + maybe_restore "network" "etc-netplan" "${base}/etc-netplan" "/etc/netplan" + maybe_restore "network" "systemd-network" "${base}/systemd-network" "/etc/systemd/network" + maybe_restore "network" "nsswitch.conf" "${base}/nsswitch.conf" "/etc/nsswitch.conf" + maybe_restore "network" "hosts.allow" "${base}/hosts.allow" "/etc/hosts.allow" + maybe_restore "network" "hosts.deny" "${base}/hosts.deny" "/etc/hosts.deny" +} + +restore_dns() { + local base="${PUBLIC_DIR}/dns" + [[ -d "${base}" ]] || { mark_manual "${SCRIPT_NAME}" "dns" "db" "missing ${base}"; return 0; } + + if ! prompt_yes_no "Restore category 'dns'? This can disrupt resolver state." no; then + report "${SCRIPT_NAME}" "dns" "category" "restore" "skipped" "operator skipped category" "" + return 0 + fi + + if [[ "${ROLE}" == "lab" ]]; then + report "${SCRIPT_NAME}" "dns" "category" "manual" "manual" "lab role: DNS restore intentionally skipped" "" + return 0 + fi + + maybe_restore "dns" "dnsmasq.conf" "${base}/dnsmasq.conf" "/etc/dnsmasq.conf" + maybe_restore "dns" "dnsmasq.d" "${base}/dnsmasq.d" "/etc/dnsmasq.d" + maybe_restore "dns" "etc-unbound" "${base}/etc-unbound" "/etc/unbound" + maybe_restore "dns" "resolv.conf" "${base}/resolv.conf" "/etc/resolv.conf" +} + +restore_firewall() { + local base="${PUBLIC_DIR}/firewall" + [[ -d "${base}" ]] || { mark_manual "${SCRIPT_NAME}" "firewall" "db" "missing ${base}"; return 0; } + + if ! prompt_yes_no "Restore category 'firewall'? This can disrupt connectivity." no; then + report "${SCRIPT_NAME}" "firewall" "category" "restore" "skipped" "operator skipped category" "" + return 0 + fi + + if [[ "${ROLE}" == "lab" ]]; then + report "${SCRIPT_NAME}" "firewall" "category" "manual" "manual" "lab role: firewall restore intentionally skipped" "" + return 0 + fi + + maybe_restore "firewall" "nftables.conf" "${base}/nftables.conf" "/etc/nftables.conf" + maybe_restore "firewall" "nftables.d" "${base}/nftables.d" "/etc/nftables.d" + + if [[ ${DRY_RUN} == false && -f /etc/nftables.conf ]] && have_cmd nft; then + nft -c -f /etc/nftables.conf >/dev/null 2>&1 || note_failure "${SCRIPT_NAME}" "firewall" "validate" "check" "nftables config validation failed" + fi +} + +restore_nginx() { + local base="${PUBLIC_DIR}/nginx/etc-nginx" + [[ -d "${base}" ]] || { mark_manual "${SCRIPT_NAME}" "nginx" "db" "missing ${base}"; return 0; } + + if ! prompt_yes_no "Restore category 'nginx'?" no; then + report "${SCRIPT_NAME}" "nginx" "category" "restore" "skipped" "operator skipped category" "" + return 0 + fi + + maybe_restore "nginx" "etc-nginx" "${base}" "/etc/nginx" + + if [[ "${ROLE}" == "lab" ]]; then + if [[ ${DRY_RUN} == true ]]; then + printf '[dry] sanitize /etc/nginx for lab role\n' + printf '[dry] rm -rf /etc/nginx/nginx\n' + printf '[dry] find /etc/nginx -maxdepth 1 -type d -name '\''sites-available.bak.*'\'' -exec rm -rf {} +\n' + printf '[dry] rm -f /etc/nginx/sites-enabled/*\n' + printf '[dry] ln -sf ../sites-available/default /etc/nginx/sites-enabled/default\n' + else + rm -rf /etc/nginx/nginx 2>/dev/null || true + find /etc/nginx -maxdepth 1 -type d -name 'sites-available.bak.*' -exec rm -rf {} + 2>/dev/null || true + + mkdir -p /etc/nginx/sites-enabled + find /etc/nginx/sites-enabled -mindepth 1 -maxdepth 1 -exec rm -f {} + 2>/dev/null || true + + if [[ -e /etc/nginx/sites-available/default ]]; then + ln -sf ../sites-available/default /etc/nginx/sites-enabled/default + fi + fi + + report "${SCRIPT_NAME}" "nginx" "lab-sanitize" "restore" "changed" \ + "lab role: dropped captured production sites-enabled and restored only default site" "" + fi +} + +restore_mariadb() { + local base="${PUBLIC_DIR}/mariadb/etc-mysql" + [[ -d "${base}" ]] || { mark_manual "${SCRIPT_NAME}" "mariadb" "db" "missing ${base}"; return 0; } + + if ! prompt_yes_no "Restore category 'mariadb'?" no; then + report "${SCRIPT_NAME}" "mariadb" "category" "restore" "skipped" "operator skipped category" "" + return 0 + fi + + maybe_restore "mariadb" "etc-mysql" "${base}" "/etc/mysql" +} + +restore_postfix() { + local base="${PUBLIC_DIR}/postfix" + [[ -d "${base}" ]] || { + mark_manual "${SCRIPT_NAME}" "postfix" "db" "missing ${base}" + return 0 + } + + if ! prompt_yes_no "Restore category 'postfix'?" no; then + report "${SCRIPT_NAME}" "postfix" "category" "restore" "skipped" "operator skipped category" "" + return 0 + fi + + if prompt_yes_no "Restore postfix/main.cf -> /etc/postfix/main.cf?" yes; then + restore_path "${SCRIPT_NAME}" "postfix" "main.cf" \ + "${base}/main.cf" "/etc/postfix/main.cf" \ + 0644 root root || true + else + report "${SCRIPT_NAME}" "postfix" "main.cf" "restore" "skipped" "operator skipped" "" + fi + + if prompt_yes_no "Restore postfix/master.cf -> /etc/postfix/master.cf?" yes; then + restore_path "${SCRIPT_NAME}" "postfix" "master.cf" \ + "${base}/master.cf" "/etc/postfix/master.cf" \ + 0644 root root || true + else + report "${SCRIPT_NAME}" "postfix" "master.cf" "restore" "skipped" "operator skipped" "" + fi +} + +restore_prosody() { + local pub_base="${PUBLIC_DIR}/prosody/etc-prosody" + local sec_base="${SECRET_DIR}/prosody/etc-prosody" + local base="" + local label="" + + if ! prompt_yes_no "Restore category 'prosody'?" no; then + report "${SCRIPT_NAME}" "prosody" "category" "restore" "skipped" "operator skipped category" "" + return 0 + fi + + if [[ -d "${sec_base}" ]]; then + base="${sec_base}" + label="etc-prosody(secret)" + elif [[ -d "${pub_base}" ]]; then + base="${pub_base}" + label="etc-prosody(public)" + else + mark_manual "${SCRIPT_NAME}" "prosody" "db" "missing public/secret prosody config" + return 0 + fi + + if prompt_yes_no "Restore prosody/${label} -> /etc/prosody?" yes; then + restore_path "${SCRIPT_NAME}" "prosody" "${label}" "${base}" "/etc/prosody" || true + + if [[ ${DRY_RUN} == true ]]; then + printf '[dry] chown -R root:root /etc/prosody\n' + printf '[dry] find /etc/prosody -type d -exec chmod 0755 {} +\n' + printf '[dry] find /etc/prosody -type f -exec chmod 0644 {} +\n' + printf '[dry] find /etc/prosody/certs -type f -exec chmod 0640 {} + 2>/dev/null || true\n' + else + chown -R root:root /etc/prosody 2>/dev/null || true + find /etc/prosody -type d -exec chmod 0755 {} + 2>/dev/null || true + find /etc/prosody -type f -exec chmod 0644 {} + 2>/dev/null || true + [[ -d /etc/prosody/certs ]] && find /etc/prosody/certs -type f -exec chmod 0640 {} + 2>/dev/null || true + fi + + report "${SCRIPT_NAME}" "prosody" "metadata" "restore" "changed" \ + "normalized /etc/prosody ownership=root:root dirs=0755 files=0644 certs=0640" "" + else + report "${SCRIPT_NAME}" "prosody" "${label}" "restore" "skipped" "operator skipped" "" + fi +} + +restore_tor() { + local pub_base="${PUBLIC_DIR}/tor" + local sec_base="${SECRET_DIR}/tor" + + if ! prompt_yes_no "Restore category 'tor'?" no; then + report "${SCRIPT_NAME}" "tor" "category" "restore" "skipped" "operator skipped category" "" + return 0 + fi + + maybe_restore "tor" "torrc" "${pub_base}/torrc" "/etc/tor/torrc" + maybe_restore "tor" "torrc.d" "${pub_base}/torrc.d" "/etc/tor/torrc.d" + + if [[ "${ROLE}" == "replacement" && -d "${sec_base}/var-lib-tor" ]]; then + maybe_restore "tor" "var-lib-tor" "${sec_base}/var-lib-tor" "/var/lib/tor" + else + report "${SCRIPT_NAME}" "tor" "identity" "restore" "skipped" "tor private data not restored in this role" "" + fi +} + +restore_i2pd() { + local pub_base="${PUBLIC_DIR}/i2pd" + local sec_base="${SECRET_DIR}/i2pd" + + if ! prompt_yes_no "Restore category 'i2pd'?" no; then + report "${SCRIPT_NAME}" "i2pd" "category" "restore" "skipped" "operator skipped category" "" + return 0 + fi + + maybe_restore "i2pd" "etc-i2pd" "${pub_base}/etc-i2pd" "/etc/i2pd" + + if [[ "${ROLE}" == "replacement" && -d "${sec_base}/var-lib-i2pd" ]]; then + maybe_restore "i2pd" "var-lib-i2pd" "${sec_base}/var-lib-i2pd" "/var/lib/i2pd" + else + report "${SCRIPT_NAME}" "i2pd" "identity" "restore" "skipped" "i2pd private data not restored in this role" "" + fi +} + +restore_docker() { + local pub_base="${PUBLIC_DIR}/docker" + local sec_base="${SECRET_DIR}/docker" + + if ! prompt_yes_no "Restore category 'docker'?" no; then + report "${SCRIPT_NAME}" "docker" "category" "restore" "skipped" "operator skipped category" "" + return 0 + fi + + maybe_restore "docker" "daemon.json" "${pub_base}/daemon.json" "/etc/docker/daemon.json" + + if [[ -d "${sec_base}/compose-full" ]]; then + report "${SCRIPT_NAME}" "docker" "compose-full" "manual" "manual" "compose files available in secret DB; restore manually per stack" "" + fi +} + +restore_monitoring() { + if ! prompt_yes_no "Restore category 'monitoring'?" no; then + report "${SCRIPT_NAME}" "monitoring" "category" "restore" "skipped" "operator skipped category" "" + return 0 + fi + + [[ -d "${PUBLIC_DIR}/prometheus/etc-prometheus" ]] && maybe_restore "monitoring" "prometheus" "${PUBLIC_DIR}/prometheus/etc-prometheus" "/etc/prometheus" + [[ -f "${PUBLIC_DIR}/prometheus/node-exporter-defaults" ]] && maybe_restore "monitoring" "prometheus-node-exporter" "${PUBLIC_DIR}/prometheus/node-exporter-defaults" "/etc/default/prometheus-node-exporter" + [[ -d "${PUBLIC_DIR}/loki/etc-loki" ]] && maybe_restore "monitoring" "loki" "${PUBLIC_DIR}/loki/etc-loki" "/etc/loki" + [[ -d "${PUBLIC_DIR}/grafana/etc-grafana" ]] && maybe_restore "monitoring" "grafana" "${PUBLIC_DIR}/grafana/etc-grafana" "/etc/grafana" + [[ -d "${SECRET_DIR}/alloy/etc-alloy" ]] && maybe_restore "monitoring" "alloy" "${SECRET_DIR}/alloy/etc-alloy" "/etc/alloy" +} + +for category in "${CATEGORIES[@]}"; do + log "processing category: ${category}" + case "${category}" in + system-basics) restore_system_basics ;; + users) restore_users ;; + ssh) restore_ssh ;; + network) restore_network ;; + dns) restore_dns ;; + firewall) restore_firewall ;; + nginx) restore_nginx ;; + mariadb) restore_mariadb ;; + postfix) restore_postfix ;; + prosody) restore_prosody ;; + tor) restore_tor ;; + i2pd) restore_i2pd ;; + docker) restore_docker ;; + monitoring) restore_monitoring ;; + *) note_failure "${SCRIPT_NAME}" "category" "${category}" "restore" "unknown category" ;; + esac +done + +set_state restore_configs done +report "${SCRIPT_NAME}" "run" "finish" "exit" "ok" "completed" "" +log "${SCRIPT_NAME}: done; report=${REPORT_FILE}" diff --git a/straper/sanctum-rebuild-toolkit.tar.gz b/straper/sanctum-rebuild-toolkit.tar.gz new file mode 100644 index 0000000..5a49146 Binary files /dev/null and b/straper/sanctum-rebuild-toolkit.tar.gz differ -- cgit v1.3