aboutsummaryrefslogtreecommitdiff
path: root/straper
diff options
context:
space:
mode:
authorLukasz Kasprzak <lukasz.kasprzak@pm.me>2026-03-19 15:05:32 +0100
committerLukasz Kasprzak <lukasz.kasprzak@pm.me>2026-03-19 15:05:32 +0100
commitb1844d805a8f190af00faf3f6e5bed7d997ecaae (patch)
treed6296ba5e85034836e203a542d2ad34a9e5c5b32 /straper
parentc74163f1ecbd809c83bb465ee89fa04369a89972 (diff)
downloadbin-b1844d805a8f190af00faf3f6e5bed7d997ecaae.tar.gz
bin-b1844d805a8f190af00faf3f6e5bed7d997ecaae.zip
created sorter for sorting downloads with custom rules; created straper for recreation of server
Diffstat (limited to 'straper')
-rw-r--r--straper/README.md123
-rw-r--r--straper/common.sh330
-rw-r--r--straper/doctor.sh222
-rw-r--r--straper/install-base.sh342
-rw-r--r--straper/rebuild.conf.example10
-rw-r--r--straper/restore-configs.sh521
-rw-r--r--straper/sanctum-rebuild-toolkit.tar.gzbin0 -> 14623 bytes
7 files changed, 1548 insertions, 0 deletions
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-<RUN_ID>.tsv`
+- `/var/log/labunix-rebuild/doctor-<RUN_ID>.txt`
+- `/var/lib/labunix-rebuild/state-<RUN_ID>.env`
+- `/var/lib/labunix-rebuild/backups/<RUN_ID>/...`
+
+## 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 <<USAGE
+Usage: sudo ./doctor.sh [options]
+
+Purpose:
+ Read-only health and readiness checks for a rebuilt sanctum/labunix host.
+
+Options:
+ --db-dir PATH
+ --role lab|hardware|replacement
+ --strict Exit non-zero if any FAIL is found
+ --output PATH Write human report to this path
+ --dry-run Still read-only; only affects report/state writes
+ --help
+USAGE
+}
+
+while [[ $# -gt 0 ]]; do
+ case "$1" in
+ --db-dir) DB_DIR="$2"; PUBLIC_DIR="${DB_DIR}/db/public"; SECRET_DIR="${DB_DIR}/db/secret"; shift ;;
+ --role) ROLE="$2"; shift ;;
+ --strict) STRICT=true ;;
+ --output) OUTFILE="$2"; shift ;;
+ --dry-run) DRY_RUN=true ;;
+ --help) usage; exit 0 ;;
+ *) die "unknown option: $1" ;;
+ esac
+ shift
+done
+
+ensure_root
+load_optional_config
+ensure_runtime_dirs
+require_cmd ip systemctl ss awk sed grep
+
+OUTFILE="${OUTFILE:-${LOG_DIR}/doctor-${RUN_ID}.txt}"
+: > "${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 <<USAGE
+Usage: sudo ./install-base.sh [options]
+
+Purpose:
+ Install a reproducible Debian base for sanctum/labunix recovery without
+ importing host-specific configs or identities.
+
+Options:
+ --db-dir PATH Capture DB root (default: ${DB_DIR})
+ --role lab|hardware|replacement
+ --profile minimal|core|full
+ --hostname NAME Set hostname
+ --timezone TZ Set timezone (default: ${TIMEZONE_WANTED})
+ --locale LOCALE Set locale (default: ${LOCALE_WANTED})
+ --use-db-apt-sources Restore apt sources from db/public/packages/apt
+ --from-db-manual Install packages listed in apt-mark-manual.txt
+ --start-safe-services Enable/start low-risk services after install
+ --yes Non-interactive yes to prompts
+ --dry-run Print actions only
+ --verbose More logging
+ --help
+USAGE
+}
+
+while [[ $# -gt 0 ]]; do
+ case "$1" in
+ --db-dir) DB_DIR="$2"; PUBLIC_DIR="${DB_DIR}/db/public"; SECRET_DIR="${DB_DIR}/db/secret"; shift ;;
+ --role) ROLE="$2"; shift ;;
+ --profile) PROFILE="$2"; shift ;;
+ --hostname) HOSTNAME_WANTED="$2"; shift ;;
+ --timezone) TIMEZONE_WANTED="$2"; shift ;;
+ --locale) LOCALE_WANTED="$2"; shift ;;
+ --use-db-apt-sources) USE_DB_APT_SOURCES=true ;;
+ --from-db-manual) FROM_DB_MANUAL=true ;;
+ --start-safe-services) START_SERVICES=true ;;
+ --yes) ASSUME_YES=true ;;
+ --dry-run) DRY_RUN=true ;;
+ --verbose) VERBOSE=true ;;
+ --help) usage; exit 0 ;;
+ *) die "unknown option: $1" ;;
+ esac
+ shift
+done
+
+ensure_root
+load_optional_config
+ensure_runtime_dirs
+require_cmd apt-get apt-cache dpkg hostnamectl rsync
+
+mkdir -p -- /etc/labunix
+
+log "${SCRIPT_NAME}: role=${ROLE} profile=${PROFILE} db=${DB_DIR}"
+report "${SCRIPT_NAME}" "run" "start" "init" "ok" "role=${ROLE} profile=${PROFILE}" ""
+
+case "${ROLE}" in
+ lab|hardware|replacement) ;;
+ *) die "invalid role: ${ROLE}" ;;
+esac
+case "${PROFILE}" in
+ minimal|core|full) ;;
+ *) die "invalid profile: ${PROFILE}" ;;
+esac
+
+BASE_PACKAGES=(
+ ca-certificates curl wget gnupg2 jq rsync vim zsh sudo
+ locales tzdata lsb-release apt-transport-https
+ net-tools iproute2 iputils-ping dnsutils ethtool
+ openssh-server systemd-resolved nftables fail2ban
+)
+
+CORE_PACKAGES=(
+ wireguard wireguard-tools dnsmasq unbound nginx mariadb-server postfix prosody
+ tor i2pd pygopherd mumble-server prometheus prometheus-node-exporter loki
+ apparmor apparmor-utils
+)
+
+FULL_EXTRA_PACKAGES=(
+ git tmux htop build-essential pkg-config
+ docker.io
+)
+
+bootstrap_dns_prepare() {
+ local test_host="${BOOTSTRAP_TEST_HOST:-deb.debian.org}"
+ local backup="/etc/resolv.conf.labunix-preinstall.bak"
+
+ if getent ahostsv4 "${test_host}" >/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 <<USAGE
+Usage: sudo ./restore-configs.sh [options]
+
+Purpose:
+ Restore selected configs from a capture DB in a controlled, category-based way.
+ Intended to be non-fatal per item whenever possible. Sensitive/identity-heavy
+ categories should be restored deliberately.
+
+Options:
+ --db-dir PATH
+ --role lab|hardware|replacement
+ --category NAME May be used multiple times
+ --list-categories
+ --yes Non-interactive yes to prompts
+ --dry-run
+ --verbose
+ --help
+
+Categories:
+ system-basics
+ users
+ ssh
+ network
+ dns
+ firewall
+ nginx
+ mariadb
+ postfix
+ prosody
+ tor
+ i2pd
+ docker
+ monitoring
+USAGE
+}
+
+while [[ $# -gt 0 ]]; do
+ case "$1" in
+ --db-dir) DB_DIR="$2"; PUBLIC_DIR="${DB_DIR}/db/public"; SECRET_DIR="${DB_DIR}/db/secret"; shift ;;
+ --role) ROLE="$2"; shift ;;
+ --category) CATEGORIES+=("$2"); shift ;;
+ --list-categories) LIST_CATEGORIES=true ;;
+ --yes) ASSUME_YES=true ;;
+ --dry-run) DRY_RUN=true ;;
+ --verbose) VERBOSE=true ;;
+ --help) usage; exit 0 ;;
+ *) die "unknown option: $1" ;;
+ esac
+ shift
+done
+
+if [[ "${LIST_CATEGORIES}" == true ]]; then
+ printf '%s\n' \
+ system-basics \
+ users \
+ ssh \
+ network \
+ dns \
+ firewall \
+ nginx \
+ mariadb \
+ postfix \
+ prosody \
+ tor \
+ i2pd \
+ docker \
+ monitoring
+ exit 0
+fi
+
+ensure_root
+load_optional_config
+ensure_runtime_dirs
+require_cmd cp chmod chown find grep sed awk systemctl
+
+[[ -d "${PUBLIC_DIR}" ]] || die "public DB not found: ${PUBLIC_DIR}"
+[[ ${#CATEGORIES[@]} -gt 0 ]] || die "no categories specified; use --category or --list-categories"
+
+log "${SCRIPT_NAME}: role=${ROLE} categories=${CATEGORIES[*]} db=${DB_DIR}"
+report "${SCRIPT_NAME}" "run" "start" "init" "ok" "role=${ROLE} categories=${CATEGORIES[*]}" ""
+
+case "${ROLE}" in
+ lab|hardware|replacement) ;;
+ *) die "invalid role: ${ROLE}" ;;
+esac
+
+fix_sudoers_perms() {
+ if [[ ${DRY_RUN} == true ]]; then
+ printf '[dry] normalize sudoers ownership and permissions\n'
+ return 0
+ fi
+
+ if [[ -f /etc/sudoers ]]; then
+ chown root:root /etc/sudoers || true
+ chmod 0440 /etc/sudoers || true
+ fi
+
+ if [[ -d /etc/sudoers.d ]]; then
+ chown root:root /etc/sudoers.d || true
+ chmod 0755 /etc/sudoers.d || true
+ find /etc/sudoers.d -maxdepth 1 -type f -exec chown root:root {} \; || true
+ find /etc/sudoers.d -maxdepth 1 -type f -exec chmod 0440 {} \; || true
+ fi
+
+ if visudo -c >/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
--- /dev/null
+++ b/straper/sanctum-rebuild-toolkit.tar.gz
Binary files differ