run_update_and_commit.sh 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377
  1. #!/usr/bin/env bash
  2. set -euo pipefail
  3. # --- journald tee: log to journal even when run manually ---
  4. if [[ -z "${INVOCATION_ID:-}" ]] && command -v systemd-cat >/dev/null 2>&1; then
  5. exec > >(tee >(systemd-cat -t vmess-domain-rotator -p info)) \
  6. 2> >(tee >(systemd-cat -t vmess-domain-rotator -p err) >&2)
  7. fi
  8. timestamp() {
  9. date '+%Y-%m-%d %H:%M:%S'
  10. }
  11. log() {
  12. printf '[%s] %s\n' "$(timestamp)" "$*"
  13. }
  14. log_err() {
  15. printf '[%s] %s\n' "$(timestamp)" "$*" >&2
  16. }
  17. SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
  18. DEFAULT_APP_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)"
  19. APP_DIR="$(git -C "$DEFAULT_APP_DIR" rev-parse --show-toplevel 2>/dev/null || printf '%s' "$DEFAULT_APP_DIR")"
  20. force_commit="${GIT_FORCE_COMMIT:-0}"
  21. if [[ "${1:-}" == "--force-commit" ]]; then
  22. force_commit="1"
  23. shift
  24. fi
  25. if [[ ! "$force_commit" =~ ^[01]$ ]]; then
  26. log "[vmess-domain-rotator] invalid GIT_FORCE_COMMIT=${force_commit}, expected 0 or 1"
  27. exit 1
  28. fi
  29. CONFIG_PATH="${1:-${APP_DIR}/config.server.json}"
  30. # --- adaptive interval check ---
  31. if [[ "$force_commit" != "1" ]]; then
  32. peak_start=$((10#${PEAK_START_HOUR:-19}))
  33. peak_end=$((10#${PEAK_END_HOUR:-24}))
  34. peak_tz="${PEAK_TZ:-Asia/Shanghai}"
  35. peak_int=$((10#${PEAK_INTERVAL_MIN:-10}))
  36. offpeak_int=$((10#${OFFPEAK_INTERVAL_MIN:-30}))
  37. # Get current hour and minute in peak timezone
  38. curr_hour=$((10#$(TZ="$peak_tz" date '+%H')))
  39. curr_min=$((10#$(TZ="$peak_tz" date '+%M')))
  40. in_peak=0
  41. if [[ "$peak_start" -eq "$peak_end" ]]; then
  42. # start == end: no peak window defined, always off-peak
  43. in_peak=0
  44. elif [[ "$peak_start" -lt "$peak_end" ]]; then
  45. # Normal range (e.g. 8 to 22)
  46. if [[ "$curr_hour" -ge "$peak_start" ]] && [[ "$curr_hour" -lt "$peak_end" ]]; then
  47. in_peak=1
  48. fi
  49. else
  50. # Crossing midnight (e.g. peak-start 22, peak-end 2)
  51. if [[ "$curr_hour" -ge "$peak_start" ]] || [[ "$curr_hour" -lt "$peak_end" ]]; then
  52. in_peak=1
  53. fi
  54. fi
  55. if [[ "$in_peak" -eq 0 ]]; then
  56. # Outside peak hours: only execute near offpeak_int minute boundaries (e.g. 0, 30)
  57. # Allow drift tolerance of half the timer tick (peak_int / 2)
  58. remainder=$((curr_min % offpeak_int))
  59. tolerance=$(( (peak_int + 1) / 2 ))
  60. if [[ $remainder -gt $tolerance ]] && [[ $remainder -lt $((offpeak_int - tolerance)) ]]; then
  61. log "[vmess-domain-rotator] off-peak run skipped: hour=${curr_hour} minute=${curr_min} (remainder=${remainder}m, next at ${offpeak_int}m boundary)"
  62. exit 0
  63. fi
  64. fi
  65. # During peak hours: always execute (timer already fires at peak_int interval)
  66. fi
  67. export GIT_TERMINAL_PROMPT=0
  68. commit_name="${GIT_COMMIT_NAME:-vmess-domain-rotator}"
  69. commit_email="${GIT_COMMIT_EMAIL:-vmess-domain-rotator@localhost}"
  70. runtime_branch="${GIT_RUNTIME_BRANCH:-runtime-state}"
  71. push_remote="${GIT_PUSH_REMOTE:-origin}"
  72. push_enabled="${GIT_PUSH_ENABLED:-1}"
  73. push_required="${GIT_PUSH_REQUIRED:-${push_enabled}}"
  74. http_user="${GIT_HTTP_USERNAME:-git}"
  75. http_token="${GIT_HTTP_TOKEN:-}"
  76. http_token_file="${GIT_HTTP_TOKEN_FILE:-}"
  77. credential_helper="${GIT_CREDENTIAL_HELPER:-}"
  78. ts="$(date -u +"%Y-%m-%dT%H:%M:%SZ")"
  79. if [[ -z "$http_token" ]] && [[ -n "$http_token_file" ]] && [[ -r "$http_token_file" ]]; then
  80. http_token="$(tr -d '\r\n' < "$http_token_file")"
  81. fi
  82. git_auth_opts=()
  83. if [[ -n "$http_token" ]]; then
  84. auth_b64="$(printf '%s:%s' "$http_user" "$http_token" | base64 | tr -d '\n')"
  85. git_auth_opts=(-c "http.extraHeader=Authorization: Basic ${auth_b64}")
  86. fi
  87. git_auth() {
  88. local dir="$1"
  89. shift
  90. if [[ -n "$credential_helper" ]]; then
  91. git -C "$dir" -c credential.helper="$credential_helper" "${git_auth_opts[@]}" "$@"
  92. else
  93. git -C "$dir" "${git_auth_opts[@]}" "$@"
  94. fi
  95. }
  96. output_settings_json="$(/usr/bin/python3 "${APP_DIR}/scripts/domain_updater.py" --config "$CONFIG_PATH" --print-output-settings)"
  97. mapfile -t output_settings < <(
  98. printf '%s' "$output_settings_json" | /usr/bin/python3 -c '
  99. import json, sys
  100. settings = json.load(sys.stdin)
  101. for key in ["runtime_dir", "selected_text_path", "selected_json_path", "state_path", "vars_path"]:
  102. print(settings[key])
  103. '
  104. )
  105. RUNTIME_DIR="${output_settings[0]}"
  106. SELECTED_TEXT_FILE="${output_settings[1]}"
  107. SELECTED_JSON_FILE="${output_settings[2]}"
  108. STATE_FILE="${output_settings[3]}"
  109. VARS_FILE="${output_settings[4]}"
  110. repo_relpath() {
  111. /usr/bin/python3 - "$APP_DIR" "$1" <<'PY'
  112. import os
  113. import sys
  114. base = os.path.realpath(sys.argv[1])
  115. path = os.path.realpath(sys.argv[2])
  116. try:
  117. common = os.path.commonpath([base, path])
  118. except ValueError:
  119. common = ""
  120. if common != base:
  121. print("")
  122. else:
  123. print(os.path.relpath(path, base))
  124. PY
  125. }
  126. json_field() {
  127. local key="$1"
  128. /usr/bin/python3 -c '
  129. import json
  130. import sys
  131. key = sys.argv[1]
  132. try:
  133. obj = json.load(sys.stdin)
  134. except Exception:
  135. print("")
  136. else:
  137. value = obj.get(key, "")
  138. print("" if value is None else str(value))
  139. ' "$key"
  140. }
  141. updater_output="$(/usr/bin/python3 "${APP_DIR}/scripts/domain_updater.py" --config "$CONFIG_PATH")"
  142. printf '%s\n' "$updater_output"
  143. updater_status="$(printf '%s' "$updater_output" | json_field status 2>/dev/null || echo "")"
  144. if [[ "$updater_status" == "error_use_last_good" ]]; then
  145. log "[vmess-domain-rotator] warning: updater using fallback value (source error)"
  146. fi
  147. if [[ ! -f "$SELECTED_TEXT_FILE" ]]; then
  148. log "[vmess-domain-rotator] selected value file missing after updater run (${SELECTED_TEXT_FILE}), skip git commit"
  149. exit 0
  150. fi
  151. after="$(tr -d '\r\n' < "$SELECTED_TEXT_FILE")"
  152. if [[ -z "$after" ]]; then
  153. log "[vmess-domain-rotator] empty selected value, skip git commit"
  154. exit 0
  155. fi
  156. selected_rel="$(repo_relpath "$SELECTED_TEXT_FILE")"
  157. selected_json_rel="$(repo_relpath "$SELECTED_JSON_FILE")"
  158. vars_rel="$(repo_relpath "$VARS_FILE")"
  159. if [[ -z "$selected_rel" ]]; then
  160. log "[vmess-domain-rotator] selected value file is outside repo (${SELECTED_TEXT_FILE}), skip git commit"
  161. exit 0
  162. fi
  163. if ! command -v git >/dev/null 2>&1; then
  164. log "[vmess-domain-rotator] git not found, skip git commit"
  165. exit 0
  166. fi
  167. if ! git -C "$APP_DIR" rev-parse --is-inside-work-tree >/dev/null 2>&1; then
  168. log "[vmess-domain-rotator] not a git repo at ${APP_DIR}, skip git commit"
  169. log "[vmess-domain-rotator] hint: reinstall service from a git clone path"
  170. exit 0
  171. fi
  172. if ! git -C "$APP_DIR" rev-parse --verify HEAD >/dev/null 2>&1; then
  173. log "[vmess-domain-rotator] repo has no commits yet, skip git commit"
  174. exit 0
  175. fi
  176. if ! git -C "$APP_DIR" remote get-url "$push_remote" >/dev/null 2>&1; then
  177. push_remote=""
  178. while IFS= read -r r; do
  179. push_remote="$r"
  180. break
  181. done < <(git -C "$APP_DIR" remote)
  182. fi
  183. work_dir=""
  184. cleanup_worktree="0"
  185. current_branch="$(git -C "$APP_DIR" symbolic-ref --quiet --short HEAD 2>/dev/null || true)"
  186. if [[ "$current_branch" == "$runtime_branch" ]]; then
  187. work_dir="$APP_DIR"
  188. else
  189. work_dir="$(mktemp -d "${TMPDIR:-/tmp}/vmess-runtime-state.XXXXXX")"
  190. cleanup_worktree="1"
  191. if git -C "$APP_DIR" show-ref --verify --quiet "refs/heads/${runtime_branch}"; then
  192. git -C "$APP_DIR" worktree add --force "$work_dir" "$runtime_branch"
  193. else
  194. if [[ -n "$push_remote" ]] && git_auth "$APP_DIR" ls-remote --exit-code --heads "$push_remote" "$runtime_branch" >/dev/null 2>&1; then
  195. git_auth "$APP_DIR" fetch "$push_remote" "$runtime_branch:$runtime_branch"
  196. git -C "$APP_DIR" worktree add --force "$work_dir" "$runtime_branch"
  197. else
  198. git -C "$APP_DIR" worktree add --force --detach "$work_dir"
  199. git -C "$work_dir" checkout --orphan "$runtime_branch"
  200. git -C "$work_dir" rm -rf . >/dev/null 2>&1 || true
  201. log "[vmess-domain-rotator] initialized branch ${runtime_branch}"
  202. fi
  203. fi
  204. fi
  205. if [[ "$cleanup_worktree" == "1" ]]; then
  206. cleanup() {
  207. trap - EXIT INT TERM
  208. git -C "$APP_DIR" worktree remove --force "$work_dir" >/dev/null 2>&1 || {
  209. rm -rf "$work_dir"
  210. git -C "$APP_DIR" worktree prune >/dev/null 2>&1 || true
  211. }
  212. }
  213. trap cleanup EXIT
  214. trap 'cleanup; exit 1' INT TERM
  215. fi
  216. work_branch="$(git -C "$work_dir" symbolic-ref --quiet --short HEAD 2>/dev/null || true)"
  217. if [[ "$work_branch" != "$runtime_branch" ]]; then
  218. log "[vmess-domain-rotator] safety check failed: worktree branch is '${work_branch:-detached}', expected '${runtime_branch}'"
  219. exit 1
  220. fi
  221. before=""
  222. if before_raw="$(git -C "$work_dir" show "HEAD:${selected_rel}" 2>/dev/null)"; then
  223. before="$(printf '%s' "$before_raw" | tr -d '\r\n')"
  224. fi
  225. before_status=""
  226. if [[ -n "$selected_json_rel" ]] && before_json_raw="$(git -C "$work_dir" show "HEAD:${selected_json_rel}" 2>/dev/null)"; then
  227. before_status="$(printf '%s' "$before_json_raw" | json_field status 2>/dev/null || echo "")"
  228. fi
  229. before_vars_status=""
  230. if [[ -n "$vars_rel" ]] && before_vars_raw="$(git -C "$work_dir" show "HEAD:${vars_rel}" 2>/dev/null)"; then
  231. before_vars_status="$(printf '%s' "$before_vars_raw" | json_field STATUS 2>/dev/null || echo "")"
  232. fi
  233. status_compared="0"
  234. status_unchanged="0"
  235. if [[ -n "$updater_status" ]]; then
  236. status_unchanged="1"
  237. if [[ -n "$before_status" ]]; then
  238. status_compared="1"
  239. [[ "$updater_status" != "$before_status" ]] && status_unchanged="0"
  240. fi
  241. if [[ -n "$before_vars_status" ]]; then
  242. status_compared="1"
  243. [[ "$updater_status" != "$before_vars_status" ]] && status_unchanged="0"
  244. fi
  245. if [[ "$status_compared" != "1" ]]; then
  246. status_unchanged="0"
  247. fi
  248. fi
  249. if [[ "$force_commit" != "1" ]] && [[ -n "$before" ]] && [[ "$after" == "$before" ]] && [[ "$status_unchanged" == "1" ]]; then
  250. log "[vmess-domain-rotator] selected value unchanged (${after}), skip git commit and push"
  251. exit 0
  252. fi
  253. tracked_src_files=("$SELECTED_TEXT_FILE" "$SELECTED_JSON_FILE" "$STATE_FILE" "$VARS_FILE")
  254. tracked_rel_files=()
  255. for src in "${tracked_src_files[@]}"; do
  256. rel="$(repo_relpath "$src")"
  257. if [[ -z "$rel" ]]; then
  258. log "[vmess-domain-rotator] skip non-repo output file: ${src}"
  259. continue
  260. fi
  261. tracked_rel_files+=("$rel")
  262. dst="$work_dir/$rel"
  263. mkdir -p "$(dirname "$dst")"
  264. if [[ -f "$src" ]]; then
  265. cp "$src" "$dst"
  266. else
  267. rm -f "$dst"
  268. fi
  269. done
  270. if [[ "${#tracked_rel_files[@]}" -eq 0 ]]; then
  271. log "[vmess-domain-rotator] no repo-local output files from config (${CONFIG_PATH}), skip git commit"
  272. exit 0
  273. fi
  274. git -C "$work_dir" add -A -- "${tracked_rel_files[@]}" || true
  275. staged_changed="1"
  276. if git -C "$work_dir" diff --cached --quiet; then
  277. staged_changed="0"
  278. fi
  279. if [[ "$staged_changed" == "0" ]] && [[ "$force_commit" != "1" ]]; then
  280. log "[vmess-domain-rotator] no staged changes for ${runtime_branch}, skip git commit"
  281. exit 0
  282. fi
  283. commit_extra_args=()
  284. if [[ "$staged_changed" == "0" ]] && [[ "$force_commit" == "1" ]]; then
  285. commit_extra_args+=(--allow-empty)
  286. log "[vmess-domain-rotator] force commit enabled with unchanged content, creating empty commit"
  287. fi
  288. commit_message="chore: rotate preferred value to ${after} (${ts})"
  289. if [[ "$updater_status" == "error_use_last_good" ]]; then
  290. commit_message="chore(fallback): rotate preferred value to ${after} (${ts})"
  291. fi
  292. if [[ "$force_commit" == "1" ]]; then
  293. commit_message="manual: value ${after}, updated at ${ts}"
  294. fi
  295. git -C "$work_dir" \
  296. -c user.name="$commit_name" \
  297. -c user.email="$commit_email" \
  298. commit "${commit_extra_args[@]}" -m "$commit_message"
  299. if [[ "$push_enabled" != "1" ]]; then
  300. log "[vmess-domain-rotator] git push disabled by GIT_PUSH_ENABLED=${push_enabled}"
  301. elif [[ -z "$push_remote" ]]; then
  302. if [[ "$push_required" == "1" ]]; then
  303. log "[vmess-domain-rotator] no remote found but push is required"
  304. exit 1
  305. fi
  306. log "[vmess-domain-rotator] no remote found, skip git push"
  307. else
  308. push_ok="0"
  309. if git -C "$work_dir" rev-parse --abbrev-ref --symbolic-full-name "@{u}" >/dev/null 2>&1; then
  310. if git_auth "$work_dir" push "$push_remote" "$runtime_branch:$runtime_branch"; then
  311. push_ok="1"
  312. log "[vmess-domain-rotator] pushed to ${push_remote}/${runtime_branch}"
  313. fi
  314. else
  315. if git_auth "$work_dir" push -u "$push_remote" "$runtime_branch:$runtime_branch"; then
  316. push_ok="1"
  317. log "[vmess-domain-rotator] pushed to ${push_remote}/${runtime_branch} (set upstream)"
  318. fi
  319. fi
  320. if [[ "$push_ok" != "1" ]]; then
  321. log "[vmess-domain-rotator] git push failed"
  322. log "[vmess-domain-rotator] hint: configure non-interactive auth (credential.helper store, SSH deploy key, or GIT_HTTP_USERNAME/GIT_HTTP_TOKEN)"
  323. if [[ "$push_required" == "1" ]]; then
  324. exit 1
  325. fi
  326. fi
  327. fi
  328. log "[vmess-domain-rotator] committed output changes on ${runtime_branch}: selected value ${after} from ${RUNTIME_DIR}"