#!/usr/bin/env bash set -euo pipefail # --- journald tee: log to journal even when run manually --- if [[ -z "${INVOCATION_ID:-}" ]] && command -v systemd-cat >/dev/null 2>&1; then exec > >(tee >(systemd-cat -t vmess-domain-rotator -p info)) \ 2> >(tee >(systemd-cat -t vmess-domain-rotator -p err) >&2) fi timestamp() { date '+%Y-%m-%d %H:%M:%S' } log() { printf '[%s] %s\n' "$(timestamp)" "$*" } log_err() { printf '[%s] %s\n' "$(timestamp)" "$*" >&2 } SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" DEFAULT_APP_DIR="$(cd "${SCRIPT_DIR}/../.." && pwd)" APP_DIR="${APP_DIR:-$(git -C "$DEFAULT_APP_DIR" rev-parse --show-toplevel 2>/dev/null || printf '%s' "$DEFAULT_APP_DIR")}" PYTHON_BIN="${PYTHON_BIN:-}" if [[ -z "$PYTHON_BIN" ]]; then if [[ -x "/usr/bin/python3" ]]; then PYTHON_BIN="/usr/bin/python3" else PYTHON_BIN="$(command -v python3 || echo "python3")" fi fi force_commit="${GIT_FORCE_COMMIT:-0}" if [[ "${1:-}" == "--force-commit" ]]; then force_commit="1" shift fi if [[ ! "$force_commit" =~ ^[01]$ ]]; then log "[vmess-domain-rotator] invalid GIT_FORCE_COMMIT=${force_commit}, expected 0 or 1" exit 1 fi if [[ -z "${1:-}" ]]; then if [[ -r "${APP_DIR}/configs/config.server.json" ]]; then CONFIG_PATH="${APP_DIR}/configs/config.server.json" else CONFIG_PATH="${APP_DIR}/config.server.json" fi else CONFIG_PATH="$1" if [[ ! -r "$CONFIG_PATH" ]] && [[ -r "${APP_DIR}/configs/${CONFIG_PATH}" ]]; then CONFIG_PATH="${APP_DIR}/configs/${CONFIG_PATH}" elif [[ ! -r "$CONFIG_PATH" ]] && [[ -r "${APP_DIR}/configs/$(basename "$CONFIG_PATH")" ]]; then CONFIG_PATH="${APP_DIR}/configs/$(basename "$CONFIG_PATH")" fi fi # --- adaptive interval check --- if [[ "$force_commit" != "1" ]]; then peak_start=$((10#${PEAK_START_HOUR:-19})) peak_end=$((10#${PEAK_END_HOUR:-24})) peak_tz="${PEAK_TZ:-Asia/Shanghai}" peak_int=$((10#${PEAK_INTERVAL_MIN:-10})) offpeak_int=$((10#${OFFPEAK_INTERVAL_MIN:-30})) # Get current hour and minute in peak timezone curr_hour=$((10#$(TZ="$peak_tz" date '+%H'))) curr_min=$((10#$(TZ="$peak_tz" date '+%M'))) in_peak=0 if [[ "$peak_start" -eq "$peak_end" ]]; then # start == end: no peak window defined, always off-peak in_peak=0 elif [[ "$peak_start" -lt "$peak_end" ]]; then # Normal range (e.g. 8 to 22) if [[ "$curr_hour" -ge "$peak_start" ]] && [[ "$curr_hour" -lt "$peak_end" ]]; then in_peak=1 fi else # Crossing midnight (e.g. peak-start 22, peak-end 2) if [[ "$curr_hour" -ge "$peak_start" ]] || [[ "$curr_hour" -lt "$peak_end" ]]; then in_peak=1 fi fi if [[ "$in_peak" -eq 0 ]]; then # Outside peak hours: only execute near offpeak_int minute boundaries (e.g. 0, 30) # Allow drift tolerance of half the timer tick (peak_int / 2) remainder=$((curr_min % offpeak_int)) tolerance=$(( (peak_int + 1) / 2 )) if [[ $remainder -gt $tolerance ]] && [[ $remainder -lt $((offpeak_int - tolerance)) ]]; then log "[vmess-domain-rotator] off-peak run skipped: hour=${curr_hour} minute=${curr_min} (remainder=${remainder}m, next at ${offpeak_int}m boundary)" exit 0 fi fi # During peak hours: always execute (timer already fires at peak_int interval) fi export GIT_TERMINAL_PROMPT=0 commit_name="${GIT_COMMIT_NAME:-vmess-domain-rotator}" commit_email="${GIT_COMMIT_EMAIL:-vmess-domain-rotator@localhost}" runtime_branch="${GIT_RUNTIME_BRANCH:-runtime-state}" push_remote="${GIT_PUSH_REMOTE:-origin}" push_enabled="${GIT_PUSH_ENABLED:-1}" push_required="${GIT_PUSH_REQUIRED:-${push_enabled}}" http_user="${GIT_HTTP_USERNAME:-git}" http_token="${GIT_HTTP_TOKEN:-}" http_token_file="${GIT_HTTP_TOKEN_FILE:-}" credential_helper="${GIT_CREDENTIAL_HELPER:-}" max_state_commits="${GIT_MAX_STATE_COMMITS:-50}" ts="$(date -u +"%Y-%m-%dT%H:%M:%SZ")" if [[ -z "$http_token" ]] && [[ -n "$http_token_file" ]] && [[ -r "$http_token_file" ]]; then http_token="$(tr -d '\r\n' < "$http_token_file")" fi git_auth() { local dir="$1" shift local -a env_vars=() if [[ -n "$http_token" ]]; then local auth_b64 auth_b64="$(printf '%s:%s' "$http_user" "$http_token" | base64 | tr -d '\n')" env_vars+=( "GIT_CONFIG_COUNT=1" "GIT_CONFIG_KEY_0=http.extraHeader" "GIT_CONFIG_VALUE_0=Authorization: Basic ${auth_b64}" ) fi if [[ -n "$credential_helper" ]]; then env "${env_vars[@]}" git -C "$dir" -c credential.helper="$credential_helper" "$@" else env "${env_vars[@]}" git -C "$dir" "$@" fi } output_settings_json="$("$PYTHON_BIN" "${APP_DIR}/scripts/domain_updater.py" --config "$CONFIG_PATH" --print-output-settings)" output_settings=() while IFS= read -r line; do output_settings+=("$line") done < <( printf '%s' "$output_settings_json" | "$PYTHON_BIN" -c ' import json, sys settings = json.load(sys.stdin) for key in ["runtime_dir", "selected_text_path", "selected_json_path", "state_path", "vars_path"]: print(settings[key]) ' ) RUNTIME_DIR="${output_settings[0]}" SELECTED_TEXT_FILE="${output_settings[1]}" SELECTED_JSON_FILE="${output_settings[2]}" STATE_FILE="${output_settings[3]}" VARS_FILE="${output_settings[4]}" repo_relpath() { "$PYTHON_BIN" - "$APP_DIR" "$1" <<'PY' import os import sys base = os.path.realpath(sys.argv[1]) path = os.path.realpath(sys.argv[2]) try: common = os.path.commonpath([base, path]) except ValueError: common = "" if common != base: print("") else: print(os.path.relpath(path, base)) PY } json_field() { local key="$1" "$PYTHON_BIN" -c ' import json import sys key = sys.argv[1] try: obj = json.load(sys.stdin) except Exception: print("") else: value = obj.get(key, "") print("" if value is None else str(value)) ' "$key" } tmp_result_json="$(mktemp "${TMPDIR:-/tmp}/vmess_result.XXXXXX")" trap 'rm -f "$tmp_result_json"' EXIT INT TERM "$PYTHON_BIN" "${APP_DIR}/scripts/domain_updater.py" --config "$CONFIG_PATH" --result-json-file "$tmp_result_json" if [[ -s "$tmp_result_json" ]]; then updater_status="$(json_field status < "$tmp_result_json" 2>/dev/null || echo "")" else updater_status="" fi rm -f "$tmp_result_json" if [[ "$updater_status" == "error_use_last_good" || "$updater_status" == "fallback_last_good" ]]; then log "[vmess-domain-rotator] warning: updater using fallback value (${updater_status})" fi if [[ ! -f "$SELECTED_TEXT_FILE" ]]; then log "[vmess-domain-rotator] selected value file missing after updater run (${SELECTED_TEXT_FILE}), skip git commit" exit 0 fi after="$(tr -d '\r\n' < "$SELECTED_TEXT_FILE")" if [[ -z "$after" ]]; then log "[vmess-domain-rotator] empty selected value, skip git commit" exit 0 fi selected_rel="$(repo_relpath "$SELECTED_TEXT_FILE")" selected_json_rel="$(repo_relpath "$SELECTED_JSON_FILE")" vars_rel="$(repo_relpath "$VARS_FILE")" if [[ -z "$selected_rel" ]]; then log "[vmess-domain-rotator] selected value file is outside repo (${SELECTED_TEXT_FILE}), skip git commit" exit 0 fi if ! command -v git >/dev/null 2>&1; then log "[vmess-domain-rotator] git not found, skip git commit" exit 0 fi if ! git -C "$APP_DIR" rev-parse --is-inside-work-tree >/dev/null 2>&1; then log "[vmess-domain-rotator] not a git repo at ${APP_DIR}, skip git commit" log "[vmess-domain-rotator] hint: reinstall service from a git clone path" exit 0 fi if ! git -C "$APP_DIR" rev-parse --verify HEAD >/dev/null 2>&1; then log "[vmess-domain-rotator] repo has no commits yet, skip git commit" exit 0 fi if ! git -C "$APP_DIR" remote get-url "$push_remote" >/dev/null 2>&1; then push_remote="" while IFS= read -r r; do push_remote="$r" break done < <(git -C "$APP_DIR" remote) fi work_dir="" cleanup_worktree="0" current_branch="$(git -C "$APP_DIR" symbolic-ref --quiet --short HEAD 2>/dev/null || true)" if [[ "$current_branch" == "$runtime_branch" ]]; then work_dir="$APP_DIR" else work_dir="$(mktemp -d "${TMPDIR:-/tmp}/vmess-runtime-state.XXXXXX")" cleanup_worktree="1" if git -C "$APP_DIR" show-ref --verify --quiet "refs/heads/${runtime_branch}"; then git -C "$APP_DIR" worktree add --force "$work_dir" "$runtime_branch" else remote_branch_exists="0" if [[ -n "$push_remote" ]]; then set +e git_auth "$APP_DIR" ls-remote --exit-code --heads "$push_remote" "$runtime_branch" >/dev/null 2>&1 ls_remote_rc=$? set -e if [[ $ls_remote_rc -eq 0 ]]; then remote_branch_exists="1" elif [[ $ls_remote_rc -eq 2 ]]; then remote_branch_exists="0" else log_err "[vmess-domain-rotator] fatal: git ls-remote failed with exit code ${ls_remote_rc} on remote ${push_remote}" exit 1 fi fi if [[ "$remote_branch_exists" == "1" ]]; then git_auth "$APP_DIR" fetch "$push_remote" "$runtime_branch:$runtime_branch" git -C "$APP_DIR" worktree add --force "$work_dir" "$runtime_branch" else git -C "$APP_DIR" worktree add --force --detach "$work_dir" git -C "$work_dir" checkout --orphan "$runtime_branch" git -C "$work_dir" rm -rf . >/dev/null 2>&1 || true log "[vmess-domain-rotator] initialized branch ${runtime_branch}" fi fi fi if [[ "$cleanup_worktree" == "1" ]]; then cleanup() { trap - EXIT INT TERM git -C "$APP_DIR" worktree remove --force "$work_dir" >/dev/null 2>&1 || { rm -rf "$work_dir" git -C "$APP_DIR" worktree prune >/dev/null 2>&1 || true } } trap cleanup EXIT trap 'cleanup; exit 1' INT TERM fi work_branch="$(git -C "$work_dir" symbolic-ref --quiet --short HEAD 2>/dev/null || true)" if [[ "$work_branch" != "$runtime_branch" ]]; then log "[vmess-domain-rotator] safety check failed: worktree branch is '${work_branch:-detached}', expected '${runtime_branch}'" exit 1 fi before="" if before_raw="$(git -C "$work_dir" show "HEAD:${selected_rel}" 2>/dev/null)"; then before="$(printf '%s' "$before_raw" | tr -d '\r\n')" fi before_status="" if [[ -n "$selected_json_rel" ]] && before_json_raw="$(git -C "$work_dir" show "HEAD:${selected_json_rel}" 2>/dev/null)"; then before_status="$(printf '%s' "$before_json_raw" | json_field status 2>/dev/null || echo "")" fi before_vars_status="" if [[ -n "$vars_rel" ]] && before_vars_raw="$(git -C "$work_dir" show "HEAD:${vars_rel}" 2>/dev/null)"; then before_vars_status="$(printf '%s' "$before_vars_raw" | json_field STATUS 2>/dev/null || echo "")" fi status_compared="0" status_unchanged="0" if [[ -n "$updater_status" ]]; then status_unchanged="1" if [[ -n "$before_status" ]]; then status_compared="1" [[ "$updater_status" != "$before_status" ]] && status_unchanged="0" fi if [[ -n "$before_vars_status" ]]; then status_compared="1" [[ "$updater_status" != "$before_vars_status" ]] && status_unchanged="0" fi if [[ "$status_compared" != "1" ]]; then status_unchanged="0" fi fi is_fallback="0" if [[ "$updater_status" == "error_use_last_good" || "$updater_status" == "fallback_last_good" ]]; then is_fallback="1" fi if [[ "$force_commit" != "1" ]] && [[ -n "$before" ]] && [[ "$after" == "$before" ]]; then if [[ "$status_unchanged" == "1" || "$is_fallback" == "1" ]]; then log "[vmess-domain-rotator] selected value unchanged (${after}) [status=${updater_status:-unknown}], skip git commit and push" exit 0 fi fi tracked_src_files=("$SELECTED_TEXT_FILE" "$SELECTED_JSON_FILE" "$STATE_FILE" "$VARS_FILE") tracked_rel_files=() for src in "${tracked_src_files[@]}"; do rel="$(repo_relpath "$src")" if [[ -z "$rel" ]]; then log "[vmess-domain-rotator] skip non-repo output file: ${src}" continue fi tracked_rel_files+=("$rel") dst="$work_dir/$rel" mkdir -p "$(dirname "$dst")" if [[ -f "$src" ]]; then if [[ "$src" != "$dst" ]]; then cp "$src" "$dst" fi else rm -f "$dst" fi done if [[ "${#tracked_rel_files[@]}" -eq 0 ]]; then log "[vmess-domain-rotator] no repo-local output files from config (${CONFIG_PATH}), skip git commit" exit 0 fi git -C "$work_dir" add -A -- "${tracked_rel_files[@]}" || true staged_changed="1" if git -C "$work_dir" diff --cached --quiet; then staged_changed="0" fi if [[ "$staged_changed" == "0" ]] && [[ "$force_commit" != "1" ]]; then log "[vmess-domain-rotator] no staged changes for ${runtime_branch}, skip git commit" exit 0 fi commit_extra_args=() if [[ "$staged_changed" == "0" ]] && [[ "$force_commit" == "1" ]]; then commit_extra_args+=(--allow-empty) log "[vmess-domain-rotator] force commit enabled with unchanged content, creating empty commit" fi commit_message="chore: rotate preferred value to ${after} (${ts})" if [[ "$updater_status" == "error_use_last_good" || "$updater_status" == "fallback_last_good" ]]; then commit_message="chore(fallback): rotate preferred value to ${after} (${ts})" fi if [[ "$force_commit" == "1" ]]; then commit_message="manual: value ${after}, updated at ${ts}" fi commit_cmd=(git -C "$work_dir" -c user.name="$commit_name" -c user.email="$commit_email" commit) if [[ "${#commit_extra_args[@]}" -gt 0 ]]; then commit_cmd+=("${commit_extra_args[@]}") fi commit_cmd+=(-m "$commit_message") "${commit_cmd[@]}" history_trimmed="0" if [[ "$max_state_commits" -gt 0 ]]; then commit_count="$(git -C "$work_dir" rev-list --count HEAD 2>/dev/null || echo "1")" if [[ "$commit_count" -gt "$max_state_commits" ]]; then log "[vmess-domain-rotator] runtime history reached ${commit_count} commits, trimming older history (retaining last ${max_state_commits})" git -C "$work_dir" branch -f "${runtime_branch}-untrimmed" HEAD >/dev/null 2>&1 || true base_commit="$(git -C "$work_dir" rev-parse "HEAD~$((max_state_commits - 1))" 2>/dev/null || true)" if [[ -n "$base_commit" ]]; then tree_sha="$(git -C "$work_dir" rev-parse "${base_commit}^{tree}" 2>/dev/null || true)" if [[ -n "$tree_sha" ]]; then root_commit="$(git -C "$work_dir" commit-tree "$tree_sha" -m "chore(prune): baseline snapshot before rolling rotation" 2>/dev/null || true)" if [[ -n "$root_commit" ]] && git -C "$work_dir" rebase --onto "$root_commit" "$base_commit" "$runtime_branch" >/dev/null 2>&1; then history_trimmed="1" log "[vmess-domain-rotator] history successfully pruned to ${max_state_commits} commits" else git -C "$work_dir" reset --hard "${runtime_branch}-untrimmed" >/dev/null 2>&1 || true fi fi fi fi fi if [[ "$push_enabled" != "1" ]]; then log "[vmess-domain-rotator] git push disabled by GIT_PUSH_ENABLED=${push_enabled}" elif [[ -z "$push_remote" ]]; then if [[ "$push_required" == "1" ]]; then log "[vmess-domain-rotator] no remote found but push is required" exit 1 fi log "[vmess-domain-rotator] no remote found, skip git push" else push_opts=() if [[ "$history_trimmed" == "1" ]]; then push_opts+=(--force-with-lease) fi push_opts+=("$push_remote" "$runtime_branch:$runtime_branch") push_ok="0" if git -C "$work_dir" rev-parse --abbrev-ref --symbolic-full-name "@{u}" >/dev/null 2>&1; then if git_auth "$work_dir" push "${push_opts[@]}"; then push_ok="1" log "[vmess-domain-rotator] pushed to ${push_remote}/${runtime_branch}" elif [[ "$history_trimmed" == "1" ]]; then log "[vmess-domain-rotator] remote rejected force-with-lease for history trimming, restoring full history and retrying normal push" git -C "$work_dir" reset --hard "${runtime_branch}-untrimmed" >/dev/null 2>&1 || true if git_auth "$work_dir" push "$push_remote" "$runtime_branch:$runtime_branch"; then push_ok="1" log "[vmess-domain-rotator] pushed to ${push_remote}/${runtime_branch} (fallback to append-only)" fi fi else if git_auth "$work_dir" push -u "${push_opts[@]}"; then push_ok="1" log "[vmess-domain-rotator] pushed to ${push_remote}/${runtime_branch} (set upstream)" elif [[ "$history_trimmed" == "1" ]]; then log "[vmess-domain-rotator] remote rejected force-with-lease for history trimming, restoring full history and retrying normal push" git -C "$work_dir" reset --hard "${runtime_branch}-untrimmed" >/dev/null 2>&1 || true if git_auth "$work_dir" push -u "$push_remote" "$runtime_branch:$runtime_branch"; then push_ok="1" log "[vmess-domain-rotator] pushed to ${push_remote}/${runtime_branch} (set upstream fallback)" fi fi fi git -C "$work_dir" branch -D "${runtime_branch}-untrimmed" >/dev/null 2>&1 || true if [[ "$push_ok" != "1" ]]; then log "[vmess-domain-rotator] git push failed" log "[vmess-domain-rotator] hint: configure non-interactive auth (credential.helper store, SSH deploy key, or GIT_HTTP_USERNAME/GIT_HTTP_TOKEN)" if [[ "$push_required" == "1" ]]; then exit 1 fi fi fi log "[vmess-domain-rotator] committed output changes on ${runtime_branch}: selected value ${after} from ${RUNTIME_DIR}"