#!/usr/bin/env bash set -euo pipefail BACKUP_USER="${BACKUP_USER:-hybbackup}" BACKUP_ROOT="${BACKUP_ROOT:-}" PUBLIC_KEY_FILE="${PUBLIC_KEY_FILE:-}" RECEIVER_SOURCE="${RECEIVER_SOURCE:-}" RECEIVER_TARGET="${RECEIVER_TARGET:-/usr/local/sbin/hyb-oa-offhost-receiver}" CONFIG_FILE="${CONFIG_FILE:-/etc/hyb-oa-offhost-backup.conf}" ALERT_WEBHOOK_FILE="${ALERT_WEBHOOK_FILE:-}" ALERT_HELPER_SOURCE="${ALERT_HELPER_SOURCE:-}" ALERT_HELPER_TARGET="${ALERT_HELPER_TARGET:-/usr/local/sbin/hyb-oa-wecom-group-alert}" DEADMAN_SOURCE="${DEADMAN_SOURCE:-}" DEADMAN_TARGET="${DEADMAN_TARGET:-/usr/local/sbin/hyb-oa-offhost-deadman}" DEADMAN_MAX_AGE_SECONDS="${DEADMAN_MAX_AGE_SECONDS:-93600}" DEADMAN_RUN_AT="${DEADMAN_RUN_AT:-06:30}" DEADMAN_SERVICE="${DEADMAN_SERVICE:-hyb-oa-offhost-deadman}" SYSTEMD_DIR="${SYSTEMD_DIR:-/etc/systemd/system}" DAILY_KEEP="${DAILY_KEEP:-7}" WEEKLY_KEEP="${WEEKLY_KEEP:-4}" MODE="${1:-install}" fail() { echo "[offhost-receiver-installer][ERROR] $1" >&2 exit 1 } need_root() { [[ "$(id -u)" == "0" ]] || fail "root privileges are required" } validate_absolute_path() { local name="$1" local value="$2" if [[ -z "$value" || "$value" != /* || ! "$value" =~ ^/[A-Za-z0-9._/-]+$ ]]; then fail "$name must be an explicit safe absolute path" fi if [[ "$value" == "/" || "$value" == */ || "$value" == *"//"* \ || "/$value/" == *"/../"* || "/$value/" == *"/./"* ]]; then fail "$name contains an unsafe path segment" fi } validate_backup_root() { validate_absolute_path BACKUP_ROOT "$BACKUP_ROOT" [[ "$BACKUP_ROOT" =~ ^/[^/]+/[^/]+ ]] \ || fail "backup root must be a dedicated path below a top-level directory" case "$BACKUP_ROOT" in /bin/*|/boot/*|/dev/*|/etc/*|/lib/*|/lib64/*|/proc/*|/run/*|/sbin/*|/sys/*|/usr/*) fail "backup root must not be inside a system-managed directory" ;; esac [[ ! -L "$BACKUP_ROOT" ]] || fail "backup root must not be a symbolic link" if [[ -d "$BACKUP_ROOT" ]]; then local unmanaged_entry unmanaged_entry="$(find "$BACKUP_ROOT" -mindepth 1 -maxdepth 1 \ ! -name inbox ! -name archive ! -name weekly ! -name state -print -quit)" [[ -z "$unmanaged_entry" ]] || fail "backup root contains an unmanaged top-level entry" fi } validate_inputs() { [[ "$BACKUP_USER" =~ ^[a-z_][a-z0-9_-]{0,30}$ ]] || fail "backup user is invalid" validate_backup_root validate_absolute_path PUBLIC_KEY_FILE "$PUBLIC_KEY_FILE" validate_absolute_path RECEIVER_SOURCE "$RECEIVER_SOURCE" validate_absolute_path RECEIVER_TARGET "$RECEIVER_TARGET" validate_absolute_path CONFIG_FILE "$CONFIG_FILE" validate_absolute_path ALERT_WEBHOOK_FILE "$ALERT_WEBHOOK_FILE" validate_absolute_path ALERT_HELPER_SOURCE "$ALERT_HELPER_SOURCE" validate_absolute_path ALERT_HELPER_TARGET "$ALERT_HELPER_TARGET" validate_absolute_path DEADMAN_SOURCE "$DEADMAN_SOURCE" validate_absolute_path DEADMAN_TARGET "$DEADMAN_TARGET" [[ -f "$PUBLIC_KEY_FILE" && ! -L "$PUBLIC_KEY_FILE" ]] || fail "public key file is missing" [[ -f "$RECEIVER_SOURCE" && ! -L "$RECEIVER_SOURCE" ]] || fail "receiver source is missing" [[ -f "$ALERT_HELPER_SOURCE" && ! -L "$ALERT_HELPER_SOURCE" ]] || fail "alert helper source is missing" [[ -f "$DEADMAN_SOURCE" && ! -L "$DEADMAN_SOURCE" ]] || fail "dead-man source is missing" [[ -f "$ALERT_WEBHOOK_FILE" && ! -L "$ALERT_WEBHOOK_FILE" \ && "$(stat -c %a "$ALERT_WEBHOOK_FILE")" == "600" \ && "$(stat -c %u "$ALERT_WEBHOOK_FILE")" == "0" ]] \ || fail "alert webhook file must be root-owned with mode 0600" [[ "$DEADMAN_MAX_AGE_SECONDS" =~ ^[1-9][0-9]*$ ]] || fail "dead-man threshold must be positive" [[ "$DEADMAN_RUN_AT" =~ ^([01][0-9]|2[0-3]):[0-5][0-9]$ ]] || fail "dead-man time is invalid" [[ "$DEADMAN_SERVICE" =~ ^[a-z0-9][a-z0-9_-]*$ ]] || fail "dead-man service name is invalid" [[ "$DAILY_KEEP" =~ ^[1-9][0-9]*$ && "$WEEKLY_KEEP" =~ ^[1-9][0-9]*$ ]] \ || fail "retention values must be positive" command -v rsync >/dev/null 2>&1 || fail "rsync must be installed before receiver setup" command -v rrsync >/dev/null 2>&1 || fail "rrsync must be installed before receiver setup" command -v python3 >/dev/null 2>&1 || fail "python3 is required" } read_public_key() { local key_type key_body remainder extra_line_count extra_line_count="$(awk 'NF { count += 1 } END { print count + 0 }' "$PUBLIC_KEY_FILE")" [[ "$extra_line_count" == "1" ]] || fail "public key file must contain exactly one non-empty key" read -r key_type key_body remainder <"$PUBLIC_KEY_FILE" [[ "$key_type" == "ssh-ed25519" || "$key_type" == "sk-ssh-ed25519@openssh.com" ]] \ || fail "only Ed25519 public keys are accepted" [[ "$key_body" =~ ^[A-Za-z0-9+/=]+$ ]] || fail "public key body is invalid" printf '%s %s' "$key_type" "$key_body" } install_receiver() { need_root validate_inputs if ! id "$BACKUP_USER" >/dev/null 2>&1; then useradd --create-home --shell /bin/bash "$BACKUP_USER" fi passwd -l "$BACKUP_USER" >/dev/null 2>&1 || true local home_dir inbox_dir archive_dir weekly_dir state_dir ssh_dir auth_file public_key rrsync_bin home_dir="$(getent passwd "$BACKUP_USER" | awk -F: '{print $6}')" validate_absolute_path BACKUP_USER_HOME "$home_dir" inbox_dir="$BACKUP_ROOT/inbox" archive_dir="$BACKUP_ROOT/archive" weekly_dir="$BACKUP_ROOT/weekly" state_dir="$BACKUP_ROOT/state" mkdir -p "$inbox_dir" "$archive_dir" "$weekly_dir" "$state_dir" "$home_dir/.ssh" [[ -d "$BACKUP_ROOT" && ! -L "$BACKUP_ROOT" \ && -d "$inbox_dir" && ! -L "$inbox_dir" \ && -d "$archive_dir" && ! -L "$archive_dir" \ && -d "$weekly_dir" && ! -L "$weekly_dir" ]] \ || fail "backup receiver directories must not be symbolic links" chown "$BACKUP_USER:$BACKUP_USER" "$BACKUP_ROOT" chown -R "$BACKUP_USER:$BACKUP_USER" "$inbox_dir" "$archive_dir" "$weekly_dir" "$home_dir/.ssh" chmod 0700 "$BACKUP_ROOT" "$inbox_dir" "$archive_dir" "$weekly_dir" "$home_dir/.ssh" chown -R root:root "$state_dir" chmod 0700 "$state_dir" install -o root -g root -m 0755 "$RECEIVER_SOURCE" "$RECEIVER_TARGET" install -o root -g root -m 0755 "$ALERT_HELPER_SOURCE" "$ALERT_HELPER_TARGET" install -o root -g root -m 0755 "$DEADMAN_SOURCE" "$DEADMAN_TARGET" rrsync_bin="$(command -v rrsync)" cat >"$CONFIG_FILE" <"$auth_file" </dev/null cat >"$SYSTEMD_DIR/${DEADMAN_SERVICE}.service" <"$SYSTEMD_DIR/${DEADMAN_SERVICE}.timer" </dev/null 2>&1 || true systemctl stop "${DEADMAN_SERVICE}.service" >/dev/null 2>&1 || true rm -f -- "$SYSTEMD_DIR/${DEADMAN_SERVICE}.timer" "$SYSTEMD_DIR/${DEADMAN_SERVICE}.service" if id "$BACKUP_USER" >/dev/null 2>&1; then local home_dir auth_file home_dir="$(getent passwd "$BACKUP_USER" | awk -F: '{print $6}')" auth_file="$home_dir/.ssh/authorized_keys" if [[ -f "$auth_file" ]] && grep -Fq 'hyb-oa-managed-offhost-backup' "$auth_file"; then rm -f -- "$auth_file" fi fi rm -f -- "$RECEIVER_TARGET" "$CONFIG_FILE" "$ALERT_HELPER_TARGET" "$DEADMAN_TARGET" systemctl daemon-reload echo "[offhost-receiver-installer][PASS] receiver authorization and dead-man removed; archives retained" } case "$MODE" in install) install_receiver ;; enable) enable_receiver ;; uninstall|--uninstall) uninstall_receiver ;; --help|-h) echo "Usage: bash install-offhost-backup-receiver.sh [install|enable|uninstall]" ;; *) fail "unsupported mode" ;; esac