#!/bin/sh # ============================================================================= # install.sh - One-click Synology NAS -> Google Drive backup installer (rclone) # # Target : Synology DSM 7.x and DSM 6.2 (busybox / ash / bash), run as root # One-liner (interactive wizard, reads answers from /dev/tty): # curl -fsSL https://nas-app.derekhost.com/install.sh | sudo sh # wget -qO- https://nas-app.derekhost.com/install.sh | sudo sh # With options: ... | sudo sh -s -- --branch 05 --src "/volume1/docs" # Local file: sudo sh install.sh [options] # Optional Tailscale: ... | sudo sh -s -- --branch 03 --tailscale --exit-node --subnet-router # Health check: nas-gdrive-backup health [--json] (or install.sh --health) # Models : generic arch detection: armv7l (DS215j/DS216j), aarch64 (DS220j), # x86_64 (Plus models) ... Defaults tuned for 512MB RAM. # # Contains NO secrets. The Google credentials live only in the rclone.conf # that the admin prepares once and passes with --config. # ============================================================================= # expr is used on purpose: $((08)) fails (octal) in busybox/dash; A&&B||die is intended. # shellcheck disable=SC2015,SC2003 # ----------------------------------------------------------------------------- # BRANCH_TABLE: shop CODE -> schedule slot (start = 01:00 + slot*6 min). # Fill in when the final code list is known, one "CODE:slot" per line, e.g. # TYS:1 # TP01:2 # Codes not listed get a stable hash slot (cksum mod 20). --time HH:MM overrides. # ----------------------------------------------------------------------------- BRANCH_TABLE=' ' # Everything is inside main() so a truncated download never runs half a script. main() { set -u # stdin is the script itself when piped (curl | sh): never read from it. STDIN_TTY=0; [ -t 0 ] && STDIN_TTY=1 [ "$STDIN_TTY" -eq 1 ] || exec &2; } die() { printf '[ERROR] %s\n' "$*" >&2; exit 1; } run() { # run a command, or only print it in --dry-run if [ "$DRY_RUN" -eq 1 ]; then printf '[DRY-RUN] %s\n' "$*"; else "$@"; fi } have() { command -v "$1" >/dev/null 2>&1; } # single-quote a value for safe inclusion in a sourced shell file shq() { printf "'%s'" "$(printf '%s' "$1" | sed "s/'/'\\\\''/g")"; } usage() { cat < gdrive:/218/daily-backup/ --sa-file FILE Service-account JSON (if rclone.conf uses service_account_file) --notify-cmd CMD Command run after each backup; gets env STATUS, LOGFILE, BRANCH --use-crontab Also add entry to /etc/crontab and restart crond --upgrade Re-download latest rclone even if one is installed --dry-run Show what would be done, change nothing --check-only Only run the preflight compatibility check (no changes) --skip-test Skip connectivity test and dry-run backup test --uninstall Remove runner, settings, config, cron entry --purge With --uninstall: also remove rclone binary and logs --purge-tailscale With --uninstall: also uninstall the Tailscale package --health [--json] Run the health check of an existing install (exit 1 on FAIL) Tailscale (optional; tailnet is decided by the auth key / login account): --tailscale Install Tailscale (Synology package) --exit-node ... and advertise this NAS as an exit node --subnet-router ... and advertise the LAN subnet (auto-detected) --routes CIDR[,CIDR] Subnet(s) to advertise (implies --subnet-router) --ts-authkey-file F File containing a tskey-auth-... key (or env TS_AUTHKEY); without a key a login URL is printed --ts-hostname NAME Tailscale machine name (default nas-) --tailscale-only Only do the Tailscale part (no backup setup) --from-settings F Non-interactive re-install from an existing backup.conf (used by "nas-gdrive-backup update"; keeps rclone.conf + Tailscale) Tuning (auto from RAM; 512MB models get 2 / 4 / 8M / 8M + --use-mmap): --transfers N --checkers N --chunk-size S --buffer-size S --tpslimit N (8) --bwlimit RATE (none, e.g. "08:00,2M 19:00,off") --fast-list (off) --no-mmap --extra-flags "..." (raw extra rclone flags) -h, --help EOF } need_arg() { [ $# -ge 2 ] && [ -n "$2" ] || die "Option $1 needs a value"; } # ---- parse args ------------------------------------------------------------- [ $# -eq 0 ] && WIZARD=1 ORIG_ARGS="" for a in "$@"; do case "$a" in ''|*[!A-Za-z0-9_./:,=+@-]*) ORIG_ARGS="$ORIG_ARGS $(shq "$a")" ;; *) ORIG_ARGS="$ORIG_ARGS $a" ;; esac done while [ $# -gt 0 ]; do case "$1" in --branch) need_arg "$@"; BRANCH=$2; shift 2 ;; --src) need_arg "$@"; SRC_LIST=$2; shift 2 ;; --time) need_arg "$@"; RUN_TIME=$2; shift 2 ;; --keep-days) need_arg "$@"; KEEP_DAYS=$2; shift 2 ;; --remote-root) need_arg "$@"; REMOTE_ROOT=$2; shift 2 ;; --site-dir) need_arg "$@"; SITE_DIR_OPT=$2; shift 2 ;; --config) need_arg "$@"; CONFIG_SRC=$2; shift 2 ;; --sa-file) need_arg "$@"; SA_FILE=$2; shift 2 ;; --notify-cmd) need_arg "$@"; NOTIFY_CMD=$2; shift 2 ;; --transfers) need_arg "$@"; TRANSFERS=$2; shift 2 ;; --checkers) need_arg "$@"; CHECKERS=$2; shift 2 ;; --chunk-size) need_arg "$@"; CHUNK_SIZE=$2; shift 2 ;; --buffer-size) need_arg "$@"; BUFFER_SIZE=$2; shift 2 ;; --tpslimit) need_arg "$@"; TPSLIMIT=$2; shift 2 ;; --bwlimit) need_arg "$@"; BWLIMIT=$2; shift 2 ;; --extra-flags) need_arg "$@"; EXTRA_FLAGS=$2; shift 2 ;; --tmp) need_arg "$@"; TMP_BASE=$2; shift 2 ;; --fast-list) FAST_LIST=1; shift ;; --no-mmap) USE_MMAP=0; shift ;; --use-crontab) USE_CRONTAB=1; shift ;; --upgrade) UPGRADE=1; shift ;; --check-only) CHECK_ONLY=1; shift ;; --wizard) WIZARD=1; shift ;; --health) HEALTH=1; shift ;; --json) JSON_OUT=1; shift ;; --tailscale) TS_WANT=1; shift ;; --exit-node) TS_WANT=1; TS_EXIT=1; shift ;; --subnet-router) TS_WANT=1; TS_SUBNET=1; shift ;; --routes) need_arg "$@"; TS_WANT=1; TS_SUBNET=1; TS_ROUTES=$2; shift 2 ;; --ts-authkey-file) need_arg "$@"; TS_WANT=1; TS_KEYFILE=$2; shift 2 ;; --ts-hostname) need_arg "$@"; TS_HOSTNAME=$2; shift 2 ;; --tailscale-only) TS_WANT=1; TS_ONLY=1; shift ;; --purge-tailscale) PURGE_TS=1; shift ;; --from-settings) need_arg "$@"; FROM_SETTINGS=$2; shift 2 ;; --dry-run) DRY_RUN=1; shift ;; --skip-test) SKIP_TEST=1; shift ;; --uninstall) UNINSTALL=1; shift ;; --purge) PURGE=1; shift ;; -h|--help) usage; exit 0 ;; *) usage >&2; die "Unknown option: $1" ;; esac done # ---- re-install from existing settings (update mode) ------------------------- if [ -n "$FROM_SETTINGS" ]; then [ -r "$FROM_SETTINGS" ] || die "Settings not found: $FROM_SETTINGS" _cli_site_dir=$SITE_DIR_OPT SITE_DIR_CUSTOM="" # shellcheck disable=SC1090 . "$FROM_SETTINGS" SITE_DIR_OPT=${_cli_site_dir:-${SITE_DIR_CUSTOM:-}} CONFIG_SRC=${RCLONE_CONFIG_FILE:-} INSTALL_URL=${UPDATE_URL:-$INSTALL_URL} [ "${USE_CRONTAB:-0}" = 1 ] && USE_CRONTAB=1 || USE_CRONTAB=0 TS_WANT=${TS_WANT:-0}; TS_EXIT=${TS_EXIT:-0}; TS_ROUTES=${TS_ROUTES:-} TS_SKIP_SETUP=1 # keep Tailscale exactly as it is (no tailscale up) WIZARD=0 fi # ---- --site-dir validation ------------------------------------------------- # norm_site_dir PATH -> prints normalised PATH (trailing '/' stripped), or returns 1 norm_site_dir() { _nsd=$(printf '%s' "$1" | sed 's#/*$##') case "$1" in /*) return 1 ;; esac [ -n "$_nsd" ] || return 1 case "$_nsd" in *[!A-Za-z0-9._/-]*) return 1 ;; esac case "/$_nsd/" in */../*|*/./*|*//*) return 1 ;; esac printf '%s' "$_nsd" } if [ -n "$SITE_DIR_OPT" ]; then _sdn=$(norm_site_dir "$SITE_DIR_OPT") || die "--site-dir '$SITE_DIR_OPT' invalid: must be a relative path (no leading '/', no '..' / '.' / empty segments) using only A-Z a-z 0-9 . _ - / (e.g. 218/daily-backup)" SITE_DIR_OPT=$_sdn fi # ---- environment checks ----------------------------------------------------- if [ -z "$ROOT" ] && [ "$(id -u)" != "0" ]; then if [ -n "$ORIG_ARGS" ]; then _pass=" -s --$ORIG_ARGS"; else _pass=""; fi cat >&2 </dev/null 2>&1; then info "crond restarted (synosystemctl, DSM 7)"; return 0; fi if have synoservicectl && synoservicectl --restart crond >/dev/null 2>&1; then info "crond restarted (synoservicectl, DSM 6)"; return 0; fi if have synoservice && synoservice -restart crond >/dev/null 2>&1; then info "crond restarted (synoservice, DSM 6)"; return 0; fi if have systemctl && systemctl restart crond >/dev/null 2>&1; then info "crond restarted (systemctl)"; return 0; fi warn "Could not restart crond automatically - reboot NAS or restart crond manually." return 1 } remove_cron_entry() { [ -f "$CRONTAB" ] || return 0 grep -q "$APP" "$CRONTAB" || return 0 if [ "$DRY_RUN" -eq 1 ]; then say "[DRY-RUN] remove $APP line(s) from $CRONTAB"; return 0; fi tmp="$CRONTAB.ngb.$$" grep -v "$APP" "$CRONTAB" > "$tmp" # exit 1 if nothing left - that's fine cat "$tmp" > "$CRONTAB" # keep inode/perms rm -f "$tmp" info "Removed cron entry from $CRONTAB" CRON_CHANGED=1 } find_rclone() { for b in "$ROOT/usr/local/bin/rclone" "$ROOT/opt/rclone/rclone"; do if [ -x "$b" ] && "$b" version >/dev/null 2>&1; then printf '%s' "$b"; return 0; fi done return 1 } detect_arch() { m=$(uname -m) case "$m" in x86_64|amd64) echo amd64 ;; aarch64|arm64|armv8*) echo arm64 ;; armv7*) echo arm-v7 ;; armv6*) echo arm-v6 ;; armv5*|arm) echo arm ;; i386|i486|i586|i686) echo 386 ;; *) return 1 ;; esac } fetch() { # fetch URL OUTFILE if have curl; then curl -fsSL --retry 3 --connect-timeout 20 -o "$2" "$1" elif have wget; then wget -q -T 30 -O "$2" "$1" else die "Neither curl nor wget found"; fi } extract_zip() { # extract_zip ZIP DIR if have unzip && unzip -q -o "$1" -d "$2" 2>/dev/null; then return 0; fi if have 7z && 7z x -y -o"$2" "$1" >/dev/null 2>&1; then return 0; fi if have busybox && busybox unzip -q -o "$1" -d "$2" 2>/dev/null; then return 0; fi if have python3 && python3 -c 'import sys,zipfile; zipfile.ZipFile(sys.argv[1]).extractall(sys.argv[2])' "$1" "$2"; then return 0; fi return 1 } # ---- preflight compatibility check ----------------------------------------- PF_FAIL=0 pf_ok() { printf ' [ OK ] %s\n' "$*"; } pf_warn() { printf ' [WARN] %s\n' "$*"; } pf_fail() { printf ' [FAIL] %s\n' "$*"; PF_FAIL=1; } kv() { # kv FILE KEY -> value of KEY="value" [ -r "$1" ] && sed -n "s/^$2=\"\{0,1\}\([^\"]*\)\"\{0,1\}.*/\1/p" "$1" | head -n 1 } free_mb() { # free MB of filesystem holding $1 (walks up to an existing dir) d=$1; while [ ! -d "$d" ] && [ "$d" != "/" ]; do d=$(dirname "$d"); done df -k "$d" 2>/dev/null | awk 'NR>1 && NF>=4 {v=$(NF-2)} END {if (v!="") print int(v/1024)}' } https_probe() { # https_probe URL -> prints HTTP code (000 = no TLS/connection) if have curl; then curl -sS -o /dev/null -m 20 -w '%{http_code}' "$1" 2>"$TMP_BASE/ngb-pf.$$" || true elif have wget; then if wget -q -T 20 -O /dev/null "$1" 2>"$TMP_BASE/ngb-pf.$$"; then echo 200 elif grep -qi 'certificate\|ssl\|tls' "$TMP_BASE/ngb-pf.$$" 2>/dev/null; then echo 000 else echo 4xx; fi # busybox wget errors on 404 but TLS worked else echo 000; fi } http_date_epoch() { # HTTP Date header of URL -> epoch seconds (portable awk) have curl || return 1 curl -sSI -m 20 "$1" 2>/dev/null | tr -d '\r' | awk ' tolower($1)=="date:" { split("Jan Feb Mar Apr May Jun Jul Aug Sep Oct Nov Dec", M, " ") for (i = 1; i <= 12; i++) if (M[i] == $4) mo = i d = $3 + 0; y = $5 + 0; split($6, t, ":") if (mo <= 2) { y -= 1; mm = mo + 9 } else mm = mo - 3 era = int(y / 400); yoe = y - era * 400 doy = int((153 * mm + 2) / 5) + d - 1 doe = yoe * 365 + int(yoe / 4) - int(yoe / 100) + doy days = era * 146097 + doe - 719468 print days * 86400 + t[1] * 3600 + t[2] * 60 + t[3]; exit }' } RAM_MB=0 preflight() { say "=== Preflight compatibility check ===" MODEL=$(kv /etc/synoinfo.conf upnpmodelname) UNIQUE=$(kv /etc/synoinfo.conf unique) HWVER=$(cat /proc/sys/kernel/syno_hw_version 2>/dev/null) if [ -n "$MODEL$UNIQUE$HWVER" ]; then pf_ok "Model: ${MODEL:-?} (unique=${UNIQUE:-?}, hw=${HWVER:-?})" else pf_warn "Model: not a Synology NAS (no /etc/synoinfo.conf) - generic Linux mode" fi PV=$(kv /etc.defaults/VERSION productversion) BN=$(kv /etc.defaults/VERSION buildnumber) MAJ=$(kv /etc.defaults/VERSION majorversion) MIN=$(kv /etc.defaults/VERSION minorversion) if [ -n "$MAJ" ]; then if [ "$MAJ" -ge 7 ] || { [ "$MAJ" -eq 6 ] && [ "${MIN:-0}" -ge 2 ]; }; then pf_ok "DSM ${PV:-$MAJ.$MIN} build ${BN:-?}" [ "$MAJ" -eq 6 ] && pf_warn "DSM 6.2 is end-of-life (no security updates) - supported by this script, but plan upgrade/replacement" else pf_fail "DSM ${PV:-$MAJ.$MIN}: need DSM 6.2 or 7.x" fi else pf_warn "DSM version: unknown (/etc.defaults/VERSION missing)" fi if A=$(detect_arch); then case "$A" in arm-v7|arm64|amd64) pf_ok "CPU arch: $(uname -m) -> rclone linux-$A" ;; *) pf_warn "CPU arch: $(uname -m) -> rclone linux-$A (not a tested target)" ;; esac else pf_fail "CPU arch: $(uname -m) is not supported by rclone builds" fi RAM_MB=${NGB_RAM_MB:-$(awk '/^MemTotal:/ {print int($2/1024)}' /proc/meminfo 2>/dev/null)} RAM_MB=${RAM_MB:-0} if [ "$RAM_MB" -lt 200 ]; then pf_fail "RAM: ${RAM_MB} MB (too little for rclone)" elif [ "$RAM_MB" -le 1024 ]; then pf_ok "RAM: ${RAM_MB} MB -> low-memory profile" else pf_ok "RAM: ${RAM_MB} MB"; fi F1=$(free_mb "$ROOT/usr/local"); F2=$(free_mb "$TMP_BASE") if [ -z "$F1" ]; then pf_warn "Free space /usr/local: unknown" elif [ "$F1" -lt 100 ]; then pf_fail "Free space /usr/local: ${F1} MB (need >= 100 MB; system partition full?)" else pf_ok "Free space /usr/local: ${F1} MB"; fi if [ -z "$F2" ]; then pf_warn "Free space $TMP_BASE: unknown" elif [ "$F2" -lt 150 ]; then pf_fail "Free space $TMP_BASE: ${F2} MB (need >= 150 MB; use --tmp /volume1//tmp)" else pf_ok "Free space $TMP_BASE: ${F2} MB"; fi if ! have curl && ! have wget; then pf_fail "Neither curl nor wget present"; fi CA="" for c in /etc/ssl/certs/ca-certificates.crt /etc/ssl/cert.pem /etc/pki/tls/certs/ca-bundle.crt; do [ -s "$c" ] && { CA=$c; break; } done [ -n "$CA" ] && pf_ok "CA bundle: $CA" || pf_warn "CA bundle: not found in usual paths (TLS may fail)" for u in https://downloads.rclone.org/version.txt https://www.googleapis.com/discovery/v1/apis; do code=$(https_probe "$u") case "$code" in 000|"") pf_fail "HTTPS $u -> FAILED: $(tail -n 1 "$TMP_BASE/ngb-pf.$$" 2>/dev/null) (check DNS/firewall/proxy/CA certs/clock)" ;; *) pf_ok "HTTPS $(echo "$u" | cut -d/ -f3) reachable (HTTP $code, TLS OK)" ;; esac done rm -f "$TMP_BASE/ngb-pf.$$" if REMOTE_NOW=$(http_date_epoch https://www.googleapis.com/) && [ -n "$REMOTE_NOW" ]; then SKEW=$(( $(date +%s) - REMOTE_NOW )); [ "$SKEW" -lt 0 ] && SKEW=$((0 - SKEW)) if [ "$SKEW" -gt 300 ]; then pf_fail "Clock skew ${SKEW}s vs Google (>300s breaks OAuth/TLS) - enable NTP: Control Panel > Regional Options > Time" elif [ "$SKEW" -gt 60 ]; then pf_warn "Clock skew ${SKEW}s vs Google - enable NTP sync" else pf_ok "Clock skew ${SKEW}s vs Google"; fi else pf_warn "Clock skew: could not determine (no curl or no Date header)" fi if have flock; then pf_ok "flock available"; else pf_warn "flock missing - runner will use mkdir lock"; fi have unzip || have 7z || have python3 || { have busybox && busybox unzip >/dev/null 2>&1; } || pf_warn "No unzip/7z/python3 found - rclone zip extraction may fail" ts_preflight if [ "$PF_FAIL" -eq 0 ]; then say "=== Preflight: PASS ==="; return 0; fi say "=== Preflight: FAIL ==="; return 1 } autotune() { # fill unset tuning values from RAM size if [ "$RAM_MB" -gt 0 ] && [ "$RAM_MB" -le 1024 ]; then t=2; c=4; k=8M; b=8M elif [ "$RAM_MB" -gt 0 ] && [ "$RAM_MB" -le 4096 ]; then t=3; c=6; k=16M; b=16M elif [ "$RAM_MB" -gt 0 ]; then t=4; c=8; k=32M; b=16M else t=2; c=4; k=8M; b=8M; fi TRANSFERS=${TRANSFERS:-$t}; CHECKERS=${CHECKERS:-$c} CHUNK_SIZE=${CHUNK_SIZE:-$k}; BUFFER_SIZE=${BUFFER_SIZE:-$b} info "Tuning: transfers=$TRANSFERS checkers=$CHECKERS drive-chunk-size=$CHUNK_SIZE buffer-size=$BUFFER_SIZE mmap=$USE_MMAP fast-list=$FAST_LIST" } # ---- Tailscale (optional) --------------------------------------------------- TS_BIN="$ROOT/var/packages/Tailscale/target/bin/tailscale" TS_BOOT="$ROOT/usr/local/bin/nas-tailscale-boot.sh" ts_installed() { [ -d "$ROOT/var/packages/Tailscale" ] && [ -x "$TS_BIN" ]; } ts_platform() { kv /etc/synoinfo.conf unique | cut -d_ -f2; } # synology__ ts_arch_key() { case "$(uname -m)" in x86_64|amd64) echo x86_64 ;; aarch64|arm64|armv8*) echo armv8 ;; armv7*) echo armv7 ;; i?86) echo i686 ;; armv5*) echo armv5 ;; *) return 1 ;; esac } ts_spk_name() { # matching .spk file name on pkgs.tailscale.com for this NAS _dsm=dsm7; [ -n "$DSM_MAJOR" ] && [ "$DSM_MAJOR" -lt 7 ] && _dsm=dsm6 _json=$(fetch "https://pkgs.tailscale.com/stable/?mode=json" /dev/stdout 2>/dev/null) || return 1 _blk=$(printf '%s\n' "$_json" | awk -v d="\"$_dsm\":" '$1==d {f=1; next} f && /}/ {exit} f {print}') for _k in "$(ts_platform)" "$(ts_arch_key)"; do [ -n "$_k" ] || continue _f=$(printf '%s\n' "$_blk" | sed -n "s/^[[:space:]]*\"$_k\":[[:space:]]*\"\([^\"]*\.spk\)\".*/\1/p" | head -n 1) [ -n "$_f" ] && { printf '%s' "$_f"; return 0; } done return 1 } mask2prefix() { # 255.255.255.0 -> 24 _p=0; _o=$IFS; IFS=. for _x in $1; do case "$_x" in 255) _p=$((_p+8)) ;; 254) _p=$((_p+7)) ;; 252) _p=$((_p+6)) ;; 248) _p=$((_p+5)) ;; 240) _p=$((_p+4)) ;; 224) _p=$((_p+3)) ;; 192) _p=$((_p+2)) ;; 128) _p=$((_p+1)) ;; esac done IFS=$_o; printf '%s' "$_p" } cidr_net() { # 192.168.1.10/24 -> 192.168.1.0/24 (per-octet math, no 32-bit overflow) _ip=${1%/*}; _pl=${1#*/}; _out=""; _i=0; _o=$IFS; IFS=. for _x in $_ip; do _b=$((_pl - _i * 8)); [ "$_b" -gt 8 ] && _b=8; [ "$_b" -lt 0 ] && _b=0 _m=$(( (255 << (8 - _b)) & 255 )) _out="$_out${_out:+.}$(( _x & _m ))"; _i=$((_i + 1)) done IFS=$_o; printf '%s/%s' "$_out" "$_pl" } detect_lan_cidr() { # LAN subnet of the default-route interface _dev=""; _c="" if have ip; then _dev=$(ip -4 route show default 2>/dev/null | awk '{for (i=1;i/dev/null | awk '{for (i=1;i/dev/null | awk '$1=="0.0.0.0" {print $NF; exit}') if [ -n "$_dev" ]; then _l=$(ifconfig "$_dev" 2>/dev/null | grep 'inet ' | head -n 1) _a=$(printf '%s' "$_l" | sed -n 's/.*inet \(addr:\)\{0,1\}\([0-9.]*\).*/\2/p') _mk=$(printf '%s' "$_l" | sed -n 's/.*[Mm]ask[: ]\{1,\}\([0-9.]*\).*/\1/p') [ -n "$_a" ] && [ -n "$_mk" ] && _c="$_a/$(mask2prefix "$_mk")" fi fi [ -n "$_c" ] || return 1 cidr_net "$_c" } valid_cidrs() { # comma list of IPv4/IPv6 CIDRs (basic syntax check) [ -n "$1" ] || return 1 _o=$IFS; IFS=, for _x in $1; do case "$_x" in */*) : ;; *) IFS=$_o; return 1 ;; esac case "$_x" in *[!0-9a-fA-F.:/]*) IFS=$_o; return 1 ;; esac done IFS=$_o } common_cidr() { # any route in list is a common home/router default? _o=$IFS; IFS=, for _x in $1; do case "$_x" in 192.168.0.0/24|192.168.1.0/24|10.0.0.0/24) IFS=$_o; return 0 ;; esac done IFS=$_o; return 1 } overlap_warning() { cat <.0/24),或者用 Tailscale 4via6 (tailscale debug via <分行編號> $1)。 EOF } ts_preflight() { if ts_installed; then pf_ok "Tailscale: installed ($("$TS_BIN" version 2>/dev/null | head -n 1))" elif [ -n "$DSM_MAJOR" ]; then if _s=$(ts_spk_name); then pf_ok "Tailscale: not installed; package available: $_s" elif [ "$TS_WANT" -eq 1 ]; then pf_fail "Tailscale: no package for $(ts_platform)/$(uname -m) DSM $DSM_MAJOR" else pf_warn "Tailscale: package lookup failed (only needed with --tailscale)"; fi else if [ "$TS_WANT" -eq 1 ] && [ -z "$ROOT" ]; then pf_fail "Tailscale: needs Synology DSM (package install)" else pf_warn "Tailscale: n/a (not DSM)"; fi fi if _lan=$(detect_lan_cidr); then pf_ok "LAN subnet: $_lan" if common_cidr "$_lan"; then overlap_warning "$_lan"; fi else pf_warn "LAN subnet: could not detect (use --routes CIDR)" fi } ts_install_pkg() { if ts_installed; then info "Tailscale already installed: $("$TS_BIN" version 2>/dev/null | head -n 1)"; return 0; fi if [ -n "$ROOT" ] || [ "$DRY_RUN" -eq 1 ]; then say "[DRY-RUN] synopkg install_from_server Tailscale || download $(ts_spk_name 2>/dev/null || echo '') + synopkg install" return 0 fi [ -n "$DSM_MAJOR" ] || { warn "Not Synology DSM - Tailscale package install skipped"; return 1; } have synopkg || { warn "synopkg not found"; return 1; } info "Installing Tailscale from Synology Package Center ..." if synopkg install_from_server Tailscale >/dev/null 2>&1 && ts_installed; then info "Installed from Package Center" else _spk=$(ts_spk_name) || { warn "No Tailscale .spk for $(ts_platform)/$(uname -m) DSM $DSM_MAJOR"; return 1; } _w=$(mktemp -d "$TMP_BASE/ngb-ts.XXXXXX") || return 1 _u="https://pkgs.tailscale.com/stable/$_spk" info "Downloading $_u" fetch "$_u" "$_w/$_spk" || { rm -rf "$_w"; warn "Download failed: $_u"; return 1; } _want=$(fetch "$_u.sha256" /dev/stdout 2>/dev/null | awk '{print $1}') if [ -n "$_want" ] && have sha256sum; then [ "$_want" = "$(sha256sum "$_w/$_spk" | awk '{print $1}')" ] || { rm -rf "$_w"; warn "SHA256 mismatch for $_spk"; return 1; } info "SHA256 verified" else warn "SHA256 not verified" fi if ! synopkg install "$_w/$_spk" >/dev/null 2>&1; then rm -rf "$_w" warn "synopkg install failed - install manually: Package Center > Manual Install > $_u" return 1 fi rm -rf "$_w" fi synopkg start Tailscale >/dev/null 2>&1 || true ts_installed || { warn "Tailscale binary not found after install"; return 1; } info "Tailscale installed: $("$TS_BIN" version 2>/dev/null | head -n 1)" } write_ts_boot() { cat <<'BOOT_EOF' #!/bin/sh # nas-tailscale-boot.sh - generated by install.sh. Run at DSM Boot-up (root): # enables IP forwarding (exit node / subnet router) and, on DSM 7, lets the # Tailscale package create its TUN device (tailscale configure-host). TS=/var/packages/Tailscale/target/bin/tailscale [ -x "$TS" ] || exit 0 echo 1 > /proc/sys/net/ipv4/ip_forward [ -w /proc/sys/net/ipv6/conf/all/forwarding ] && echo 1 > /proc/sys/net/ipv6/conf/all/forwarding MAJ=$(sed -n 's/^majorversion="\{0,1\}\([0-9]*\).*/\1/p' /etc.defaults/VERSION 2>/dev/null) if [ "${MAJ:-7}" -ge 7 ]; then "$TS" configure-host synosystemctl restart pkgctl-Tailscale.service 2>/dev/null || { synopkg stop Tailscale; synopkg start Tailscale; } fi exit 0 BOOT_EOF } ts_setup() { say "" say "=== Tailscale ===" ts_install_pkg || { warn "Tailscale step skipped"; return 1; } _host=${TS_HOSTNAME:-nas-${SITE_NAME:-$SITE_DIR}} _host=$(printf '%s' "$_host" | tr 'A-Z_' 'a-z-' | tr -cd 'a-z0-9-') if [ "$TS_SUBNET" -eq 1 ] && [ -z "$TS_ROUTES" ]; then TS_ROUTES=$(detect_lan_cidr) || { warn "Cannot detect LAN subnet - re-run with --routes CIDR"; return 1; } info "Detected LAN subnet: $TS_ROUTES" fi _adv="" [ "$TS_EXIT" -eq 1 ] && _adv="$_adv exit-node" [ -n "$TS_ROUTES" ] && _adv="$_adv routes=$TS_ROUTES" if [ -n "$_adv" ]; then info "Enabling IP forwarding + TUN (configure-host) via $TS_BOOT" if [ "$DRY_RUN" -eq 0 ]; then mkdir -p "$(dirname "$TS_BOOT")" write_ts_boot > "$TS_BOOT" && chmod 700 "$TS_BOOT" if [ -z "$ROOT" ]; then sh "$TS_BOOT" >/dev/null 2>&1 || warn "boot helper returned an error"; sleep 3; fi fi fi # key via file:PATH so it never appears in argv / ps / logs _kw=""; _kf="" if [ -n "$TS_KEYFILE" ]; then _kf=$TS_KEYFILE elif [ -n "$TS_KEY_VALUE" ]; then _kw=$(mktemp -d "$TMP_BASE/ngb-tskey.XXXXXX") || return 1 chmod 700 "$_kw"; ( umask 077; printf '%s\n' "$TS_KEY_VALUE" > "$_kw/key" ); _kf="$_kw/key" fi set -- --hostname="$_host" [ "$TS_EXIT" -eq 1 ] && set -- "$@" --advertise-exit-node [ -n "$TS_ROUTES" ] && set -- "$@" --advertise-routes="$TS_ROUTES" _state="" [ -x "$TS_BIN" ] && _state=$("$TS_BIN" status --json 2>/dev/null | sed -n 's/^[[:space:]]*"BackendState":[[:space:]]*"\([^"]*\)".*/\1/p' | head -n 1) if [ -n "$ROOT" ] || [ "$DRY_RUN" -eq 1 ]; then _verb="up --reset"; [ "$_state" = Running ] && _verb="set" say "[DRY-RUN] tailscale $_verb $*${_kf:+ --authkey=file:}" elif [ "$_state" = "Running" ]; then # already logged in: 'tailscale set' changes only these prefs, keeps the rest if "$TS_BIN" set "$@" 2>/dev/null; then info "tailscale set $*" else "$TS_BIN" up --reset "$@" && info "tailscale up --reset $*"; fi elif [ -n "$_kf" ]; then if "$TS_BIN" up --reset "$@" --authkey="file:$_kf" >/dev/null 2>&1; then info "Logged in with auth key: tailscale up $*" else warn "tailscale up with auth key failed (expired / wrong key?)"; fi else _uw=$(mktemp -d "$TMP_BASE/ngb-tsup.XXXXXX") || return 1 nohup "$TS_BIN" up --reset "$@" > "$_uw/up.out" 2>&1 & _i=0 while [ "$_i" -lt 30 ] && ! grep -q 'https://' "$_uw/up.out" 2>/dev/null; do sleep 1; _i=$((_i + 1)); done _url=$(grep -o 'https://[^ ]*' "$_uw/up.out" 2>/dev/null | head -n 1) if [ -n "$_url" ]; then say " >>> 用瀏覽器開呢個網址登入 Tailscale (open to log in):" say " $_url" else warn "No login URL yet - run: $TS_BIN up --reset $*" fi fi [ -n "$_kw" ] && rm -rf "$_kw" TS_KEY_VALUE="" say " Hostname : $_host" if [ -n "$_adv" ]; then cat < $_host > Edit route settings > 剔 Use as exit node / subnet routes。或者喺 ACL 設 autoApprovers, 例如: "autoApprovers": { "exitNode": ["tag:nas"], "routes": { "${TS_ROUTES:-10.10.3.0/24}": ["tag:nas"] } } (auth key 要帶 tag:nas;伺服器建議 Disable key expiry) EOF fi if [ -n "$TS_ROUTES" ] && common_cidr "$TS_ROUTES"; then overlap_warning "$TS_ROUTES"; fi if [ -n "$_adv" ] && [ "${DSM_MAJOR:-7}" -ge 7 ]; then cat < 任務排程表 > 新增 > 觸發的任務 > 使用者定義的指令碼 使用者 root,事件「開機」,指令碼: $TS_BOOT (Tailscale 套件升級後亦要重跑一次呢個指令碼或者重新開機) EOF fi return 0 } if [ "$CHECK_ONLY" -eq 1 ]; then preflight; rc=$?; autotune; exit "$rc" fi # ---- health check of an existing install ------------------------------------ if [ "$HEALTH" -eq 1 ]; then [ -x "$RUNNER" ] || die "Not installed yet ($RUNNER missing) - run the installer first" if [ "$JSON_OUT" -eq 1 ]; then exec "$RUNNER" health --json; else exec "$RUNNER" health; fi fi # ---- rclone install --------------------------------------------------------- install_rclone() { ARCH=$(detect_arch) || die "Unsupported CPU architecture: $(uname -m)" info "CPU: $(uname -m) -> rclone build linux-$ARCH" if [ "$DRY_RUN" -eq 1 ]; then say "[DRY-RUN] download $RCLONE_BASE_URL//rclone--linux-$ARCH.zip, verify SHA256, install" return 0 fi work="$TMP_BASE/ngb-rclone.$$" rm -rf "$work"; mkdir -p "$work" || die "Cannot create temp dir $work (use --tmp /volume1/somewhere)" ver=$(fetch "$RCLONE_BASE_URL/version.txt" /dev/stdout 2>/dev/null | sed -n 's/^rclone \(v[0-9][0-9.]*\).*/\1/p' | head -n 1) if [ -n "$ver" ]; then zipname="rclone-$ver-linux-$ARCH.zip" url="$RCLONE_BASE_URL/$ver/$zipname" else warn "Could not read current version; falling back to rclone-current zip (no checksum)" zipname="rclone-current-linux-$ARCH.zip" url="$RCLONE_BASE_URL/$zipname" fi info "Downloading $url" fetch "$url" "$work/$zipname" || { rm -rf "$work"; die "Download failed: $url"; } if [ -n "$ver" ]; then if fetch "$RCLONE_BASE_URL/$ver/SHA256SUMS" "$work/SHA256SUMS" 2>/dev/null && have sha256sum; then want=$(grep " $zipname\$" "$work/SHA256SUMS" | awk '{print $1}' | head -n 1) got=$(sha256sum "$work/$zipname" | awk '{print $1}') [ -n "$want" ] && [ "$want" = "$got" ] || { rm -rf "$work"; die "SHA256 mismatch for $zipname"; } info "SHA256 verified" else warn "SHA256 not verified (SHA256SUMS or sha256sum unavailable)" fi fi extract_zip "$work/$zipname" "$work/x" || { rm -rf "$work"; die "Cannot unzip (need unzip, 7z, busybox unzip or python3)"; } newbin=$(find "$work/x" -type f -name rclone | head -n 1) [ -n "$newbin" ] || { rm -rf "$work"; die "rclone binary not found in zip"; } chmod 755 "$newbin" "$newbin" version >/dev/null 2>&1 || { rm -rf "$work"; die "Downloaded rclone does not run on this CPU ($(uname -m))"; } destdir="$ROOT/usr/local/bin" mkdir -p "$destdir" 2>/dev/null if ! { [ -d "$destdir" ] && touch "$destdir/.ngb_w" 2>/dev/null && rm -f "$destdir/.ngb_w"; }; then destdir="$ROOT/opt/rclone"; mkdir -p "$destdir" || { rm -rf "$work"; die "Cannot create $destdir"; } fi cp "$newbin" "$destdir/rclone.new" && chmod 755 "$destdir/rclone.new" && mv -f "$destdir/rclone.new" "$destdir/rclone" \ || { rm -rf "$work"; die "Cannot install rclone to $destdir"; } rm -rf "$work" RCLONE_BIN="$destdir/rclone" info "Installed $("$RCLONE_BIN" version 2>/dev/null | head -n 1) -> $RCLONE_BIN" } RCLONE_BIN="" ensure_rclone() { if [ "$UPGRADE" -eq 0 ] && RCLONE_BIN=$(find_rclone); then info "rclone already installed and working: $RCLONE_BIN ($("$RCLONE_BIN" version 2>/dev/null | head -n 1)) - skip (use --upgrade to update)" else install_rclone [ -n "$RCLONE_BIN" ] || RCLONE_BIN="$ROOT/usr/local/bin/rclone" UPGRADE=0 fi } # rclone.conf check print_conf_help() { cat >&2 <<'EOF' The shared rclone config (remote name MUST be "gdrive") is missing. Create it ONCE on the admin Windows PC (see README-zh-HK.md Part 1): 1) Google Cloud Console: own OAuth client (Desktop app), Drive API enabled, consent screen PUBLISHED to Production (Testing = token expires in 7 days) 2) rclone config -> n) new remote "gdrive" -> drive -> client_id/secret -> scope 1 (drive) -> auto config: y -> sign in with the backup Gmail 3) rclone config file (shows path) -> copy rclone.conf to the NAS by USB / SCP (never by chat / plain email - it contains the Drive token) Optional (Google Workspace only): Shared Drive + service account (service_account_file + team_drive; pass the JSON with --sa-file). Then run e.g.: curl -fsSL https://nas-app.derekhost.com/install.sh | sudo sh (or: ... | sudo sh -s -- --branch 03 --src "/volume1/share1" --config /volume1/admin-tools/rclone.conf) EOF } check_config() { [ -n "$CONFIG_SRC" ] || { print_conf_help; die "--config is required"; } [ -f "$CONFIG_SRC" ] || { print_conf_help; die "Config file not found: $CONFIG_SRC"; } grep -q '^[[:space:]]*\[gdrive\][[:space:]]*$' "$CONFIG_SRC" || { print_conf_help; die "Config has no [gdrive] remote: $CONFIG_SRC"; } # service account file referenced by the conf? SA_REF=$(awk ' /^[[:space:]]*\[/ { insec = ($0 ~ /^[[:space:]]*\[gdrive\][[:space:]]*$/) ; next } insec && /^[[:space:]]*service_account_file[[:space:]]*=/ { sub(/^[^=]*=[[:space:]]*/, ""); sub(/[[:space:]]*$/, ""); print; exit } ' "$CONFIG_SRC") if [ -n "$SA_REF" ]; then if [ -z "$SA_FILE" ]; then if [ -f "$SA_REF" ]; then SA_FILE=$SA_REF elif [ -f "$(dirname "$CONFIG_SRC")/$(basename "$SA_REF")" ]; then SA_FILE="$(dirname "$CONFIG_SRC")/$(basename "$SA_REF")" else die "rclone.conf uses service_account_file ($SA_REF) - pass the JSON with --sa-file /path/sa.json"; fi fi [ -f "$SA_FILE" ] || die "Service account file not found: $SA_FILE" fi } # ---- uninstall -------------------------------------------------------------- if [ "$UNINSTALL" -eq 1 ]; then CRON_CHANGED=0 info "Uninstalling $APP ..." remove_cron_entry [ "$CRON_CHANGED" -eq 1 ] && restart_crond run rm -f "$RUNNER" "${RUNNER%.sh}" if [ "$PURGE_TS" -eq 1 ]; then if [ -n "$ROOT" ]; then say "[DRY-RUN] synopkg uninstall Tailscale" elif have synopkg && [ -d /var/packages/Tailscale ]; then run synopkg uninstall Tailscale >/dev/null 2>&1 && info "Tailscale package uninstalled" fi run rm -f "$TS_BOOT" say " (remove the Boot-up task for nas-tailscale-boot.sh in DSM Task Scheduler; delete the machine in the Tailscale admin console)" elif [ -d "$ROOT/var/packages/Tailscale" ]; then info "Tailscale package kept (use --purge-tailscale to remove it)" fi run rm -rf "$ETC_DIR" run rm -rf "$LOCK_BASE.lock" "$LOCK_BASE.lock.d" if [ "$PURGE" -eq 1 ]; then run rm -f "$ROOT/usr/local/bin/rclone" run rm -rf "$ROOT/opt/rclone" run rm -rf "$LOG_DIR" info "Purged rclone binary and logs." else info "rclone binary and logs ($LOG_DIR) kept (use --purge to remove)." fi say "" say "Done. If you created a DSM Task Scheduler task, delete it in:" say " Control Panel > Task Scheduler > (task NAS-GDrive-Backup-...) > Delete" say "Backup data on Google Drive is NOT touched." exit 0 fi # ---- site name / schedule helpers ------------------------------------------ # parse_site VALUE -> BRANCH (display), SITE_DIR (Drive folder / logs), SITE_NAME, SITE_SLOT (stagger) # (SITE_DIR is replaced by --site-dir afterwards when given; SITE_NAME never is) # 1-15 -> branch-NN (legacy numeric, slot = number) # shop code -> [A-Za-z0-9_-]{1,16}, upper-cased, e.g. TYS, TP01 table_slot() { printf '%s\n' "$BRANCH_TABLE" | sed -n "s/^[[:space:]]*$1:[[:space:]]*\([0-9][0-9]*\).*/\1/p" | head -n 1; } parse_site() { case "$1" in [0-9]|[0-9][0-9]) n=$(expr "$1" + 0) if [ "$n" -ge 1 ] && [ "$n" -le 15 ]; then BRANCH=$(printf '%02d' "$n"); SITE_DIR="branch-$BRANCH"; SITE_NAME=$SITE_DIR; SITE_SLOT=$n; return 0 fi ;; esac case "$1" in ''|[-_]*|*[!A-Za-z0-9_-]*) return 1 ;; esac [ ${#1} -le 16 ] || return 1 BRANCH=$(printf '%s' "$1" | tr '[:lower:]' '[:upper:]'); SITE_DIR=$BRANCH; SITE_NAME=$BRANCH SITE_SLOT=$(table_slot "$BRANCH") [ -n "$SITE_SLOT" ] || SITE_SLOT=$(( $(printf '%s' "$BRANCH" | cksum | cut -d' ' -f1) % 20 )) } default_time() { m=$((60 + SITE_SLOT * 6)); printf '%02d:%02d' $(((m / 60) % 24)) $((m % 60)); } valid_time() { case "$1" in [0-2][0-9]:[0-5][0-9]) : ;; *) return 1 ;; esac [ "$(expr "${1%%:*}" + 0)" -le 23 ] } # ---- interactive wizard (plain sh read; no dialog/whiptail on DSM) ---------- ask() { # ask VAR "prompt" "default" if [ -n "$3" ]; then printf ' %s [%s]: ' "$2" "$3"; else printf ' %s: ' "$2"; fi IFS= read -r _a <&3 || { echo; die "Input ended - aborted"; } _a=$(printf '%s' "$_a" | sed 's/^[[:space:]]*//; s/[[:space:]]*$//') [ -n "$_a" ] || _a=$3 eval "$1=\$_a" } step() { printf '\n\033[1m[%s/7] %s\033[0m\n' "$1" "$2"; } yn_default() { if [ "$1" -eq 1 ]; then echo Y; else echo N; fi; } is_yes() { case "$1" in y|Y|yes|YES|Yes) return 0 ;; *) return 1 ;; esac; } list_shares() { # print candidate shared folders, one per line for v in ${NGB_VOLUME_GLOB:-/volume[0-9]*}; do [ -d "$v" ] || continue for d in "$v"/*; do [ -d "$d" ] || continue b=$(basename "$d") case "$b" in @*|\#*|.*|lost+found|aquota.*|admin-tools) continue ;; esac printf '%s\n' "$d" done done } find_default_conf() { # first rclone.conf found in common upload spots for pat in admin-tools/rclone.conf public/rclone.conf 'homes/*/rclone.conf'; do for v in ${NGB_VOLUME_GLOB:-/volume[0-9]*}; do for c in "$v"/$pat; do [ -f "$c" ] && { printf '%s' "$c"; return 0; } done done done for c in "$CONF_DEST" /root/rclone.conf; do [ -f "$c" ] && { printf '%s' "$c"; return 0; } done return 1 } open_tty() { # fd 3 = where the wizard reads answers from if [ "$STDIN_TTY" -eq 1 ]; then exec 3<&0; return 0; fi if ( exec 3/dev/null; then exec 3 Google Drive 備份設定精靈 (setup wizard) v%s ===\n' "$VERSION_TAG" printf ' 直接撳 Enter = 用 [ ] 入面嘅預設值 (Enter accepts default)\n' step 1 "系統檢查 Preflight" if ! preflight; then die "系統檢查唔通過,請先處理 [FAIL] 項目 (fix FAIL items first)" fi PF_DONE=1 step 2 "分店代號 Shop code" printf ' 輸入分店代號 (英文/數字,例如 TYS、TP01;舊式 01-15 都得)\n' while :; do ask _site "分店代號 Code" "$BRANCH" parse_site "$_site" && break printf ' ✗ 唔啱:只可以英文/數字/-/_,最多 16 個字\n' done printf ' Drive 子資料夾 (喺 %s/ 下面;可以有 /,例如 218/daily-backup;Enter = 預設)\n' "$REMOTE_ROOT" while :; do ask _sd "Drive 子資料夾 Site dir" "${SITE_DIR_OPT:-$SITE_DIR}" if _sdn=$(norm_site_dir "$_sd"); then if [ "$_sdn" = "$SITE_DIR" ]; then SITE_DIR_OPT=""; else SITE_DIR_OPT=$_sdn; SITE_DIR=$_sdn; fi break fi printf ' ✗ 唔啱:唔可以 / 開頭、唔可以有 ..,只可以英文/數字/. _ - /\n' done printf ' → Drive 資料夾: %s/%s\n' "$REMOTE_ROOT" "$SITE_DIR" step 3 "要備份嘅資料夾 Folders" SHARES=$(list_shares) if [ -n "$SHARES" ]; then printf '%s\n' "$SHARES" | awk '{ printf " %2d) %s%s\n", NR, $0, ($0 ~ /\/homes$/ ? " (用戶家目錄 homes)" : "") }' printf ' 揀編號用逗號分隔 (e.g. 1,3);all = 全部;或者直接打路徑 /volume1/xxx\n' else printf ' 搵唔到 /volume*/ 共用資料夾,請直接輸入路徑 (comma separated)\n' fi while :; do ask _pick "揀選 Select" "$SRC_LIST" SRC_LIST=""; _bad="" case "$_pick" in all|ALL|a) SRC_LIST=$(printf '%s\n' "$SHARES" | grep -v '/homes$' | paste -sd, -) ;; /*) SRC_LIST=$_pick ;; *) for i in $(printf '%s' "$_pick" | tr ', ' ' '); do case "$i" in ''|*[!0-9]*) _bad=1; break ;; esac d=$(printf '%s\n' "$SHARES" | sed -n "${i}p") [ -n "$d" ] || { _bad=1; break; } SRC_LIST="${SRC_LIST:+$SRC_LIST,}$d" done ;; esac if [ -z "$_bad" ] && [ -n "$SRC_LIST" ]; then _ok=1; OLDIFS=$IFS; IFS=','; set -f for d in $SRC_LIST; do [ -d "$d" ] || { printf ' ✗ 搵唔到: %s\n' "$d"; _ok=0; }; done IFS=$OLDIFS; set +f [ "$_ok" -eq 1 ] && break else printf ' ✗ 輸入唔啱 (invalid selection)\n' fi done printf '%s\n' "$SRC_LIST" | tr ',' '\n' | sed 's/^/ → /' step 4 "Google Drive 設定檔 rclone.conf" _defconf=$CONFIG_SRC if [ -z "$_defconf" ]; then if _defconf=$(find_default_conf); then printf ' 自動搵到 (auto-detected): %s\n' "$_defconf" else _defconf=/volume1/admin-tools/rclone.conf; fi fi ask CONFIG_SRC "rclone.conf 路徑 path" "$_defconf" if [ ! -f "$CONFIG_SRC" ]; then printf ' ✗ 搵唔到 %s。請 IT admin 喺電腦做一次 (one-time):\n' "$CONFIG_SRC" printf ' 1) 電腦執行 rclone config,建立 remote 名 "gdrive" (Google Drive)\n' printf ' 2) 用 SCP/USB 將 rclone.conf 放上 NAS (例如 /root/rclone.conf),再執行一次安裝指令\n' printf ' 3) 詳情睇 README-zh-HK.md (唔好經 WhatsApp/email 傳送!)\n' exit 1 fi check_config printf ' 準備 rclone (download if needed) ... ' ensure_rclone >/dev/null || die "rclone 安裝失敗" printf 'OK (%s)\n' "$("$RCLONE_BIN" version 2>/dev/null | head -n 1)" printf ' 測試連線 (rclone lsd gdrive:) ... ' if [ -n "$SA_FILE" ]; then "$RCLONE_BIN" --config "$CONFIG_SRC" --drive-service-account-file "$SA_FILE" lsd gdrive: --max-depth 1 >/dev/null 2>"$TMP_BASE/ngb-wz.$$"; _rc=$? else "$RCLONE_BIN" --config "$CONFIG_SRC" lsd gdrive: --max-depth 1 >/dev/null 2>"$TMP_BASE/ngb-wz.$$"; _rc=$? fi if [ "$_rc" -eq 0 ]; then printf 'OK ✓\n' else printf 'FAILED ✗\n'; tail -n 2 "$TMP_BASE/ngb-wz.$$" | sed 's/^/ /' ask _cont "仍然繼續安裝? Continue anyway (y/N)" "N" case "$_cont" in y|Y|yes) : ;; *) rm -f "$TMP_BASE/ngb-wz.$$"; exit 1 ;; esac fi rm -f "$TMP_BASE/ngb-wz.$$" step 5 "時間表 Schedule" while :; do ask RUN_TIME "每日備份時間 Daily time (HH:MM)" "${RUN_TIME:-$(default_time)}" valid_time "$RUN_TIME" && break; printf ' ✗ 格式要 HH:MM (00:00-23:59)\n' done while :; do ask KEEP_DAYS "舊版本保留日數 Keep versions (days)" "$KEEP_DAYS" case "$KEEP_DAYS" in ''|0|*[!0-9]*) printf ' ✗ 要正整數\n' ;; *) break ;; esac done _cr=N; [ "$USE_CRONTAB" -eq 1 ] && _cr=Y ask _cr "用 crontab 自動排程? (N = 之後喺 DSM 工作排程表手動加,較穩陣) (y/N)" "$_cr" case "$_cr" in y|Y|yes) USE_CRONTAB=1 ;; *) USE_CRONTAB=0 ;; esac step 6 "Tailscale 遙距網絡 (可選 optional)" ask _ts "安裝 Tailscale? Install Tailscale (y/N)" "$(yn_default "$TS_WANT")" if is_yes "$_ts"; then TS_WANT=1 ask _ex "做 exit node? Advertise as exit node (y/N)" "$(yn_default "$TS_EXIT")" if is_yes "$_ex"; then TS_EXIT=1; else TS_EXIT=0; fi ask _sr "分享本地 LAN 網段? Share this NAS's local LAN subnet (y/N)" "$(yn_default "$TS_SUBNET")" if is_yes "$_sr"; then TS_SUBNET=1 _det=${TS_ROUTES:-$(detect_lan_cidr 2>/dev/null)} [ -n "$_det" ] && printf ' 偵測到 LAN (detected): %s\n' "$_det" while :; do ask TS_ROUTES "網段 Subnet CIDR (逗號分隔)" "$_det" valid_cidrs "$TS_ROUTES" && break printf ' ✗ 格式要 CIDR,例如 10.10.3.0/24\n' done if common_cidr "$TS_ROUTES"; then overlap_warning "$TS_ROUTES"; fi else TS_SUBNET=0; TS_ROUTES="" fi if [ -n "$TS_KEYFILE" ] || [ -n "$TS_KEY_VALUE" ]; then printf ' Auth key: 已提供 (provided, hidden)\n' else printf ' Auth key: 輸入 key 檔案路徑;打 - 隱藏貼上;Enter = 之後用網址登入\n' while :; do ask _k "Auth key" "" case "$_k" in "") break ;; -) printf ' 貼上 key (唔會顯示 hidden): ' stty -echo 0<&3 2>/dev/null IFS= read -r TS_KEY_VALUE <&3 stty echo 0<&3 2>/dev/null printf '\n'; break ;; *) if [ -r "$_k" ]; then TS_KEYFILE=$_k; break; fi printf ' ✗ 讀唔到 %s\n' "$_k" ;; esac done fi else TS_WANT=0; TS_EXIT=0; TS_SUBNET=0; TS_ROUTES="" fi step 7 "確認 Confirm" printf ' 分店 Code : %s → gdrive:%s/%s\n' "$BRANCH" "$REMOTE_ROOT" "$SITE_DIR" printf ' 資料夾 Folders: %s\n' "$SRC_LIST" printf ' 設定檔 Config : %s\n' "$CONFIG_SRC" printf ' 時間 Time : 每日 %s 保留 Keep: %s 日 排程: %s\n' "$RUN_TIME" "$KEEP_DAYS" "$([ "$USE_CRONTAB" -eq 1 ] && echo crontab || echo 'DSM Task Scheduler')" if [ "$TS_WANT" -eq 1 ]; then _tsk="之後用網址登入"; { [ -n "$TS_KEYFILE" ] || [ -n "$TS_KEY_VALUE" ]; } && _tsk="auth key" printf ' Tailscale : 安裝 exit node: %s subnet: %s 登入: %s\n' "$(yn_default "$TS_EXIT")" "${TS_ROUTES:-no}" "$_tsk" else printf ' Tailscale : 唔裝\n' fi ask _go "開始安裝? Install now (Y/n)" "Y" case "$_go" in n|N|no) printf ' 已取消 (cancelled)\n'; exit 0 ;; esac printf '\n' } # validate Tailscale options early if [ -n "$TS_KEYFILE" ] && [ ! -r "$TS_KEYFILE" ]; then die "--ts-authkey-file not readable: $TS_KEYFILE"; fi if [ -n "$TS_ROUTES" ] && ! valid_cidrs "$TS_ROUTES"; then die "--routes must be CIDR list, e.g. 10.10.3.0/24"; fi # --tailscale-only: skip the backup part completely if [ "$TS_ONLY" -eq 1 ]; then if [ -n "$BRANCH" ]; then parse_site "$BRANCH" || die "--branch must be a shop code (e.g. TYS) or 01-15" elif [ -z "$TS_HOSTNAME" ]; then die "--tailscale-only needs --branch NN or --ts-hostname NAME"; fi preflight || die "Preflight check failed" ts_setup; exit $? fi # required flags missing -> wizard (pre-filled with given flags) if [ "$WIZARD" -eq 0 ] && [ -n "$(missing_flags)" ]; then if [ -n "$BRANCH" ] && [ -n "$SRC_LIST" ] && CONFIG_SRC=$(find_default_conf); then info "Using auto-detected rclone.conf: $CONFIG_SRC" else WIZARD=1 fi fi if [ "$WIZARD" -eq 1 ]; then if ! open_tty; then _mf=$(missing_flags) cat >&2 <&2; die "--branch is required"; } parse_site "$BRANCH" || die "--branch must be a shop code (letters/digits/-/_, max 16, e.g. TYS) or 01-15" [ -n "$SITE_DIR_OPT" ] && SITE_DIR=$SITE_DIR_OPT # --site-dir overrides only the Drive folder case "$KEEP_DAYS" in ''|*[!0-9]*) die "--keep-days must be a positive integer" ;; esac [ "$KEEP_DAYS" -ge 1 ] || die "--keep-days must be >= 1" for v in "${TRANSFERS:-1}" "${CHECKERS:-1}" "$TPSLIMIT"; do case "$v" in ''|*[!0-9]*) die "--transfers/--checkers/--tpslimit must be integers" ;; esac done case "$REMOTE_ROOT" in ''|/*|*..*|*:*) die "--remote-root must be a simple relative folder name" ;; esac REMOTE_ROOT=$(printf '%s' "$REMOTE_ROOT" | sed 's#/*$##') [ -n "$RUN_TIME" ] || RUN_TIME=$(default_time) case "$RUN_TIME" in [0-9][0-9]:[0-9][0-9]) : ;; *) die "--time must be HH:MM (24h)" ;; esac RUN_H=$(expr "${RUN_TIME%%:*}" + 0); RUN_M=$(expr "${RUN_TIME##*:}" + 0) { [ "$RUN_H" -le 23 ] && [ "$RUN_M" -le 59 ]; } || die "--time must be HH:MM (00:00-23:59)" # sources: must exist, be non-empty dirs, have unique folder names [ -n "$SRC_LIST" ] || { usage >&2; die "--src is required"; } CLEAN_SRC="" NAMES=" " OLDIFS=$IFS; IFS=','; set -f for s in $SRC_LIST; do IFS=$OLDIFS s=$(printf '%s' "$s" | sed 's/^[[:space:]]*//; s/[[:space:]]*$//; s#/*$##') [ -n "$s" ] || continue case "$s" in /*) : ;; *) die "Source must be an absolute path: $s" ;; esac [ -d "$s" ] || die "Source folder not found: $s" n=$(basename "$s") case "$NAMES" in *" $n "*) die "Duplicate folder name '$n' in --src (each source must have a unique last folder name)" ;; esac NAMES="$NAMES$n " if [ -z "$(ls -A "$s" 2>/dev/null)" ]; then warn "Source folder is empty: $s (runner will refuse to sync an empty source)"; fi CLEAN_SRC="${CLEAN_SRC:+$CLEAN_SRC,}$s" done IFS=$OLDIFS; set +f [ -n "$CLEAN_SRC" ] || die "--src contained no usable folders" FIRST_SRC=${CLEAN_SRC%%,*} check_config if [ "$PF_DONE" -eq 0 ]; then preflight || die "Preflight check failed - fix the [FAIL] items above (or run --check-only to re-test)" fi autotune ensure_rclone # ---- config + settings ------------------------------------------------------ info "Installing rclone config -> $CONF_DEST (chmod 600)" if [ "$DRY_RUN" -eq 0 ]; then umask 077 mkdir -p "$ETC_DIR" && chmod 700 "$ETC_DIR" || die "Cannot create $ETC_DIR" cp "$CONFIG_SRC" "$CONF_DEST.new" && chmod 600 "$CONF_DEST.new" || die "Cannot copy config" # works even if CONFIG_SRC = CONF_DEST if [ -n "$SA_FILE" ]; then if [ "$SA_FILE" != "$SA_DEST" ]; then cp "$SA_FILE" "$SA_DEST" && chmod 600 "$SA_DEST" || die "Cannot copy service account file" fi # point the installed conf at the installed JSON (only inside [gdrive]) awk -v p="$SA_DEST" ' /^[[:space:]]*\[/ { insec = ($0 ~ /^[[:space:]]*\[gdrive\][[:space:]]*$/) } insec && /^[[:space:]]*service_account_file[[:space:]]*=/ { print "service_account_file = " p; next } { print }' "$CONF_DEST.new" > "$CONF_DEST.tmp" && mv -f "$CONF_DEST.tmp" "$CONF_DEST.new" chmod 600 "$CONF_DEST.new" info "Service account JSON installed -> $SA_DEST (chmod 600)" fi mv -f "$CONF_DEST.new" "$CONF_DEST" umask 022 else say "[DRY-RUN] mkdir -p $ETC_DIR; cp $CONFIG_SRC $CONF_DEST; chmod 600" fi write_settings() { cat </. # Usage: nas-gdrive-backup.sh [--dry-run] [--first-only] [--notify-cmd CMD] [--settings FILE] # nas-gdrive-backup status [--no-size] (last run, result, size, next run) # nas-gdrive-backup health [--json] [--offline] (PASS/WARN/FAIL, exit 1 on FAIL) # nas-gdrive-backup update [--rclone] (download + verify new install.sh, re-install keeping config) set -u SETTINGS_FILE="${NGB_SETTINGS:-@@SETTINGS@@}" DRY=0; FIRST_ONLY=0; NOTIFY_OVERRIDE=""; STATUS_MODE=0; STATUS_SIZE=1; HEALTH_MODE=0; JSON_OUT=0; OFFLINE=0; UPDATE_MODE=0; UPD_RCLONE=0 while [ $# -gt 0 ]; do case "$1" in --dry-run) DRY=1; shift ;; --first-only) FIRST_ONLY=1; shift ;; status|--status) STATUS_MODE=1; shift ;; health|--health) HEALTH_MODE=1; shift ;; update|--update) UPDATE_MODE=1; shift ;; --rclone) UPD_RCLONE=1; shift ;; --json) JSON_OUT=1; shift ;; --offline) OFFLINE=1; shift ;; --no-size) STATUS_SIZE=0; shift ;; --notify-cmd) NOTIFY_OVERRIDE=${2:-}; shift 2 ;; --settings) SETTINGS_FILE=${2:-}; shift 2 ;; -h|--help) sed -n '2,8p' "$0"; exit 0 ;; *) echo "Unknown option: $1" >&2; exit 2 ;; esac done [ -r "$SETTINGS_FILE" ] || { echo "Settings not found: $SETTINGS_FILE" >&2; exit 2; } # shellcheck disable=SC1090 . "$SETTINGS_FILE" [ -n "$NOTIFY_OVERRIDE" ] && NOTIFY_CMD=$NOTIFY_OVERRIDE SITE_DIR=${SITE_DIR:-branch-$BRANCH} # ---- update mode: fetch install.sh + .sha256, verify, re-install (manual only) ---- if [ "$UPDATE_MODE" = 1 ]; then [ "$(id -u)" = 0 ] || [ -n "${NGB_ROOT:-}" ] || { echo "Run as root: sudo $0 update" >&2; exit 1; } if [ -n "${NAS_APP_URL:-}" ]; then url="${NAS_APP_URL%/}/install.sh" else url=${UPDATE_URL:-https://nas-app.derekhost.com/install.sh}; fi uw=$(mktemp -d "${TMPDIR:-/tmp}/ngb-update.XXXXXX") || exit 1 get() { if command -v curl >/dev/null 2>&1; then curl -fsSL --retry 2 -o "$2" "$1"; else wget -q -O "$2" "$1"; fi; } echo "Downloading $url (+ .sha256) ..." if ! get "$url" "$uw/install.sh" || ! get "$url.sha256" "$uw/install.sh.sha256"; then echo "ERROR: download failed" >&2; rm -rf "$uw"; exit 1 fi want=$(awk 'NR==1 {print $1}' "$uw/install.sh.sha256") got=$(sha256sum "$uw/install.sh" 2>/dev/null | awk '{print $1}') if [ -z "$want" ] || [ "$want" != "$got" ]; then echo "ERROR: SHA256 mismatch - NOT updating (expected ${want:-?}, got ${got:-?})" >&2; rm -rf "$uw"; exit 1 fi echo "SHA256 OK: $got" echo "New version: $(sed -n 's/^VERSION_TAG="\(.*\)"/\1/p' "$uw/install.sh" | head -n 1)" if [ "$UPD_RCLONE" = 1 ]; then sh "$uw/install.sh" --from-settings "$SETTINGS_FILE" --upgrade else sh "$uw/install.sh" --from-settings "$SETTINGS_FILE"; fi urc=$? rm -rf "$uw" exit "$urc" fi # ---- status mode ---- if [ "$STATUS_MODE" = 1 ]; then echo "nas-gdrive-backup status ($SITE_DIR -> $REMOTE_NAME:$REMOTE_ROOT/$SITE_DIR)" if [ -f "$LOG_DIR/last-status" ]; then read -r st d t rest < "$LOG_DIR/last-status" lf=${rest##*log=} echo " Last run : $d $t Result: $st (${rest%% log=*})" if [ -f "$lf" ]; then cp_n=$(grep -c 'Copied (' "$lf" 2>/dev/null); mv_n=$(grep -c 'Moved into backup dir' "$lf" 2>/dev/null) echo " Files : ${cp_n:-0} uploaded, ${mv_n:-0} old/deleted moved to _versions" echo " Log : $lf" ec=$(grep -c 'ERROR' "$lf" 2>/dev/null); echo " Errors : ${ec:-0} line(s) with ERROR" fi else echo " Last run : never" fi running=no if [ -d "$LOCK_BASE.lock.d" ] && kill -0 "$(cat "$LOCK_BASE.lock.d/pid" 2>/dev/null)" 2>/dev/null; then running=yes elif command -v flock >/dev/null 2>&1 && [ -e "$LOCK_BASE.lock" ] && ! flock -n "$LOCK_BASE.lock" true 2>/dev/null; then running=yes; fi echo " Running now: $running" TSB=${TS_BIN:-/var/packages/Tailscale/target/bin/tailscale} if [ -x "$TSB" ]; then tst=$("$TSB" status --json 2>/dev/null | sed -n 's/^[[:space:]]*"BackendState":[[:space:]]*"\([^"]*\)".*/\1/p' | head -n 1) tadv=$("$TSB" debug prefs 2>/dev/null | awk '/"AdvertiseRoutes": \[/ {f=1; if (/\]/) exit; next} f && /\]/ {exit} f {gsub(/[",[:space:]]/, ""); if ($0 != "") printf "%s ", $0}') echo " Tailscale : ${tst:-unknown} ip=$("$TSB" ip -4 2>/dev/null | head -n 1) advertised: ${tadv:-none}" elif [ "${TS_WANT:-0}" = 1 ]; then echo " Tailscale : NOT installed (expected)" fi if [ -n "${RUN_TIME:-}" ]; then now=$(date +%H%M); rt=$(echo "$RUN_TIME" | tr -d ':') if [ "$now" -lt "$rt" ]; then nd=$(date +%F) else nd=$(date -d "@$(( $(date +%s) + 86400 ))" +%F 2>/dev/null || echo tomorrow); fi how="DSM Task Scheduler (check it exists)"; ct=${CRONTAB_FILE:-/etc/crontab} grep -q 'nas-gdrive-backup' "$ct" 2>/dev/null && how="$ct" [ "${USE_CRONTAB:-0}" = 1 ] && ! grep -q 'nas-gdrive-backup' "$ct" 2>/dev/null && how="crontab entry MISSING (DSM update? re-run install.sh)" echo " Next run : $nd $RUN_TIME via $how" fi if [ "${STATUS_SIZE:-1}" = 1 ]; then printf ' Drive size : ' "$RCLONE_BIN" size --config "$RCLONE_CONFIG_FILE" "$REMOTE_NAME:$REMOTE_ROOT/$SITE_DIR" 2>/dev/null | tr '\n' ' ' | sed 's/Total objects:/objects/; s/Total size:/ size/; s/ */ /g'; echo fi exit 0 fi PATH=/usr/local/bin:/opt/rclone:/usr/bin:/bin:/usr/sbin:/sbin:$PATH export PATH export GOGC="${GOGC:-50}" # lower Go GC target -> less RAM on 512MB NAS umask 077 mkdir -p "$LOG_DIR" || { echo "Cannot create $LOG_DIR" >&2; exit 2; } TS=$(date '+%Y%m%d-%H%M%S') TODAY=$(date '+%F') LOGFILE="$LOG_DIR/nas-gdrive-backup-$TS.log" DEST_BASE="$REMOTE_NAME:$REMOTE_ROOT/$SITE_DIR" VERS_BASE="$DEST_BASE/_versions" # ---- health mode: PASS/WARN/FAIL table (or --json); exit 1 on any FAIL ---- if [ "$HEALTH_MODE" = 1 ]; then H_ROWS=""; H_FAIL=0; H_WARN=0 hadd() { # hadd STATUS id "中文名" "detail" H_ROWS="$H_ROWS$1 $2 $3 $4 " case "$1" in FAIL) H_FAIL=$((H_FAIL + 1)) ;; WARN) H_WARN=$((H_WARN + 1)) ;; esac } RC_NET="--config $RCLONE_CONFIG_FILE --contimeout 15s --timeout 30s --retries 1 --low-level-retries 2" # 1 rclone if v=$("$RCLONE_BIN" version 2>/dev/null | head -n 1) && [ -n "$v" ]; then hadd PASS rclone "rclone 程式" "$v" else hadd FAIL rclone "rclone 程式" "$RCLONE_BIN 行唔到"; fi # 2 config if [ -f "$RCLONE_CONFIG_FILE" ]; then perm=$(stat -c %a "$RCLONE_CONFIG_FILE" 2>/dev/null || echo "?") if ! grep -q '^[[:space:]]*\[gdrive\]' "$RCLONE_CONFIG_FILE"; then hadd FAIL config "rclone.conf" "冇 [gdrive] remote" elif [ "$perm" = 600 ]; then hadd PASS config "rclone.conf" "perm 600, [gdrive] OK" else hadd WARN config "rclone.conf" "權限係 $perm,應該 600 (chmod 600 $RCLONE_CONFIG_FILE)"; fi else hadd FAIL config "rclone.conf" "搵唔到 $RCLONE_CONFIG_FILE" fi # 3/4 Drive reachable + write test if [ "$OFFLINE" = 1 ]; then hadd WARN drive "Google Drive 連線" "skipped (--offline)" else # shellcheck disable=SC2086 if "$RCLONE_BIN" $RC_NET lsd "$REMOTE_NAME:" --max-depth 1 >/dev/null 2>&1; then hadd PASS drive "Google Drive 連線" "rclone lsd $REMOTE_NAME: OK" probe="$DEST_BASE/.healthcheck/probe-$(hostname 2>/dev/null || echo nas)-$(date +%s).txt" # shellcheck disable=SC2086 if echo "healthcheck $(date '+%F %T')" | "$RCLONE_BIN" $RC_NET rcat "$probe" 2>/dev/null \ && "$RCLONE_BIN" $RC_NET deletefile "$probe" 2>/dev/null; then "$RCLONE_BIN" $RC_NET rmdir "$DEST_BASE/.healthcheck" >/dev/null 2>&1 hadd PASS drive_write "Drive 寫入測試" "upload + delete OK ($REMOTE_ROOT/$SITE_DIR/.healthcheck)" else hadd FAIL drive_write "Drive 寫入測試" "上傳/刪除失敗 (quota? 權限?)" fi else hadd FAIL drive "Google Drive 連線" "rclone lsd 失敗 (網絡 / token 過期 / 時間唔準?)" fi fi # 5 sources OLDIFS=$IFS; IFS=','; set -f for s in $SRC_LIST; do IFS=$OLDIFS if [ ! -d "$s" ]; then hadd FAIL source "來源資料夾" "$s 唔存在" elif [ -z "$(ls -A "$s" 2>/dev/null)" ]; then hadd FAIL source "來源資料夾" "$s 係空 (未 mount?)" else hadd PASS source "來源資料夾" "$s"; fi done IFS=$OLDIFS; set +f # 6 runner rp=${RUNNER_PATH:-$0} if [ -x "$rp" ]; then hadd PASS runner "備份程式" "$rp"; else hadd FAIL runner "備份程式" "$rp 唔存在/唔可執行"; fi # 7 schedule ct=${CRONTAB_FILE:-/etc/crontab} if grep -q 'nas-gdrive-backup' "$ct" 2>/dev/null; then hadd PASS schedule "排程" "$ct ($RUN_TIME)" elif [ "${USE_CRONTAB:-0}" = 1 ]; then hadd FAIL schedule "排程" "crontab 冇咗 (DSM 更新?) - 再跑 install.sh" elif grep -rqs 'nas-gdrive-backup' /usr/syno/etc/esynoscheduler /usr/syno/etc/synoschedule.d 2>/dev/null; then hadd PASS schedule "排程" "DSM 任務排程表有任務 (偵測到)" else hadd WARN schedule "排程" "請確認 DSM 任務排程表有每日 $RUN_TIME 任務 (偵測唔到)"; fi # 8 last run if [ -f "$LOG_DIR/last-status" ]; then read -r st d t _rest < "$LOG_DIR/last-status" age_h="" lt=$(date -d "$d $t" +%s 2>/dev/null) && age_h=$(( ($(date +%s) - lt) / 3600 )) if [ "$st" != OK ]; then hadd FAIL last_run "上次備份" "$st @ $d $t" elif [ -n "$age_h" ] && [ "$age_h" -gt 48 ]; then hadd WARN last_run "上次備份" "OK 但已經 ${age_h} 小時前 ($d $t)" else hadd PASS last_run "上次備份" "OK @ $d $t"; fi else hadd WARN last_run "上次備份" "未跑過 (never run)" fi # 9 disk / RAM fm=$(df -k "$LOG_DIR" 2>/dev/null | awk 'NR>1 && NF>=4 {v=$(NF-2)} END {if (v!="") print int(v/1024)}') if [ -z "$fm" ]; then hadd WARN disk "系統磁碟空間" "unknown" elif [ "$fm" -lt 100 ]; then hadd WARN disk "系統磁碟空間" "${fm} MB (< 100 MB)" else hadd PASS disk "系統磁碟空間" "${fm} MB free"; fi ma=$(awk '/^MemAvailable:/ {print int($2/1024)}' /proc/meminfo 2>/dev/null) [ -n "$ma" ] || ma=$(awk '/^MemFree:|^Cached:/ {s+=$2} END {print int(s/1024)}' /proc/meminfo 2>/dev/null) if [ -n "$ma" ] && [ "$ma" -lt 80 ]; then hadd WARN ram "記憶體" "可用 ${ma} MB (偏低)" else hadd PASS ram "記憶體" "可用 ${ma:-?} MB"; fi # 10 Tailscale (only if chosen or installed) TSB=${TS_BIN:-/var/packages/Tailscale/target/bin/tailscale} if [ "${TS_WANT:-0}" = 1 ] || [ -x "$TSB" ]; then if [ ! -x "$TSB" ]; then hadd FAIL ts_pkg "Tailscale 套件" "未安裝" else # shellcheck disable=SC2009 if pidof tailscaled >/dev/null 2>&1 || pgrep tailscaled >/dev/null 2>&1 || ps 2>/dev/null | grep -v grep | grep -q tailscaled; then hadd PASS ts_pkg "Tailscale 套件" "tailscaled running ($("$TSB" version 2>/dev/null | head -n 1))" else hadd FAIL ts_pkg "Tailscale 套件" "tailscaled 冇行 (synopkg start Tailscale)"; fi tj=$("$TSB" status --json 2>/dev/null) bs=$(printf '%s\n' "$tj" | sed -n 's/^[[:space:]]*"BackendState":[[:space:]]*"\([^"]*\)".*/\1/p' | head -n 1) tn=$(printf '%s\n' "$tj" | awk '/"CurrentTailnet"/ {f=1} f && /"Name"/ {gsub(/.*"Name":[[:space:]]*"|".*/, ""); print; exit}') [ -n "$tn" ] || tn=$(printf '%s\n' "$tj" | sed -n 's/^[[:space:]]*"MagicDNSSuffix":[[:space:]]*"\([^"]*\)".*/\1/p' | head -n 1) self=$(printf '%s\n' "$tj" | awk '/^\t"Self": \{/ {f=1; next} f && /^\t\}/ {exit} f {print}') hn=$(printf '%s\n' "$self" | sed -n 's/^[[:space:]]*"HostName":[[:space:]]*"\([^"]*\)".*/\1/p' | head -n 1) allowed=$(printf '%s\n' "$self" | awk '/"AllowedIPs": \[/ {f=1; next} f && /\]/ {exit} f {gsub(/[",[:space:]]/, ""); if ($0 != "") printf "%s ", $0}') if [ "$bs" = Running ]; then hadd PASS ts_login "Tailscale 登入" "tailnet=${tn:-?} host=${hn:-?} ip=$("$TSB" ip -4 2>/dev/null | head -n 1)" else hadd FAIL ts_login "Tailscale 登入" "state=${bs:-unknown} (未登入: $TSB up)"; fi adv=$("$TSB" debug prefs 2>/dev/null | awk '/"AdvertiseRoutes": \[/ {f=1; if (/\]/) exit; next} f && /\]/ {exit} f {gsub(/[",[:space:]]/, ""); if ($0 != "") printf "%s ", $0}') want_exit=${TS_EXIT:-0}; want_routes=${TS_ROUTES:-} case " $adv " in *" 0.0.0.0/0 "*) has_exit=1 ;; *) has_exit=0 ;; esac if [ "$want_exit" = 1 ]; then if [ "$has_exit" != 1 ]; then hadd FAIL ts_exit "Exit node" "未 advertise" else case " $allowed " in *" 0.0.0.0/0 "*) hadd PASS ts_exit "Exit node" "advertised + approved" ;; *) hadd WARN ts_exit "Exit node" "advertised 但未批准 (admin console / autoApprovers)" ;; esac fi fi if [ -n "$want_routes" ]; then OLDIFS=$IFS; IFS=',' for r in $want_routes; do IFS=$OLDIFS case " $adv " in *" $r "*) case " $allowed " in *" $r "*) hadd PASS ts_route "Subnet route" "$r advertised + approved" ;; *) hadd WARN ts_route "Subnet route" "$r advertised 但未批准" ;; esac ;; *) hadd FAIL ts_route "Subnet route" "$r 未 advertise" ;; esac done IFS=$OLDIFS fi if [ "$want_exit" = 1 ] || [ -n "$want_routes" ]; then if [ "$(cat /proc/sys/net/ipv4/ip_forward 2>/dev/null)" = 1 ]; then hadd PASS ts_fwd "IP forwarding" "ipv4 on" else hadd FAIL ts_fwd "IP forwarding" "ipv4 off (跑 nas-tailscale-boot.sh / 開機任務)"; fi if [ -d /sys/class/net/tailscale0 ]; then hadd PASS ts_tun "configure-host (TUN)" "tailscale0 存在" else hadd FAIL ts_tun "configure-host (TUN)" "冇 tailscale0 (DSM7: 跑 nas-tailscale-boot.sh)"; fi fi fi fi if [ "$H_FAIL" -gt 0 ]; then overall=FAIL; elif [ "$H_WARN" -gt 0 ]; then overall=WARN; else overall=PASS; fi if [ "$JSON_OUT" = 1 ]; then jesc() { printf '%s' "$1" | sed 's/\\/\\\\/g; s/"/\\"/g; s/ / /g'; } printf '{"site":"%s","host":"%s","time":"%s","overall":"%s","fail":%s,"warn":%s,"checks":[' \ "$(jesc "$SITE_DIR")" "$(jesc "$(hostname 2>/dev/null)")" "$(date '+%Y-%m-%dT%H:%M:%S%z')" "$overall" "$H_FAIL" "$H_WARN" first=1 printf '%s' "$H_ROWS" | while IFS=' ' read -r hs hid hname hdet; do [ -n "$hs" ] || continue [ "$first" = 1 ] || printf ',' first=0 printf '{"status":"%s","id":"%s","name":"%s","detail":"%s"}' "$hs" "$hid" "$(jesc "$hname")" "$(jesc "$hdet")" done printf ']}\n' else echo "健康檢查 Health check - $SITE_DIR ($(date '+%F %T'))" echo "------------------------------------------------------------------" printf '%s' "$H_ROWS" | while IFS=' ' read -r hs hid hname hdet; do [ -n "$hs" ] || continue printf ' %-4s %s: %s\n' "$hs" "$hname" "$hdet" done echo "------------------------------------------------------------------" case "$overall" in PASS) echo "結果: PASS ✓ 全部正常" ;; WARN) echo "結果: WARN - 有 $H_WARN 項要留意,但冇嚴重問題" ;; FAIL) echo "結果: FAIL ✗ - $H_FAIL 項失敗,請跟上面提示處理" ;; esac fi [ "$H_FAIL" -eq 0 ]; exit $? fi log() { printf '%s %s\n' "$(date '+%F %T')" "$*" | tee -a "$LOGFILE"; } # ---- lock (flock if available, else mkdir) ---- LOCKDIR="" # shellcheck disable=SC2317 cleanup() { if [ -n "$LOCKDIR" ]; then rm -rf "$LOCKDIR"; fi; } mkdir -p "$(dirname "$LOCK_BASE")" 2>/dev/null if command -v flock >/dev/null 2>&1; then exec 9>"$LOCK_BASE.lock" if ! flock -n 9; then log "Another backup is running (flock busy) - exit"; exit 75; fi else # mkdir fallback (no flock, e.g. some DSM 6 builds) if mkdir "$LOCK_BASE.lock.d" 2>/dev/null; then LOCKDIR="$LOCK_BASE.lock.d"; echo $$ > "$LOCKDIR/pid" else oldpid=$(cat "$LOCK_BASE.lock.d/pid" 2>/dev/null) if [ -n "$oldpid" ] && kill -0 "$oldpid" 2>/dev/null; then log "Another backup is running (pid $oldpid) - exit"; exit 75 fi log "Removing stale lock (pid ${oldpid:-?})" rm -rf "$LOCK_BASE.lock.d" mkdir "$LOCK_BASE.lock.d" 2>/dev/null || { log "Cannot get lock"; exit 75; } LOCKDIR="$LOCK_BASE.lock.d"; echo $$ > "$LOCKDIR/pid" fi fi trap cleanup EXIT trap 'log "Interrupted"; exit 130' INT TERM # ---- common rclone flags ---- set -- --config "$RCLONE_CONFIG_FILE" \ --transfers "$TRANSFERS" --checkers "$CHECKERS" --tpslimit "$TPSLIMIT" \ --drive-chunk-size "$CHUNK_SIZE" --buffer-size "$BUFFER_SIZE" \ --drive-stop-on-upload-limit --retries 3 --low-level-retries 10 \ --log-file "$LOGFILE" --log-level INFO --stats 5m --stats-one-line [ "${FAST_LIST:-0}" = 1 ] && set -- "$@" --fast-list [ "${USE_MMAP:-1}" = 1 ] && set -- "$@" --use-mmap [ -n "${BWLIMIT:-}" ] && set -- "$@" --bwlimit "$BWLIMIT" [ "$DRY" = 1 ] && set -- "$@" --dry-run # shellcheck disable=SC2086 [ -n "${EXTRA_FLAGS:-}" ] && set -- "$@" $EXTRA_FLAGS log "=== NAS -> Google Drive backup start: $SITE_DIR dry-run=$DRY rclone=$("$RCLONE_BIN" version 2>/dev/null | head -n 1)" FAIL=0; OKCNT=0 OLDIFS=$IFS; IFS=','; set -f for SRC in $SRC_LIST; do IFS=$OLDIFS; set +f [ -n "$SRC" ] || continue NAME=$(basename "$SRC") if [ ! -d "$SRC" ]; then log "ERROR: source missing: $SRC (volume not mounted?) - skipped"; FAIL=$((FAIL + 1)); continue fi if [ -z "$(ls -A "$SRC" 2>/dev/null)" ]; then log "ERROR: source is EMPTY: $SRC - refusing to sync (would move everything to _versions)"; FAIL=$((FAIL + 1)); continue fi log "--- sync $SRC -> $DEST_BASE/$NAME" if "$RCLONE_BIN" sync "$SRC" "$DEST_BASE/$NAME" \ --backup-dir "$VERS_BASE/$TODAY/$NAME" \ --exclude '@eaDir/**' --exclude '#recycle/**' --exclude '#snapshot/**' \ --exclude '@tmp/**' --exclude '.DS_Store' --exclude 'Thumbs.db' \ --exclude '@SynoResource' --exclude '@SynoEAStream' \ "$@"; then log "OK: $SRC"; OKCNT=$((OKCNT + 1)) else rc=$?; log "ERROR: rclone sync failed for $SRC (exit $rc)"; FAIL=$((FAIL + 1)) fi [ "$FIRST_ONLY" = 1 ] && break set -f; IFS=',' done IFS=$OLDIFS; set +f # ---- prune old versions: remove _versions/YYYY-MM-DD folders older than KEEP_DAYS ---- # (by folder date, not file mtime: moved files keep their ORIGINAL mtime, so # "rclone delete --min-age" alone would delete fresh versions of old files) if [ "$FIRST_ONLY" != 1 ]; then NOW=$(date +%s) CUTOFF=$(date -d "@$((NOW - KEEP_DAYS * 86400))" +%Y%m%d 2>/dev/null || true) if [ -z "$CUTOFF" ]; then log "WARN: 'date -d @N' unsupported - fallback prune with --min-age ${KEEP_DAYS}d" "$RCLONE_BIN" delete "$VERS_BASE" --min-age "${KEEP_DAYS}d" "$@" || log "WARN: prune (min-age) failed" else log "--- prune $VERS_BASE older than $KEEP_DAYS days (before $CUTOFF)" "$RCLONE_BIN" lsf --dirs-only --config "$RCLONE_CONFIG_FILE" "$VERS_BASE" 2>/dev/null | while IFS= read -r d; do d=${d%/} case "$d" in [0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]) : ;; *) continue ;; esac dn=$(printf '%s' "$d" | tr -d '-') if [ "$dn" -lt "$CUTOFF" ]; then log "prune: $VERS_BASE/$d" "$RCLONE_BIN" purge "$VERS_BASE/$d" "$@" || log "WARN: purge failed: $d" fi done fi "$RCLONE_BIN" rmdirs "$VERS_BASE" --leave-root "$@" 2>/dev/null || true fi # ---- result, log rotation, notify ---- if [ "$FAIL" -eq 0 ]; then STATUS=OK; RC=0; else STATUS=FAILED; RC=1; fi log "=== backup finished: $STATUS (ok=$OKCNT failed=$FAIL) log=$LOGFILE" [ "$DRY" = 1 ] || printf '%s %s ok=%s failed=%s log=%s\n' "$STATUS" "$(date '+%F %T')" "$OKCNT" "$FAIL" "$LOGFILE" > "$LOG_DIR/last-status" # keep newest LOG_KEEP logs (file names sort by timestamp) n=0; for f in "$LOG_DIR"/nas-gdrive-backup-*.log; do [ -e "$f" ] && n=$((n + 1)); done del=$((n - ${LOG_KEEP:-14})) for f in "$LOG_DIR"/nas-gdrive-backup-*.log; do # glob is sorted = oldest first [ "$del" -gt 0 ] || break; rm -f "$f"; del=$((del - 1)) done if [ -n "${NOTIFY_CMD:-}" ]; then STATUS=$STATUS LOGFILE=$LOGFILE BRANCH=$BRANCH sh -c "$NOTIFY_CMD" >>"$LOGFILE" 2>&1 || log "WARN: notify command failed" fi exit "$RC" RUNNER_EOF } info "Writing settings -> $SETTINGS" info "Writing runner -> $RUNNER" if [ "$DRY_RUN" -eq 0 ]; then umask 077 write_settings > "$SETTINGS.new" && mv -f "$SETTINGS.new" "$SETTINGS" && chmod 600 "$SETTINGS" || die "Cannot write settings" mkdir -p "$(dirname "$RUNNER")" "$LOG_DIR" "$(dirname "$LOCK_BASE")" || die "Cannot create dirs" esc_settings=$(printf '%s' "$SETTINGS" | sed 's/[#&\\]/\\&/g') write_runner | sed "s#@@SETTINGS@@#$esc_settings#" > "$RUNNER.new" && chmod 700 "$RUNNER.new" && mv -f "$RUNNER.new" "$RUNNER" || die "Cannot write runner" ln -sf "$RUNNER" "${RUNNER%.sh}" 2>/dev/null || true # "nas-gdrive-backup status" umask 022 else say "[DRY-RUN] would write settings:"; write_settings | sed 's/^/ /' fi # ---- scheduling ------------------------------------------------------------- CRON_CHANGED=0 if [ "$USE_CRONTAB" -eq 1 ]; then remove_cron_entry line=$(printf '%s\t%s\t*\t*\t*\troot\t%s' "$RUN_M" "$RUN_H" "$RUNNER") if [ "$DRY_RUN" -eq 1 ]; then say "[DRY-RUN] append to $CRONTAB: $line $CRON_TAG" else [ -f "$CRONTAB" ] || { mkdir -p "$(dirname "$CRONTAB")"; : > "$CRONTAB"; } printf '%s\n%s\n' "$CRON_TAG" "$line" >> "$CRONTAB" info "Added to $CRONTAB: $line" CRON_CHANGED=1 fi [ "$CRON_CHANGED" -eq 1 ] && restart_crond fi # ---- tests ------------------------------------------------------------------ TEST_RESULT="skipped" if [ "$SKIP_TEST" -eq 0 ] && [ "$DRY_RUN" -eq 0 ]; then info "Connectivity test: rclone lsd gdrive:" if "$RCLONE_BIN" --config "$CONF_DEST" lsd gdrive: --max-depth 1 >/dev/null 2>"$TMP_BASE/ngb-lsd.$$"; then info " -> OK, Google Drive reachable" info "Dry-run backup of first source: $FIRST_SRC" if "$RUNNER" --dry-run --first-only >/dev/null 2>&1; then TEST_RESULT="OK" info " -> dry-run OK" else TEST_RESULT="dry-run FAILED" warn " -> dry-run FAILED, see newest log in $LOG_DIR" fi else TEST_RESULT="connectivity FAILED" warn " -> FAILED: $(tail -n 3 "$TMP_BASE/ngb-lsd.$$" 2>/dev/null)" warn " Check internet/DNS/time on NAS and the [gdrive] section of rclone.conf" fi rm -f "$TMP_BASE/ngb-lsd.$$" fi # ---- summary ---------------------------------------------------------------- cat < versions : gdrive:$REMOTE_ROOT/$SITE_DIR/_versions// (kept $KEEP_DAYS days) time : daily $RUN_TIME ================================================================================ EOF if [ "$USE_CRONTAB" -eq 1 ]; then cat < Task Scheduler > Create > Scheduled Task > User-defined script General : Task = NAS-GDrive-Backup-$SITE_NAME , User = root , Enabled Schedule : Run on the following days = Daily ; First run time = $RUN_TIME ; Frequency = once a day (last run time = same) Task Settings: [x] Send run details by email / "only when the script terminates abnormally" User-defined script: $RUNNER Then select the task > Run, and check: cat $LOG_DIR/last-status EOF fi say " Run manually now: $RUNNER (test only: $RUNNER --dry-run)" say " Status: ${RUNNER%.sh} status" say " Health: ${RUNNER%.sh} health (--json for monitoring)" case "$CONFIG_SRC" in */volume[0-9]*/*) say " !! Delete the uploaded secret copy now: rm -f '$CONFIG_SRC'" ;; esac # ---- optional Tailscale ----------------------------------------------------- if [ "$TS_WANT" -eq 1 ] && [ "$TS_SKIP_SETUP" -eq 0 ]; then ts_setup || warn "Tailscale setup incomplete - see messages above"; fi # ---- post-install health check --------------------------------------------- [ "$DRY_RUN" -eq 1 ] && exit 0 say "" "$RUNNER" health HRC=$? if [ "$HRC" -ne 0 ] && [ "$TS_WANT" -eq 1 ]; then say "(如果 Tailscale 仲等緊你用網址登入 / 批准,完成後再跑: ${RUNNER%.sh} health)" fi exit "$HRC" } main "$@"