#!/usr/bin/env bash set -euo pipefail REMOTE_DIR="${REMOTE_DIR:-/opt/hyb-oa-trialrun}" REMOTE_ENV_FILE="${REMOTE_ENV_FILE:-${REMOTE_DIR}/deploy/trialrun/.env}" COMPOSE_FILE="${COMPOSE_FILE:-deploy/trialrun/docker-compose.yml}" COMPOSE_PROJECT_NAME="${COMPOSE_PROJECT_NAME:-}" POSTGRES_DATA_DIR="${POSTGRES_DATA_DIR:-}" STORAGE_HOST_DIR="${STORAGE_HOST_DIR:-}" BUILD_SHA="${BUILD_SHA:-}" VITE_BUILD_SHA="${VITE_BUILD_SHA:-}" AUTOMATIC_BACKUP_DIR="${AUTOMATIC_BACKUP_DIR:-}" AUTOMATIC_BACKUP_STATE_DIR="${AUTOMATIC_BACKUP_STATE_DIR:-}" OFFHOST_TARGET="${OFFHOST_TARGET:-}" OFFHOST_SSH_KEY="${OFFHOST_SSH_KEY:-}" OFFHOST_KNOWN_HOSTS="${OFFHOST_KNOWN_HOSTS:-}" ALERT_WEBHOOK_FILE="${ALERT_WEBHOOK_FILE:-}" ALERT_HELPER="${ALERT_HELPER:-${REMOTE_DIR}/scripts/send-wecom-group-alert.py}" BACKUP_RUN_AT="${BACKUP_RUN_AT:-02:30}" WATCHDOG_RUN_AT="${WATCHDOG_RUN_AT:-07:10}" SERVICE_PREFIX="${SERVICE_PREFIX:-hyb-oa-automatic-backup}" MODE="${1:-install}" RUNNER="${REMOTE_DIR}/scripts/run-automatic-backup.sh" SYSTEMD_DIR="/etc/systemd/system" fail() { echo "[automatic-backup-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_managed_directory_path() { local name="$1" local value="$2" validate_absolute_path "$name" "$value" [[ "$value" =~ ^/[^/]+/[^/]+ ]] \ || fail "$name must be a dedicated path below a top-level directory" case "$value" in /bin/*|/boot/*|/dev/*|/etc/*|/lib/*|/lib64/*|/proc/*|/run/*|/sbin/*|/sys/*|/usr/*) fail "$name must not be inside a system-managed directory" ;; esac } paths_overlap() { local first="$1" local second="$2" [[ "$first" == "$second" || "$first" == "$second"/* || "$second" == "$first"/* ]] } validate_inputs() { local name value for name in REMOTE_DIR REMOTE_ENV_FILE POSTGRES_DATA_DIR STORAGE_HOST_DIR \ AUTOMATIC_BACKUP_DIR AUTOMATIC_BACKUP_STATE_DIR OFFHOST_SSH_KEY OFFHOST_KNOWN_HOSTS \ ALERT_WEBHOOK_FILE ALERT_HELPER; do value="${!name:-}" validate_absolute_path "$name" "$value" done validate_managed_directory_path AUTOMATIC_BACKUP_DIR "$AUTOMATIC_BACKUP_DIR" validate_managed_directory_path AUTOMATIC_BACKUP_STATE_DIR "$AUTOMATIC_BACKUP_STATE_DIR" [[ -d "$REMOTE_DIR" && -f "$REMOTE_ENV_FILE" && -x "$RUNNER" ]] \ || fail "deployment root, env file, or backup runner is missing" [[ -f "$POSTGRES_DATA_DIR/PG_VERSION" && -d "$STORAGE_HOST_DIR" ]] \ || fail "active data directories are invalid" [[ "$COMPOSE_PROJECT_NAME" =~ ^[a-z0-9][a-z0-9_-]*$ ]] || fail "Compose project name is invalid" [[ "$COMPOSE_FILE" =~ ^[A-Za-z0-9._/-]+$ && "$COMPOSE_FILE" != /* \ && "/$COMPOSE_FILE/" != *"/../"* && "/$COMPOSE_FILE/" != *"/./"* ]] \ || fail "Compose file must be a safe relative path" [[ "$BUILD_SHA" =~ ^[0-9a-fA-F]{40,64}$ ]] || fail "full build SHA is invalid" BUILD_SHA="${BUILD_SHA,,}" VITE_BUILD_SHA="${VITE_BUILD_SHA,,}" [[ "$VITE_BUILD_SHA" =~ ^[0-9a-f]{12}$ && "${BUILD_SHA:0:12}" == "$VITE_BUILD_SHA" ]] \ || fail "frontend build SHA must match the full build prefix" [[ "$OFFHOST_TARGET" =~ ^[a-z_][a-z0-9_-]*@[A-Za-z0-9._-]+$ ]] \ || fail "off-host target must use a safe user@alias value" [[ -f "$OFFHOST_SSH_KEY" && ! -L "$OFFHOST_SSH_KEY" \ && "$(stat -c %a "$OFFHOST_SSH_KEY")" == "600" ]] \ || fail "off-host private key must exist with mode 0600" [[ -s "$OFFHOST_KNOWN_HOSTS" ]] || fail "pinned known-hosts file 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" [[ -f "$ALERT_HELPER" && ! -L "$ALERT_HELPER" ]] || fail "alert helper is missing" [[ "$BACKUP_RUN_AT" =~ ^([01][0-9]|2[0-3]):[0-5][0-9]$ ]] || fail "backup time is invalid" [[ "$WATCHDOG_RUN_AT" =~ ^([01][0-9]|2[0-3]):[0-5][0-9]$ ]] || fail "watchdog time is invalid" [[ "$SERVICE_PREFIX" =~ ^[a-z0-9][a-z0-9_-]*$ ]] || fail "service prefix is invalid" local -a isolated_paths=( "$POSTGRES_DATA_DIR" "$STORAGE_HOST_DIR" "$AUTOMATIC_BACKUP_DIR" "$AUTOMATIC_BACKUP_STATE_DIR" ) local first_index second_index for ((first_index = 0; first_index < ${#isolated_paths[@]}; first_index += 1)); do for ((second_index = first_index + 1; second_index < ${#isolated_paths[@]}; second_index += 1)); do if paths_overlap "${isolated_paths[$first_index]}" "${isolated_paths[$second_index]}"; then fail "data, backup, and state directories must be independent" fi done done } environment_lines() { cat <"$service_file" <"$timer_file" </dev/null 2>&1 || true systemctl stop "${SERVICE_PREFIX}-${suffix}.service" >/dev/null 2>&1 || true rm -f -- "$SYSTEMD_DIR/${SERVICE_PREFIX}-${suffix}.timer" \ "$SYSTEMD_DIR/${SERVICE_PREFIX}-${suffix}.service" done systemctl daemon-reload echo "[automatic-backup-installer][PASS] units removed; backups and credentials retained" } case "$MODE" in install) install_units ;; enable) enable_units ;; uninstall|--uninstall) uninstall_units ;; --help|-h) echo "Usage: bash install-automatic-backup-timer.sh [install|enable|uninstall]" ;; *) fail "unsupported mode" ;; esac