252 lines
8.5 KiB
Bash
Executable File
252 lines
8.5 KiB
Bash
Executable File
#!/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}" <<EOF
|
||
# AWOOOI host-local offsite backup target.
|
||
# Created by configure-offsite-rclone.sh.
|
||
# No OAuth token or provider secret is stored in this file.
|
||
export OFFSITE_PROVIDER=rclone
|
||
export OFFSITE_RCLONE_REMOTE=$(quote_shell "${REMOTE_NAME}")
|
||
export OFFSITE_REMOTE_ROOT=$(quote_shell "${REMOTE_ROOT}")
|
||
export RCLONE_BWLIMIT=\${RCLONE_BWLIMIT:-8M}
|
||
export RCLONE_TRANSFERS=\${RCLONE_TRANSFERS:-2}
|
||
export RCLONE_CHECKERS=\${RCLONE_CHECKERS:-4}
|
||
export OFFSITE_RCLONE_BACKEND=\${OFFSITE_RCLONE_BACKEND:-drive}
|
||
export RCLONE_FAST_LIST=\${RCLONE_FAST_LIST:-1}
|
||
export RCLONE_DRIVE_USE_TRASH=\${RCLONE_DRIVE_USE_TRASH:-false}
|
||
export OFFSITE_SYNC_DELETE_OLD=\${OFFSITE_SYNC_DELETE_OLD:-1}
|
||
export OFFSITE_SYNC_MAX_LOAD_1=\${OFFSITE_SYNC_MAX_LOAD_1:-12}
|
||
export OFFSITE_SYNC_MAX_BACKUP_DISK_USED_PCT=\${OFFSITE_SYNC_MAX_BACKUP_DISK_USED_PCT:-92}
|
||
export OFFSITE_SYNC_REQUIRE_ENABLE_MARKER_FOR_FULL=\${OFFSITE_SYNC_REQUIRE_ENABLE_MARKER_FOR_FULL:-1}
|
||
export OFFSITE_SYNC_FULL_MIN_RUNWAY_MINUTES=\${OFFSITE_SYNC_FULL_MIN_RUNWAY_MINUTES:-270}
|
||
export OFFSITE_SYNC_NOTIFY_SKIPPED=\${OFFSITE_SYNC_NOTIFY_SKIPPED:-0}
|
||
export OFFSITE_SYNC_NOTIFY_SUCCESS=\${OFFSITE_SYNC_NOTIFY_SUCCESS:-0}
|
||
EOF
|
||
chmod 0600 "${OFFSITE_ENV_FILE}"
|
||
}
|
||
|
||
print_status() {
|
||
rclone_present && echo "RCLONE_PRESENT=1" || echo "RCLONE_PRESENT=0"
|
||
echo "OFFSITE_PROVIDER=rclone"
|
||
echo "OFFSITE_RCLONE_REMOTE=${REMOTE_NAME}"
|
||
echo "OFFSITE_REMOTE_ROOT=${REMOTE_ROOT}"
|
||
remote_configured && echo "RCLONE_REMOTE_CONFIGURED=1" || echo "RCLONE_REMOTE_CONFIGURED=0"
|
||
[ -f "${OFFSITE_ENV_FILE}" ] && echo "OFFSITE_ENV_PRESENT=1" || echo "OFFSITE_ENV_PRESENT=0"
|
||
env_mode_ok && echo "OFFSITE_ENV_MODE_OK=1" || echo "OFFSITE_ENV_MODE_OK=0"
|
||
}
|
||
|
||
root_remote_parent_path() {
|
||
if [[ "${ROOT_REMOTE_PATH}" == */* ]]; then
|
||
printf '%s' "${ROOT_REMOTE_PATH%/*}"
|
||
fi
|
||
}
|
||
|
||
root_remote_leaf_name() {
|
||
printf '%s/' "${ROOT_REMOTE_PATH##*/}"
|
||
}
|
||
|
||
create_root_scoped_remote() {
|
||
local parent_path
|
||
local parent_target
|
||
local leaf_name
|
||
local root_folder_id
|
||
local rclone_conf
|
||
|
||
if ! rclone_present; then
|
||
echo "rclone command is missing; install rclone first." >&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
|