#!/usr/bin/env bash set -e # 檢查 bash 是否存在 if ! command -v bash >/dev/null 2>&1; then echo "[ERROR] 此腳本需要 bash,但系統未安裝 bash。請先安裝 bash。" >&2 exit 1 fi # ⚠️ 本腳本依設計會傳送 SSH 私鑰至指定 CONTROLLER_WEBHOOK,請確認該端安全 SCRIPT_URL="https://file.up9cloud.net/worker-keepalive.sh" CONTROLLER_WEBHOOK="$1" log() { local level="$1" shift local ts=$({ date +"%Y-%m-%d %H:%M:%S" 2>/dev/null || echo "0000-00-00 00:00:00"; }) if [[ "$level" == "ERROR" ]]; then echo "[$ts][$level]" "$@" >&2 else echo "[$ts][$level]" "$@" fi } if [[ -z "$CONTROLLER_WEBHOOK" ]]; then log INFO "使用方式:" log INFO "curl -fsSL $SCRIPT_URL -o ~/worker-keepalive.sh" log INFO "chmod +x ~/worker-keepalive.sh" log INFO "~/worker-keepalive.sh https:///webhook/worker-sync" exit 1 fi check_bash_version() { local required_major=4 local current_major=${BASH_VERSINFO[0]:-0} if (( current_major < required_major )); then log ERROR "bash 版本過低,需要 bash $required_major 以上版本,目前是 ${BASH_VERSION:-未知}" exit 1 fi } check_bash_version RETRY_COUNT=3 RETRY_WAIT_SECONDS=5 CURRENT_USER="${USER:-$(id -un)}" SCRIPT_PATH="$(cd "$(dirname "$0")" && pwd)/$(basename "$0")" IS_ROOT=0 [[ "$(id -u)" == "0" ]] && IS_ROOT=1 NEEDED_CMDS=(id ssh ssh-keygen mktemp grep cut tr chmod touch date curl jq rsync) [[ $IS_ROOT -eq 0 ]] && NEEDED_CMDS+=(sudo) self_update() { local script_path="$SCRIPT_PATH" local script_basename="$(basename "$script_path")" local etag_file="${script_path}.etag" local tmp_script="/tmp/${script_basename}.tmp" local remote_etag=$(curl -sI "$SCRIPT_URL" | grep -i '^ETag:' | cut -d' ' -f2 | tr -d '\r"') if [ -z "$remote_etag" ]; then log WARN "無法取得遠端版本資訊,略過更新檢查" return 0 fi local local_etag="" if [ -f "$etag_file" ]; then local_etag=$(cat "$etag_file") fi if [ "$remote_etag" != "$local_etag" ]; then log INFO "偵測到新版本,開始更新腳本..." if curl -fsSL "$SCRIPT_URL" -o "$tmp_script"; then chmod +x "$tmp_script" cp "$script_path" "$script_path.bak" mv "$tmp_script" "$script_path" echo "$remote_etag" > "$etag_file" log INFO "腳本更新完成,重新執行..." exec "$script_path" "$CONTROLLER_WEBHOOK" return 1 # ⚠️ exec 不會返回,除非失敗;但保險起見加 return else log ERROR "下載新腳本失敗,繼續使用現有版本" return 0 fi fi return 0 } install_tools() { log INFO "檢查並安裝必要工具..." local pkg_cmd="" local os_pkg_manager="" if command -v apk &>/dev/null; then pkg_cmd="apk add --no-cache" os_pkg_manager="apk" elif command -v dnf &>/dev/null; then pkg_cmd="dnf install -y" os_pkg_manager="dnf" elif command -v yum &>/dev/null; then pkg_cmd="yum install -y" os_pkg_manager="yum" elif command -v apt-get &>/dev/null; then pkg_cmd="apt-get install -y" os_pkg_manager="apt" elif command -v pacman &>/dev/null; then pkg_cmd="pacman -S --noconfirm" os_pkg_manager="pacman" elif command -v zypper &>/dev/null; then pkg_cmd="zypper install -y" os_pkg_manager="zypper" else log ERROR "無法判斷套件管理器類型" return 1 fi declare -A pkg_map_apk=( [ssh]="openssh-client" [ssh-keygen]="openssh-client" ) declare -A pkg_map_apt=( [ssh]="openssh-client" [ssh-keygen]="openssh-client" ) declare -A pkg_map_yum=( [ssh]="openssh-clients" [ssh-keygen]="openssh-clients" ) declare -A pkg_map_dnf=( [ssh]="openssh-clients" [ssh-keygen]="openssh-clients" ) declare -A pkg_map_pacman=( [ssh]="openssh" [ssh-keygen]="openssh" ) declare -A pkg_map_zypper=( [ssh]="openssh" [ssh-keygen]="openssh" ) local failed=0 local pkg_name="" for cmd in "${NEEDED_CMDS[@]}"; do if ! command -v "$cmd" &>/dev/null; then case "$os_pkg_manager" in apk) pkg_name="${pkg_map_apk[$cmd]:-$cmd}" ;; apt) pkg_name="${pkg_map_apt[$cmd]:-$cmd}" ;; yum) pkg_name="${pkg_map_yum[$cmd]:-$cmd}" ;; dnf) pkg_name="${pkg_map_dnf[$cmd]:-$cmd}" ;; pacman) pkg_name="${pkg_map_pacman[$cmd]:-$cmd}" ;; zypper) pkg_name="${pkg_map_zypper[$cmd]:-$cmd}" ;; esac log INFO "系統尚未安裝 $cmd,嘗試安裝套件 $pkg_name ..." if [[ $IS_ROOT -eq 0 ]]; then sudo $pkg_cmd "$pkg_name" else $pkg_cmd "$pkg_name" fi if ! command -v "$cmd" &>/dev/null; then log ERROR "安裝 $cmd 失敗" ((failed++)) else log INFO "$cmd 安裝成功" fi else log INFO "$cmd 已安裝" fi done if [[ $failed -gt 0 ]]; then log ERROR "有 $failed 個必要工具安裝失敗,請檢查系統狀態" return 1 fi return 0 } generate_ssh_key_if_needed() { local ssh_key="$HOME/.ssh/id_rsa" local ssh_pub="$ssh_key.pub" if [[ -f "$ssh_key" ]]; then log INFO "SSH 私鑰已存在" if [[ -f "$ssh_pub" ]]; then log INFO "SSH 公鑰已存在" else log INFO "從私鑰產生公鑰..." ssh-keygen -y -f "$ssh_key" > "$ssh_pub" log INFO "公鑰產生完成" fi else log INFO "尚未存在 SSH 金鑰,開始產生..." ssh-keygen -t rsa -b 4096 -N "" -f "$ssh_key" log INFO "SSH 金鑰產生完成" fi return 0 } ensure_authorized_key_present() { local ssh_pub="$HOME/.ssh/id_rsa.pub" local auth_file="$HOME/.ssh/authorized_keys" if [[ ! -f "$auth_file" ]]; then touch "$auth_file" chmod 600 "$auth_file" fi local pub_content=$(cat "$ssh_pub") if ! grep -qF "$pub_content" "$auth_file"; then log INFO "將公鑰加入至 authorized_keys" echo "$pub_content" >> "$auth_file" else log INFO "公鑰已存在於 authorized_keys 中" fi return 0 } can_ssh_to_self() { log INFO "測試能否 SSH 自連本機..." ssh -i "$HOME/.ssh/id_rsa" -o StrictHostKeyChecking=no -o ConnectTimeout=5 -o BatchMode=yes "localhost" exit 0 &>/dev/null local status=$? if [[ $status -ne 0 ]]; then log WARN "無法 SSH 自連本機,跳過金鑰上報" return 1 fi log INFO "SSH 自連成功" return 0 } get_machine_id() { local id="" if [[ -f /etc/machine-id ]]; then id=$(cat /etc/machine-id) elif [[ -f /var/lib/dbus/machine-id ]]; then id=$(cat /var/lib/dbus/machine-id) fi echo $id } should_report_key() { log INFO "查詢主控端是否需要更新金鑰..." local result_json=$(curl -s -X POST "$CONTROLLER_WEBHOOK" \ -H "Content-Type: application/json" \ -d "$(jq -n \ --arg ssh_user "$CURRENT_USER" \ --arg machine_id "$(get_machine_id)" \ '{ "ssh_user": $ssh_user, "machine_id": $machine_id }')") if [[ -z "$result_json" ]]; then log ERROR "無法取得主控端回應" return 1 fi if ! echo "$result_json" | jq empty 2>/dev/null; then log ERROR "主控端回應不是合法 JSON:" echo "$result_json" return 1 fi local last_update_timestamp_ms=$(echo "$result_json" | jq -r '.last_update_time // empty') if [[ -z "$last_update_timestamp_ms" ]]; then log INFO "主控端尚無金鑰紀錄,需回報" return 0 fi local last_update_timestamp_s=$((last_update_timestamp_ms / 1000)) local now_timestamp_s=$(date -u +%s) local boot_timestamp_s=$((now_timestamp_s - $(cat /proc/uptime | cut -d' ' -f1 | cut -d'.' -f1))) if (( last_update_timestamp_s < boot_timestamp_s )); then log INFO "主控端金鑰更新時間 (${last_update_timestamp_s})s 早於系統啟動時間 (${boot_timestamp_s})s,假設金鑰過期,需回報" return 0 else log INFO "主控端金鑰更新時間 (${last_update_timestamp_s})s 晚於系統啟動時間 (${boot_timestamp_s})s,假設金鑰已更新,無需回報" return 1 fi } report_private_key() { local priv_key="$HOME/.ssh/id_rsa" log INFO "開始傳送私鑰至主控端..." local json_payload=$(jq -n \ --arg machine_id "$(get_machine_id)" \ --arg hostname "$(cat /etc/hostname)" \ --arg ssh_user "$CURRENT_USER" \ --arg private_key "$(cat "$priv_key" | tr -d '\r')" \ '{ "machine_id": $machine_id, "hostname": $hostname, "ssh_user": $ssh_user, "private_key": $private_key }') local http_code local response=$(curl -s -w "%{http_code}" -X POST "$CONTROLLER_WEBHOOK" \ -H "Content-Type: application/json" \ -d "$json_payload") http_code="${response: -3}" local body="${response:0:${#response}-3}" echo "$body" | tee /dev/stderr if [[ "$http_code" != "200" ]]; then log ERROR "傳送失敗,HTTP 回應碼:$http_code" return 1 fi log INFO "私鑰傳送成功" return 0 } setup_autostart() { local script_path="$SCRIPT_PATH" local script_basename="$(basename "$script_path")" local exec_prefix="" [[ $IS_ROOT -eq 0 ]] && exec_prefix="sudo" local log_file="/tmp/${script_basename}.log" local cron_cmd="$script_path $CONTROLLER_WEBHOOK" local cron_entry="*/10 * * * * $cron_cmd >> $log_file 2>&1" if command -v crontab &>/dev/null; then log INFO "使用 crontab 設定自動執行" local tmp_old_cron=$(mktemp "/tmp/${script_basename}.cron.old.XXXXXX") local tmp_new_cron=$(mktemp "/tmp/${script_basename}.cron.new.XXXXXX") local current_cron=$(crontab -l 2>/dev/null || true) echo "$current_cron" > "$tmp_old_cron" echo "$current_cron" | grep -Fv "$cron_cmd" > "$tmp_new_cron" || true echo "$cron_entry" >> "$tmp_new_cron" local old_cron_text=$(< "$tmp_old_cron") local new_cron_text=$(< "$tmp_new_cron") if [[ "$old_cron_text" == "$new_cron_text" ]]; then log INFO "crontab 任務已存在且內容相同,略過設定" else log INFO "偵測到 crontab 任務異動,準備更新..." if command -v diff &>/dev/null; then echo "====== [DIFF] crontab 差異 ======" diff -u "$tmp_old_cron" "$tmp_new_cron" || true else echo "====== [舊版] crontab ======" cat "$tmp_old_cron" echo "====== [新版] crontab ======" cat "$tmp_new_cron" fi echo "========================================" crontab "$tmp_new_cron" log INFO "crontab 任務已成功更新" fi rm -f "$tmp_old_cron" "$tmp_new_cron" elif command -v systemctl &>/dev/null; then log INFO "使用 systemd 設定自動執行" local service_name="worker-keepalive.service" local service_file="/etc/systemd/system/$service_name" local tmp_service=$(mktemp "/tmp/${script_basename}.service.tmp.XXXXXX") cat > "$tmp_service" </dev/null; then echo "====== [DIFF] $service_file 差異 ======" diff -u "$service_file" "$tmp_service" || true else echo "====== [舊版] $service_file ======" cat "$service_file" echo "====== [新版] $service_file ======" cat "$tmp_service" fi echo "========================================" $exec_prefix cp "$tmp_service" "$service_file" $exec_prefix systemctl daemon-reload $exec_prefix systemctl restart "$service_name" log INFO "systemd 服務已更新並重新啟動" else log INFO "systemd 任務內容未變更,略過設定" fi fi rm -f "$tmp_service" else log ERROR "找不到 crontab 或 systemd,無法設定自動啟動" return 1 fi } main() { if [ -f /.dockerenv ]; then log INFO "偵測到在 Docker 環境中執行,略過自我更新" else self_update || exit 0 fi if ! install_tools; then log ERROR "安裝必要工具失敗,無法繼續執行" exit 1 fi if generate_ssh_key_if_needed \ && ensure_authorized_key_present \ && can_ssh_to_self; then if should_report_key; then local count=0 while true; do if report_private_key; then break fi ((count++)) if (( count >= RETRY_COUNT )); then log WARN "已達最大重試次數,最終仍未成功回報私鑰" break fi log WARN "第 $((count)) 次傳送私鑰失敗,等待 $RETRY_WAIT_SECONDS 秒後重試..." sleep "$RETRY_WAIT_SECONDS" done fi else log WARN "SSH 金鑰建立或驗證失敗,跳過私鑰回報流程" fi setup_autostart } main