aboutsummaryrefslogtreecommitdiff
path: root/straper/lib/common.sh
diff options
context:
space:
mode:
Diffstat (limited to 'straper/lib/common.sh')
-rwxr-xr-xstraper/lib/common.sh330
1 files changed, 330 insertions, 0 deletions
diff --git a/straper/lib/common.sh b/straper/lib/common.sh
new file mode 100755
index 0000000..32f3581
--- /dev/null
+++ b/straper/lib/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}"
+}