#!/bin/bash # ============================================================================= # WOOO AIOps - Configure provider-neutral rclone offsite target # 2026-05-19 ogt + Codex: Google Drive 成為優先 offsite 目標。 # # 安全邊界: # - 這支腳本只寫 /backup/scripts/offsite.env 的 provider/remote/path 設定。 # - Google Drive OAuth token 由 rclone 自己保存在 host-local rclone.conf。 # - 不把 token、refresh token、password 或 recovery code 印到畫面。 # ============================================================================= set -euo pipefail BACKUP_BASE="${BACKUP_BASE:-/backup}" OFFSITE_ENV_FILE="${BACKUP_OFFSITE_ENV_FILE:-${BACKUP_BASE}/scripts/offsite.env}" REQUESTED_REMOTE_NAME="${OFFSITE_RCLONE_REMOTE:-}" REQUESTED_REMOTE_ROOT="${OFFSITE_REMOTE_ROOT:-}" if [ -f "${OFFSITE_ENV_FILE}" ]; then # shellcheck disable=SC1090 source "${OFFSITE_ENV_FILE}" fi REMOTE_NAME="${REQUESTED_REMOTE_NAME:-${OFFSITE_RCLONE_REMOTE:-gdrive}}" REMOTE_ROOT="${REQUESTED_REMOTE_ROOT:-${OFFSITE_REMOTE_ROOT:-${REMOTE_NAME}:awoooi-backups/restic}}" SOURCE_REMOTE="${OFFSITE_RCLONE_SOURCE_REMOTE:-gdrive}" ROOT_REMOTE_NAME="${OFFSITE_RCLONE_ROOT_REMOTE:-gdrive_awoooi_restic}" ROOT_REMOTE_PATH="${OFFSITE_RCLONE_ROOT_PATH:-awoooi-backups/restic}" MODE="status" usage() { cat <<'USAGE' Usage: configure-offsite-rclone.sh --status configure-offsite-rclone.sh --interactive OFFSITE_RCLONE_REMOTE=gdrive OFFSITE_REMOTE_ROOT=gdrive:awoooi-backups/restic configure-offsite-rclone.sh --write-from-env OFFSITE_RCLONE_SOURCE_REMOTE=gdrive OFFSITE_RCLONE_ROOT_REMOTE=gdrive_awoooi_restic configure-offsite-rclone.sh --create-root-remote Notes: - Google Drive 請先用 --interactive 進入 rclone config,建立 remote,例如 gdrive。 - --create-root-remote 會用既有 OAuth remote 建立 root-scoped remote,避免每次從整個 Drive 查找路徑。 - /backup/scripts/offsite.env 只保存 remote 名稱與路徑,不保存 OAuth token。 - rclone.conf 是 host-local secret,必須納入 credential escrow,不可進 repo。 USAGE } while [ "$#" -gt 0 ]; do case "$1" in --status) MODE="status" shift ;; --interactive) MODE="interactive" shift ;; --write-from-env) MODE="write-from-env" shift ;; --create-root-remote) MODE="create-root-remote" shift ;; -h|--help) usage exit 0 ;; *) echo "Unknown argument: $1" >&2 usage >&2 exit 2 ;; esac done quote_shell() { printf "%s" "$1" | sed "s/'/'\\\\''/g; 1s/^/'/; \$s/\$/'/" } rclone_present() { command -v rclone >/dev/null 2>&1 } remote_configured() { rclone_present || return 1 rclone listremotes 2>/dev/null | grep -Fxq "${REMOTE_NAME}:" } source_remote_configured() { rclone_present || return 1 rclone listremotes 2>/dev/null | grep -Fxq "${SOURCE_REMOTE}:" } env_mode_ok() { [ -f "${OFFSITE_ENV_FILE}" ] || return 1 mode="$(stat -c '%a' "${OFFSITE_ENV_FILE}" 2>/dev/null || stat -f '%Lp' "${OFFSITE_ENV_FILE}" 2>/dev/null || echo unknown)" [ "${mode}" = "600" ] } write_env() { install -d -m 750 "$(dirname "${OFFSITE_ENV_FILE}")" umask 077 cat > "${OFFSITE_ENV_FILE}" <&2 exit 1 fi if ! source_remote_configured; then echo "source rclone remote missing: ${SOURCE_REMOTE}:" >&2 exit 1 fi if ! command -v python3 >/dev/null 2>&1; then echo "python3 command is missing; cannot safely update rclone.conf without exposing token." >&2 exit 1 fi parent_path="$(root_remote_parent_path)" leaf_name="$(root_remote_leaf_name)" if [ -n "${parent_path}" ]; then parent_target="${SOURCE_REMOTE}:${parent_path}" else parent_target="${SOURCE_REMOTE}:" fi root_folder_id="$(rclone lsf --format pi "${parent_target}" --max-depth 1 \ | awk -F';' -v leaf="${leaf_name}" '$1 == leaf {print $2; exit}')" if [ -z "${root_folder_id}" ]; then echo "target Google Drive folder not found below ${SOURCE_REMOTE}: ${ROOT_REMOTE_PATH}" >&2 exit 1 fi rclone_conf="$(rclone config file | awk 'previous {print; exit} /Configuration file is stored at:/ {previous=1}')" if [ -z "${rclone_conf}" ] || [ ! -f "${rclone_conf}" ]; then echo "rclone config file not found" >&2 exit 1 fi SOURCE_REMOTE="${SOURCE_REMOTE}" ROOT_REMOTE_NAME="${ROOT_REMOTE_NAME}" ROOT_FOLDER_ID="${root_folder_id}" RCLONE_CONF="${rclone_conf}" python3 - <<'PY' import configparser import os path = os.environ["RCLONE_CONF"] src = os.environ["SOURCE_REMOTE"] dst = os.environ["ROOT_REMOTE_NAME"] root_id = os.environ["ROOT_FOLDER_ID"] cp = configparser.ConfigParser() cp.read(path) if not cp.has_section(src): raise SystemExit("source remote missing") if not cp.has_section(dst): cp.add_section(dst) for key, value in cp.items(src): cp.set(dst, key, value) cp.set(dst, "root_folder_id", root_id) with open(path, "w") as fh: cp.write(fh) os.chmod(path, 0o600) PY REMOTE_NAME="${ROOT_REMOTE_NAME}" REMOTE_ROOT="${ROOT_REMOTE_NAME}:" write_env echo "ROOT_SCOPED_REMOTE_READY=${ROOT_REMOTE_NAME}:" echo "ROOT_SCOPED_PATH=${ROOT_REMOTE_PATH}" print_status } case "${MODE}" in status) print_status ;; write-from-env) write_env print_status ;; create-root-remote) create_root_scoped_remote ;; interactive) if ! rclone_present; then echo "rclone command is missing; install rclone first." >&2 exit 1 fi echo "Current target remote name: ${REMOTE_NAME}" read -r -p "Google Drive rclone remote name [${REMOTE_NAME}]: " remote_input REMOTE_NAME="${remote_input:-${REMOTE_NAME}}" REMOTE_ROOT="${OFFSITE_REMOTE_ROOT:-${REMOTE_NAME}:awoooi-backups/restic}" if ! remote_configured; then echo "rclone remote ${REMOTE_NAME}: 尚未存在,接著會進入 rclone config。" echo "請選 Google Drive,完成 OAuth;不要把 token 貼到聊天或 repo。" rclone config fi read -r -p "Offsite remote root [${REMOTE_ROOT}]: " root_input REMOTE_ROOT="${root_input:-${REMOTE_ROOT}}" write_env print_status ;; esac