Преглед на файлове

fix: for git push authentication

Dew-OF-Aurora преди 2 седмици
родител
ревизия
f3e003f6d3

+ 108 - 95
CLAUDE.md

@@ -1,121 +1,134 @@
 # CLAUDE.md
+
 This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
 
 ## Project Overview
 
-VMess domain rotator - automated pipeline that fetches preferred domains from an API, selects the best candidate, and exports runtime files for Sub-Store and V2Ray integration.
+VMess domain rotator: fetch candidate domains from an API, select a preferred domain, write runtime artifacts, and optionally auto-commit runtime changes to a dedicated branch.
 
-## Key Commands
+## Common Commands
 
-### Development
+### Validate scripts
 ```bash
-# Syntax check Python scripts
 python3 -m py_compile scripts/domain_updater.py
 python3 -m py_compile scripts/update_vmess_links.py
+```
 
-# Manual run (single update)
+### Run domain selection once
+```bash
 python3 scripts/domain_updater.py --config config.json
+```
 
-# Manual run with conditional git commit (commits only if domain changed)
+### Run scheduler entrypoint with conditional git commit
+```bash
 bash scripts/run_update_and_commit.sh config.json
+```
 
-# Update VMess base64 links with selected domain
-python3 scripts/update_vmess_links.py --input ./nodes.txt --output ./nodes.updated.txt --domain-file ./runtime/current_domain.txt
+### Update VMess links from selected domain
+```bash
+python3 scripts/update_vmess_links.py \
+  --input ./nodes.txt \
+  --output ./nodes.updated.txt \
+  --domain-file ./runtime/current_domain.txt
+```
 
-# Update specific nodes by regex
-python3 scripts/update_vmess_links.py --input ./nodes.txt --output ./nodes.updated.txt --domain-file ./runtime/current_domain.txt --name-regex "(argo|cf|vm)"
+### Update only matching node names
+```bash
+python3 scripts/update_vmess_links.py \
+  --input ./nodes.txt \
+  --output ./nodes.updated.txt \
+  --domain-file ./runtime/current_domain.txt \
+  --name-regex "(argo|cf|vm)"
+```
 
-# Local smoke test HTTP endpoint for runtime outputs
+### Local runtime smoke test
+```bash
 python3 -m http.server 8080 --directory runtime
+curl http://127.0.0.1:8080/current_domain.json
 ```
 
-### Deployment (Debian)
+### Debian systemd install/uninstall
 ```bash
-# Install systemd service+timer (uses current git repo directory)
 sudo bash scripts/install_debian.sh
-
-# Install with custom timer interval
 sudo bash scripts/install_debian.sh --interval 5min
-
-# Uninstall (keeps git repository and files)
+sudo bash scripts/install_debian.sh --git-push 0
+sudo bash scripts/install_debian.sh --git-http-username aurora --git-http-token-file /root/.config/vmess-token --git-use-credential-store 1
 sudo bash scripts/uninstall_debian.sh
+sudo bash scripts/uninstall_debian.sh --keep-auth-files
 ```
 
-## Architecture
-
-### Core Flow
-1. `scripts/domain_updater.py` - Main entrypoint that:
-   - Fetches domain candidates from API (configured in `config.json`)
-   - Parses response using JSON path or regex
-   - Optionally scores candidates based on API fields
-   - Optionally healthchecks domains (TLS handshake)
-   - Selects best domain and writes runtime outputs
-   - Falls back to `last_good_domain` from state.json if all checks fail
-
-2. `scripts/run_update_and_commit.sh` - Scheduler entrypoint that:
-   - Runs domain_updater.py
-   - Compares before/after domain
-   - Commits to `runtime-state` branch if domain changed
-   - Pushes to remote if configured
-
-3. `scripts/update_vmess_links.py` - Post-processor for VMess subscription links:
-   - Decodes vmess:// base64 payloads
-   - Updates `add` field with selected domain
-   - Supports node name filtering by regex
-
-### Configuration Structure
-- `config.json` - Single runtime config file containing:
-  - `api`: endpoint URL, method, headers, timeout
-  - `parser`: JSON paths (`field_paths`) or regex for extracting domains
-  - `domain_filter`: include/exclude patterns (e.g., filter out IPv4 addresses)
-  - `scoring`: ranking logic based on API fields or API order
-  - `healthcheck`: TLS handshake verification settings
-  - `selection`: top_n candidates to consider
-  - `output`: runtime directory and file paths
-  - `v2ray`: template file token replacement (optional)
-
-### Output Files
-All runtime outputs are written to `runtime/`:
-- `current_domain.txt` - Plain text domain
-- `current_domain.json` - JSON payload with domain, timestamp, status, source_count
-- `state.json` - Persistent state including `last_good_domain` for fallback
-- `substore_vars.json` - Variables for Sub-Store operator scripts
-
-### Sub-Store Integration
-`substore/operator_template.js` - Operator script that:
-- Fetches `current_domain.json` via HTTP using `$substore.http.get`
-- Caches domain with TTL (5 minutes by default)
-- Replaces VMess `server` field for nodes matching `NODE_NAME_REGEX`
-- Must be configured with actual DOMAIN_JSON_URL in production
-
-### Systemd Deployment
-- Service runs `run_update_and_commit.sh` oneshot
-- Timer triggers every 1h by default (configurable via install_debian.sh)
-- Uses current git repository directory (in-place mode only)
-- Uses the installing user (from SUDO_USER) as service user
-- Runtime state committed to `runtime-state` branch
-- No separate service user created - uses existing user's git credentials
-
-## Important Behaviors
-
-- **Fallback**: If all healthchecks fail, script uses `last_good_domain` from `runtime/state.json`
-- **Conditional commits**: `run_update_and_commit.sh` only commits when domain actually changes
-- **Branch separation**: Runtime state goes to `runtime-state` branch via git worktree
-- **API order mode**: For vps789 Top20, set `scoring.use_api_order=true` to trust API ranking
-- **Domain filtering**: Use `domain_filter.exclude_regex` to filter out IPv4 addresses when API returns mixed results
-- **Sub-Store caching**: Operator uses `scriptResourceCache` with TTL to avoid excessive HTTP requests
-
-## Configuration Notes
-
-For vps789 Top20 API specifically:
-- Use `parser.field_paths: ["data.good[].ip"]`
-- Enable `scoring.use_api_order: true` to respect API ranking
-- Set `healthcheck.enabled: false` if you want pure API-based selection
-- Add IPv4 exclude regex to `domain_filter.exclude_regex` to filter out IP addresses
-
-## Git Workflow
-
-- Main branch: `main` (source code)
-- Runtime branch: `runtime-state` (auto-committed domain updates)
-- Commits to runtime-state use generic git identity: `vmess-domain-rotator@localhost`
-- Script creates runtime-state branch automatically if missing
+## Testing and verification status
+
+- There is currently no dedicated `tests/` directory or unit test suite.
+- Primary verification is Python syntax check (`py_compile`) plus manual script runs.
+
+## Architecture (big picture)
+
+### 1) Domain selection pipeline
+`scripts/domain_updater.py` is the core pipeline:
+- Calls the configured API (`api` block in `config.json`).
+- Extracts candidates via `parser.field_paths`, `parser.json_paths`, or regex fallback.
+- Normalizes and de-duplicates domains/IPs.
+- Applies include/exclude filtering (`domain_filter`).
+- Optionally ranks records (`scoring`) using API fields/time windows or API order.
+- Optionally healthchecks candidates with TLS handshake (`healthcheck`).
+- Selects winner from scored/check results (`selection.top_n`).
+- Writes runtime artifacts under `runtime/`.
+- Falls back to `last_good_domain` from `runtime/state.json` when selection fails.
+
+### 2) Runtime-state git automation
+`scripts/run_update_and_commit.sh` wraps the updater and git workflow:
+- Resolves git top-level robustly and skips commit if service is not running inside a git repo.
+- Skips commit if domain unchanged or empty.
+- Commits only runtime outputs (`runtime/current_domain.txt`, `runtime/current_domain.json`, `runtime/state.json`, `runtime/substore_vars.json`).
+- Targets `runtime-state` branch (configurable via `GIT_RUNTIME_BRANCH`) and includes a branch safety check before staging.
+- Uses a temporary git worktree when current branch is not `runtime-state`.
+- Supports non-interactive push auth in two modes:
+  - credential helper mode (`GIT_CREDENTIAL_HELPER`, e.g. `store`)
+  - header mode (`GIT_HTTP_USERNAME` + `GIT_HTTP_TOKEN`/`GIT_HTTP_TOKEN_FILE`)
+- `GIT_PUSH_REQUIRED` defaults to `GIT_PUSH_ENABLED`; when enabled, push failure returns non-zero for systemd visibility.
+- Disables interactive git prompts via `GIT_TERMINAL_PROMPT=0` to avoid systemd hangs.
+
+
+### 3) VMess subscription post-processing
+`scripts/update_vmess_links.py`:
+- Reads subscription lines (plain text or full base64 subscription).
+- Decodes each `vmess://` payload JSON.
+- Replaces `add` field with selected domain.
+- Optionally filters by node name regex (`ps` field).
+- Re-encodes output and prints JSON summary stats.
+
+### 4) Sub-Store runtime consumer
+`substore/operator_template.js`:
+- Fetches `runtime/current_domain.json` over HTTP.
+- Caches domain in `scriptResourceCache` (default TTL 5 min).
+- Rewrites VMess `server` for matched node names.
+
+## Config model (`config.json`)
+
+Key top-level blocks:
+- `api`: endpoint/method/headers/body/timeout
+- `parser`: domain extraction paths/regex
+- `domain_filter`: include suffixes / exclude regex
+- `scoring`: record ranking fields and strategy
+- `healthcheck`: TLS probe settings
+- `selection`: candidate cut (`top_n`)
+- `output`: runtime file names/paths
+- `v2ray`: optional token replacement rendering
+- `notify`: optional post-run command (`AUTODOMAIN`, `AUTODOMAIN_STATUS` env vars)
+
+## Runtime artifacts
+
+Generated in `runtime/`:
+- `current_domain.txt`: selected domain (plain text)
+- `current_domain.json`: selected domain + status + metadata
+- `state.json`: persistent state, including `last_good_domain`
+- `substore_vars.json`: export-friendly variables
+
+## Operational behavior that matters
+
+- Fallback behavior is stateful: `last_good_domain` persistence is critical for resilience.
+- `runtime-state` branch is intended to isolate frequently changing runtime outputs from `main` source history.
+- Debian installer (`scripts/install_debian.sh`) writes `/etc/vmess-domain-rotator.env` (service env), configures oneshot service+timer, and can store HTTPS token in `/var/lib/vmess-domain-rotator/git_http_token`.
+- For non-interactive systemd runs, prefer SSH deploy key or installer-provided HTTPS token env configuration.
+- Uninstaller removes systemd units and, by default, removes service auth/env files unless `--keep-auth-files` is set.

+ 39 - 5
README.md

@@ -150,9 +150,38 @@ Install on a Debian server (creates systemd service+timer):
 sudo bash scripts/install_debian.sh
 ```
 
-If you run this inside a git clone, installer now defaults to in-place mode (service uses current repo path), so auto-commit writes to your real repo. To force copy deployment under `/opt`, set `--app-dir` explicitly.
+This installer is in-place only (service runs from current git clone path). Runtime updates are committed to `runtime-state` and when push is enabled (`--git-push 1`, default), push failure is treated as a service failure.
 
-This installer initializes a git repo under app dir only in copy mode (if missing). Runtime updates are committed and pushed to `runtime-state` branch by default when selected domain changes, so `main` stays clean.
+Non-interactive push/auth options:
+
+```bash
+# Disable push completely (only local runtime-state commits)
+sudo bash scripts/install_debian.sh --git-push 0
+
+# Recommended: credential.helper store (same style as manual git config --global credential.helper store)
+sudo bash scripts/install_debian.sh \
+  --user aurora --group aurora \
+  --git-http-username aurora \
+  --git-http-token-file /root/.config/vmess-token \
+  --git-use-credential-store 1
+
+# Optional: set explicit credential store file
+sudo bash scripts/install_debian.sh \
+  --user aurora --group aurora \
+  --git-http-username aurora \
+  --git-http-token-file /root/.config/vmess-token \
+  --git-use-credential-store 1 \
+  --git-credentials-file /home/aurora/.git-credentials
+```
+
+Installer behavior for auth:
+- With `--git-use-credential-store 1`, installer configures service user global git credential helper and writes credentials via `git credential approve`.
+- With `--git-use-credential-store 0`, installer stores token at `/var/lib/vmess-domain-rotator/git_http_token` and service uses header-based auth.
+- Service environment is written to `/etc/vmess-domain-rotator.env`.
+
+Push behavior:
+- `--git-push 1` (default): commit + push are required; push failure exits non-zero so systemd marks run failed.
+- `--git-push 0`: only local runtime-state commit is attempted, push skipped.
 
 Uninstall:
 
@@ -160,12 +189,17 @@ Uninstall:
 sudo bash scripts/uninstall_debian.sh
 ```
 
+Keep auth/env files during uninstall:
+
+```bash
+sudo bash scripts/uninstall_debian.sh --keep-auth-files
+```
+
 Useful options:
 
 ```bash
-sudo bash scripts/install_debian.sh --user root --group root --interval 5min
-sudo bash scripts/install_debian.sh --app-dir /opt/vmess-domain-rotator
-sudo bash scripts/uninstall_debian.sh --keep-app-dir
+sudo bash scripts/install_debian.sh --user aurora --group aurora --interval 5min
+sudo bash scripts/install_debian.sh --git-push-remote origin
 ```
 
 ## Config notes

+ 61 - 75
git-push-authentication.md

@@ -1,117 +1,103 @@
-# Git Push 认证配置指南
+# Git Push 认证配置指南(无 Token 文件方案)
 
-## 问题分析
+> 适用场景:每台服务器上都有完整项目;你会先手动 push 一次,并用 `git config --global credential.helper store` 保存凭据;之后 systemd 服务无交互自动 push `runtime-state`。
 
-从日志可以看到,systemd 服务在尝试推送时遇到认证失败:
+## 核心结论
 
-```
-fatal: could not read Username for 'https://git.dewofaurora.de': No such device or address
-```
+可以,按你的逻辑完全可行:
 
-这是因为 systemd 服务在非交互式环境下运行,无法提示输入用户名和密码。
+1. 每台服务器手动配置 `credential.helper store`
+2. 每台服务器手动 `git push` 一次(触发输入用户名/密码或 Token)
+3. 后续服务定时任务可直接无交互 push
 
-## 解决方案
+不需要 `--git-http-token-file`。
 
-### 方案 1: 使用 Git Credential Store(推荐用于个人服务器)
+---
 
-在服务器上执行以下命令:
+## 每台服务器的标准步骤
 
-```bash
-# 1. 配置全局 credential helper
-git config --global credential.helper store
+假设项目目录为 `/home/aurora/vmess-domain-rotator`,远端为 `origin`。
 
-# 2. 手动推送一次以保存凭据
-cd /opt/vmess-domain-rotator  # 或者你的应用目录
-git push vmess-domain-rotator runtime-state:runtime-state
+### 1) 先确认 remote 是 HTTPS
 
-# 系统会提示输入用户名和密码,输入后会被保存到 ~/.git-credentials
+```bash
+cd /home/aurora/vmess-domain-rotator
+git remote get-url origin
 ```
 
-### 方案 2: 使用 Git Credential Cache(临时缓存)
+必须是 `https://...`,例如:
 
-```bash
-# 缓存凭据 1 小时(3600秒)
-git config --global credential.helper 'cache --timeout=3600'
-
-# 第一次推送需要输入凭据
-git push vmess-domain-rotator runtime-state:runtime-state
+```text
+https://git.example.com/aurora/vmess-domain-rotator.git
 ```
 
-### 方案 3: 使用 Personal Access Token(推荐用于 Git 服务器)
+> 如果是 SSH 地址(`git@...`),就不是 credential store 这套流程。
 
-如果你的 Git 服务器支持 Personal Access Token:
+### 2) 配置凭据持久化(服务用户)
 
 ```bash
-# 1. 在 Git 服务器上生成 Token(如 GitLab: Settings -> Access Tokens)
-
-# 2. 使用 Token 作为密码
-# 方法 A: 在推送时使用 Token URL
-git remote set-url vmess-domain-rotator https://username:TOKEN@git.dewofaurora.de/aurora/vmess-domain-rotator.git
-
-# 方法 B: 使用 credential store
 git config --global credential.helper store
-# 然后推送时用户名输入你的用户名,密码输入 Token
 ```
 
-### 方案 4: 使用 SSH URL(推荐用于生产环境)
+### 3) 手动 push 一次,触发保存凭据
 
-如果你的 Git 服务器支持 SSH
+你可以 push 任意分支(同一个 HTTPS 主机即可),例如:
 
 ```bash
-# 1. 切换远程 URL 为 SSH
-git remote set-url vmess-domain-rotator git@git.dewofaurora.de:aurora/vmess-domain-rotator.git
-
-# 2. 确保 systemd 用户有 SSH 密钥
-sudo -u vmessrotator ssh-keygen -t ed25519 -C "vmess-domain-rotator@localhost"
+git push origin main
+```
 
-# 3. 将公钥添加到 Git 服务器
-sudo -u vmessrotator cat /home/vmessrotator/.ssh/id_ed25519.pub
-# 复制公钥内容,然后在 Git 服务器的 SSH Keys 设置中添加
+首次会提示输入用户名与密码(或 Token),输入后会写入 `~/.git-credentials`。
 
-# 4. 测试 SSH 连接
-sudo -u vmessrotator ssh -T git@git.dewofaurora.de
+### 4) 安装 systemd 服务(不传 token 文件)
 
-# 5. 测试推送
-sudo -u vmessrotator git push vmess-domain-rotator runtime-state:runtime-state
+```bash
+sudo bash scripts/install_debian.sh \
+  --interval 1h \
+  --git-push 1 \
+  --git-use-credential-store 1
 ```
 
-## 验证配置
+> 默认会使用当前 `sudo` 执行者作为服务用户(即 `SUDO_USER`)。
 
-配置完成后,测试 systemd 服务:
+### 5) 验证服务是否能自动 push
 
 ```bash
-# 手动触发服务
 sudo systemctl start vmess-domain-rotator.service
+sudo journalctl -u vmess-domain-rotator.service -n 120 --no-pager
+```
 
-# 查看日志
-sudo journalctl -u vmess-domain-rotator.service -n 50
+日志应看到:
 
-# 确认看到 "pushed to vmess-domain-rotator/runtime-state" 或类似成功消息
-```
+- `committed domain change on runtime-state ...`
+- `pushed to origin/runtime-state`
 
-## 推荐方案
+---
 
-根据你的情况,建议使用 **方案 1 (Git Credential Store)**:
+## 为什么这套流程可行
 
-1. 简单易用,无需 SSH 密钥配置
-2. 持久化存储,重启后仍有效
-3. 适合个人服务器环境
+- `run_update_and_commit.sh` 在服务里是无交互执行(`GIT_TERMINAL_PROMPT=0`)。
+- 但只要服务用户已在本机有可用凭据(`credential.helper store` + 曾成功 push),后续 push 不需要再交互。
 
-执行步骤:
+---
 
-```bash
-# 在服务器上以服务用户身份执行
-sudo -u vmessrotator bash << 'EOF'
-cd /opt/vmess-domain-rotator  # 或你的应用目录
-git config --global credential.helper store
-git push vmess-domain-rotator runtime-state:runtime-state
-# 输入用户名和密码后,凭据会被保存
-EOF
-```
+## 换新服务器时怎么做
+
+每台服务器都重复同样流程(凭据不会跨机器同步):
+
+1. `git config --global credential.helper store`
+2. 手动 `git push` 一次保存凭据
+3. 运行 `install_debian.sh --git-push 1 --git-use-credential-store 1`
+
+即:**一机一初始化**。
+
+---
 
-## 安全建议
+## 常见坑
 
-1. **不要在共享服务器上使用 credential store**:如果服务器有其他用户,考虑使用 SSH 方案
-2. **定期轮换凭据**:建议每 90 天更换一次密码或 Token
-3. **使用最小权限 Token**:如果使用 Token,只授予 `write_repository` 权限
-4. **监控推送日志**:定期检查是否有异常推送记录
+1. **服务用户不一致**
+   - 你是 `aurora` 手动保存的凭据,但服务跑在别的用户下,就读不到。
+2. **remote 不是 HTTPS**
+   - `credential.helper store` 对 SSH remote 不生效。
+3. **凭据存储是明文**
+   - `~/.git-credentials` 为明文,请确保服务器权限可控。

+ 11 - 11
runtime/current_domain.json

@@ -1,30 +1,30 @@
 {
-  "domain": "5.cf.3666888.xyz",
-  "updated_at": "2026-04-16T04:07:40Z",
+  "domain": "01-cctv.com",
+  "updated_at": "2026-04-16T18:40:45Z",
   "status": "ok",
   "source_count": 20,
   "checked_count": 0,
   "top_candidates": [
     {
-      "domain": "5.cf.3666888.xyz",
+      "domain": "01-cctv.com",
       "scores": [
-        168.0
+        133.0
       ],
-      "created_raw": "2026-04-16 00:00:00"
+      "created_raw": "2026-04-17 00:00:00"
     },
     {
-      "domain": "2.cf.3666888.xyz",
+      "domain": "f.lma.de5.net",
       "scores": [
-        167.0
+        133.0
       ],
-      "created_raw": "2026-04-16 00:00:00"
+      "created_raw": "2026-04-17 00:00:00"
     },
     {
-      "domain": "op.chinwa.eu.cc",
+      "domain": "01-qq.com",
       "scores": [
-        163.0
+        132.0
       ],
-      "created_raw": "2026-04-16 00:00:00"
+      "created_raw": "2026-04-17 00:00:00"
     }
   ]
 }

+ 1 - 1
runtime/current_domain.txt

@@ -1 +1 @@
-5.cf.3666888.xyz
+01-cctv.com

+ 2 - 2
runtime/state.json

@@ -1,6 +1,6 @@
 {
-  "updated_at": "2026-04-16T04:07:40Z",
-  "last_good_domain": "5.cf.3666888.xyz",
+  "updated_at": "2026-04-16T18:40:45Z",
+  "last_good_domain": "01-cctv.com",
   "status": "ok",
   "source_count": 20,
   "checked_count": 0,

+ 2 - 2
runtime/substore_vars.json

@@ -1,5 +1,5 @@
 {
-  "AUTO_DOMAIN": "5.cf.3666888.xyz",
-  "UPDATED_AT": "2026-04-16T04:07:40Z",
+  "AUTO_DOMAIN": "01-cctv.com",
+  "UPDATED_AT": "2026-04-16T18:40:45Z",
   "STATUS": "ok"
 }

+ 11 - 2
scripts/domain_updater.py

@@ -367,9 +367,18 @@ def main():
     ap.add_argument("--config", default="config.json", help="Path to config JSON")
     args = ap.parse_args()
 
-    cfg = read_json_file(args.config)
-    runtime_dir = cfg.get("output", {}).get("runtime_dir", "./runtime")
+    config_path_abs = os.path.abspath(args.config)
+    if not os.path.exists(config_path_abs):
+        print(json.dumps({"status": "error", "error": f"config file not found: {config_path_abs}"}, ensure_ascii=True), file=sys.stderr)
+        sys.exit(1)
+
+    cfg = read_json_file(config_path_abs)
     output_cfg = cfg.get("output", {})
+    runtime_dir_cfg = output_cfg.get("runtime_dir", "./runtime")
+    if os.path.isabs(runtime_dir_cfg):
+        runtime_dir = runtime_dir_cfg
+    else:
+        runtime_dir = os.path.normpath(os.path.join(os.path.dirname(config_path_abs), runtime_dir_cfg))
     v2_cfg = cfg.get("v2ray", {})
     notify_cfg = cfg.get("notify", {})
 

+ 190 - 16
scripts/install_debian.sh

@@ -6,8 +6,16 @@ RUN_USER=""
 RUN_GROUP=""
 RUN_USER_SET="0"
 RUN_GROUP_SET="0"
+RUN_HOME=""
 INTERVAL="1h"
 INSTALL_DEPS="1"
+GIT_PUSH_ENABLED="1"
+GIT_PUSH_REMOTE="origin"
+GIT_HTTP_USERNAME="git"
+GIT_HTTP_TOKEN=""
+GIT_HTTP_TOKEN_FILE=""
+GIT_USE_CREDENTIAL_STORE="1"
+GIT_CREDENTIALS_FILE=""
 
 usage() {
 	cat <<'EOF'
@@ -16,21 +24,35 @@ Usage: sudo bash scripts/install_debian.sh [options]
 Default behavior:
 - Uses current git repository directory as working directory (in-place mode)
 - Uses the user executing sudo as service user
+- Enables git push after runtime-state commits
 
 Options:
- --user <name>        Service user (default: current sudo user)
- --group <name>       Service group (default: current sudo user's group)
- --interval <value>   Timer interval, e.g. 1h/10min (default: 1h)
- --no-install-deps    Skip apt dependency install
- -h, --help           Show help
+ --user <name>                  Service user (default: current sudo user)
+ --group <name>                 Service group (default: current sudo user's group)
+ --interval <value>             Timer interval, e.g. 1h/10min (default: 1h)
+ --git-push <0|1>               Enable/disable push to remote (default: 1)
+ --git-push-remote <name>       Remote name for push (default: origin)
+ --git-http-username <u>        Username for HTTPS auth (default: git)
+ --git-http-token <t>           HTTPS token for non-interactive push
+ --git-http-token-file <f>      Read HTTPS token from file
+ --git-use-credential-store <0|1> Use git credential.helper store (default: 1)
+ --git-credentials-file <f>     Custom credentials file for helper store
+ --no-install-deps              Skip apt dependency install
+ -h, --help                     Show help
 
 Examples:
  sudo bash scripts/install_debian.sh
  sudo bash scripts/install_debian.sh --interval 10min
- sudo bash scripts/install_debian.sh --user root --group root
+ sudo bash scripts/install_debian.sh --git-push 0
+ sudo bash scripts/install_debian.sh --git-http-username aurora --git-http-token-file /root/.config/vmess-token
+ sudo bash scripts/install_debian.sh --git-use-credential-store 1 --git-credentials-file /home/aurora/.git-credentials
 EOF
 }
 
+run_as_service_user() {
+	runuser -u "$RUN_USER" -- env HOME="$RUN_HOME" "$@"
+}
+
 while [[ $# -gt 0 ]]; do
 	case "$1" in
 		--user)
@@ -47,6 +69,34 @@ while [[ $# -gt 0 ]]; do
 			INTERVAL="$2"
 			shift 2
 			;;
+		--git-push)
+			GIT_PUSH_ENABLED="$2"
+			shift 2
+			;;
+		--git-push-remote)
+			GIT_PUSH_REMOTE="$2"
+			shift 2
+			;;
+		--git-http-username)
+			GIT_HTTP_USERNAME="$2"
+			shift 2
+			;;
+		--git-http-token)
+			GIT_HTTP_TOKEN="$2"
+			shift 2
+			;;
+		--git-http-token-file)
+			GIT_HTTP_TOKEN_FILE="$2"
+			shift 2
+			;;
+		--git-use-credential-store)
+			GIT_USE_CREDENTIAL_STORE="$2"
+			shift 2
+			;;
+		--git-credentials-file)
+			GIT_CREDENTIALS_FILE="$2"
+			shift 2
+			;;
 		--no-install-deps)
 			INSTALL_DEPS="0"
 			shift
@@ -68,19 +118,19 @@ if [[ "$(id -u)" -ne 0 ]]; then
 	exit 1
 fi
 
-# Get source directory (current git repo)
-SOURCE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+if ! command -v runuser >/dev/null 2>&1; then
+	echo "Error: runuser is required on Debian for configuring service-user git credentials" >&2
+	exit 1
+fi
 
-# Verify we're in a git repository
+SOURCE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
 if ! git -C "$SOURCE_DIR" rev-parse --is-inside-work-tree >/dev/null 2>&1; then
 	echo "Error: Current directory is not a git repository." >&2
 	echo "This script must be run from within a git repository." >&2
 	exit 1
 fi
-
 APP_DIR="$SOURCE_DIR"
 
-# Set default user/group from SUDO_USER if available
 if [[ -n "${SUDO_USER:-}" ]] && [[ "$RUN_USER_SET" != "1" ]]; then
 	RUN_USER="$SUDO_USER"
 fi
@@ -89,7 +139,6 @@ if [[ -n "${SUDO_USER:-}" ]] && [[ "$RUN_GROUP_SET" != "1" ]]; then
 	RUN_GROUP="$(id -gn "$SUDO_USER")"
 fi
 
-# Validate that we have a user set
 if [[ -z "$RUN_USER" ]]; then
 	echo "Error: Could not determine service user. Please run with sudo or specify --user" >&2
 	exit 1
@@ -100,18 +149,139 @@ if [[ -z "$RUN_GROUP" ]]; then
 	exit 1
 fi
 
+if [[ ! "$GIT_PUSH_ENABLED" =~ ^[01]$ ]]; then
+	echo "Error: --git-push must be 0 or 1" >&2
+	exit 1
+fi
+
+if [[ ! "$GIT_USE_CREDENTIAL_STORE" =~ ^[01]$ ]]; then
+	echo "Error: --git-use-credential-store must be 0 or 1" >&2
+	exit 1
+fi
+
+if [[ -z "$GIT_PUSH_REMOTE" ]]; then
+	echo "Error: --git-push-remote cannot be empty" >&2
+	exit 1
+fi
+
+if [[ -n "$GIT_HTTP_TOKEN" ]] && [[ -n "$GIT_HTTP_TOKEN_FILE" ]]; then
+	echo "Error: provide either --git-http-token or --git-http-token-file, not both" >&2
+	exit 1
+fi
+
+if [[ -n "$GIT_HTTP_TOKEN_FILE" ]] && [[ ! -r "$GIT_HTTP_TOKEN_FILE" ]]; then
+	echo "Error: cannot read token file: $GIT_HTTP_TOKEN_FILE" >&2
+	exit 1
+fi
+
+if [[ -n "$GIT_HTTP_TOKEN_FILE" ]]; then
+	GIT_HTTP_TOKEN="$(tr -d '\r\n' < "$GIT_HTTP_TOKEN_FILE")"
+fi
+
+if [[ -n "$GIT_HTTP_TOKEN" ]] && [[ -z "$GIT_HTTP_USERNAME" ]]; then
+	echo "Error: --git-http-username cannot be empty when token is set" >&2
+	exit 1
+fi
+
+if [[ -n "$GIT_HTTP_TOKEN" ]] && [[ "$RUN_USER" == "root" ]]; then
+	echo "Error: refusing to store git token for root service user" >&2
+	echo "Use --user <non-root> or disable push with --git-push 0" >&2
+	exit 1
+fi
+
+RUN_HOME="$(getent passwd "$RUN_USER" | cut -d: -f6)"
+if [[ -z "$RUN_HOME" ]]; then
+	echo "Error: could not determine home directory for user: $RUN_USER" >&2
+	exit 1
+fi
+
 if [[ "$INSTALL_DEPS" == "1" ]]; then
 	export DEBIAN_FRONTEND=noninteractive
 	apt-get update -y
 	apt-get install -y python3 ca-certificates git
 fi
 
-# Ensure runtime directory exists with correct permissions
 mkdir -p "$APP_DIR/runtime"
 chmod +x "$APP_DIR/scripts/run_update_and_commit.sh" || true
 chown -R "$RUN_USER:$RUN_GROUP" "$APP_DIR/runtime"
 
-# Generate systemd service unit
+SERVICE_STATE_DIR="/var/lib/${SERVICE_NAME}"
+ENV_FILE="/etc/${SERVICE_NAME}.env"
+TOKEN_FILE=""
+REMOTE_URL=""
+AUTH_MODE="header"
+
+if [[ "$GIT_USE_CREDENTIAL_STORE" == "1" ]]; then
+	AUTH_MODE="credential-helper-store"
+fi
+
+mkdir -p "$SERVICE_STATE_DIR"
+chown "$RUN_USER:$RUN_GROUP" "$SERVICE_STATE_DIR"
+chmod 750 "$SERVICE_STATE_DIR"
+
+if [[ "$GIT_PUSH_ENABLED" == "1" ]]; then
+	REMOTE_URL="$(git -C "$APP_DIR" remote get-url "$GIT_PUSH_REMOTE" 2>/dev/null || true)"
+	if [[ -z "$REMOTE_URL" ]]; then
+		echo "Warning: remote '$GIT_PUSH_REMOTE' not found now. Push may fail until remote is configured." >&2
+	fi
+fi
+
+if [[ -n "$GIT_HTTP_TOKEN" ]]; then
+	if [[ "$GIT_USE_CREDENTIAL_STORE" == "1" ]]; then
+		if [[ "$REMOTE_URL" =~ ^https:// ]]; then
+			helper_value="store"
+			if [[ -n "$GIT_CREDENTIALS_FILE" ]]; then
+				helper_value="store --file ${GIT_CREDENTIALS_FILE}"
+				mkdir -p "$(dirname "$GIT_CREDENTIALS_FILE")"
+				touch "$GIT_CREDENTIALS_FILE"
+				chown "$RUN_USER:$RUN_GROUP" "$GIT_CREDENTIALS_FILE"
+				chmod 600 "$GIT_CREDENTIALS_FILE"
+			fi
+
+			run_as_service_user git config --global credential.helper "$helper_value"
+			printf 'url=%s\nusername=%s\npassword=%s\n\n' "$REMOTE_URL" "$GIT_HTTP_USERNAME" "$GIT_HTTP_TOKEN" | run_as_service_user git credential approve
+		else
+			echo "Warning: token provided but remote is not HTTPS; credential.helper store setup skipped." >&2
+			echo "Warning: fallback to header-token-file auth mode for this install." >&2
+			GIT_USE_CREDENTIAL_STORE="0"
+		fi
+	fi
+
+	if [[ "$GIT_USE_CREDENTIAL_STORE" != "1" ]]; then
+		TOKEN_FILE="${SERVICE_STATE_DIR}/git_http_token"
+		printf '%s\n' "$GIT_HTTP_TOKEN" >"$TOKEN_FILE"
+		chown "$RUN_USER:$RUN_GROUP" "$TOKEN_FILE"
+		chmod 600 "$TOKEN_FILE"
+		AUTH_MODE="header-token-file"
+	fi
+fi
+
+run_as_service_user git config --global --add safe.directory "$APP_DIR" || true
+
+cat >"$ENV_FILE" <<EOF
+GIT_PUSH_ENABLED=${GIT_PUSH_ENABLED}
+GIT_PUSH_REQUIRED=${GIT_PUSH_ENABLED}
+GIT_PUSH_REMOTE=${GIT_PUSH_REMOTE}
+GIT_RUNTIME_BRANCH=runtime-state
+GIT_HTTP_USERNAME=${GIT_HTTP_USERNAME}
+HOME=${RUN_HOME}
+EOF
+
+if [[ "$GIT_USE_CREDENTIAL_STORE" == "1" ]]; then
+	if [[ -n "$GIT_CREDENTIALS_FILE" ]]; then
+		printf 'GIT_CREDENTIAL_HELPER=store --file %s\n' "$GIT_CREDENTIALS_FILE" >>"$ENV_FILE"
+	else
+		printf 'GIT_CREDENTIAL_HELPER=store\n' >>"$ENV_FILE"
+	fi
+fi
+
+if [[ -n "$TOKEN_FILE" ]]; then
+	printf 'GIT_HTTP_TOKEN_FILE=%s\n' "$TOKEN_FILE" >>"$ENV_FILE"
+fi
+
+chown root:root "$ENV_FILE"
+chmod 600 "$ENV_FILE"
+
 cat >"/etc/systemd/system/${SERVICE_NAME}.service" <<EOF
 [Unit]
 Description=VMess Domain Rotator updater
@@ -123,10 +293,11 @@ Type=oneshot
 User=${RUN_USER}
 Group=${RUN_GROUP}
 WorkingDirectory=${APP_DIR}
+EnvironmentFile=-${ENV_FILE}
+UMask=0077
 ExecStart=/bin/bash ${APP_DIR}/scripts/run_update_and_commit.sh ${APP_DIR}/config.json
 EOF
 
-# Generate systemd timer unit
 cat >"/etc/systemd/system/${SERVICE_NAME}.timer" <<EOF
 [Unit]
 Description=Run VMess Domain Rotator every ${INTERVAL}
@@ -142,7 +313,6 @@ Persistent=true
 WantedBy=timers.target
 EOF
 
-# Enable and start service
 systemctl daemon-reload
 systemctl enable --now "${SERVICE_NAME}.timer"
 systemctl start "${SERVICE_NAME}.service"
@@ -155,6 +325,10 @@ echo "  Working directory: ${APP_DIR}"
 echo "  Service user: ${RUN_USER}"
 echo "  Service group: ${RUN_GROUP}"
 echo "  Timer interval: ${INTERVAL}"
+echo "  Push enabled: ${GIT_PUSH_ENABLED}"
+echo "  Push remote: ${GIT_PUSH_REMOTE}"
+echo "  Auth mode: ${AUTH_MODE}"
+echo "  Env file: ${ENV_FILE}"
 echo ""
 echo "Commands:"
 echo "  Check status: systemctl status ${SERVICE_NAME}.timer"

+ 70 - 16
scripts/run_update_and_commit.sh

@@ -1,10 +1,45 @@
 #!/usr/bin/env bash
 set -euo pipefail
 
-APP_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+DEFAULT_APP_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)"
+APP_DIR="$(git -C "$DEFAULT_APP_DIR" rev-parse --show-toplevel 2>/dev/null || printf '%s' "$DEFAULT_APP_DIR")"
 CONFIG_PATH="${1:-${APP_DIR}/config.json}"
 DOMAIN_FILE="${APP_DIR}/runtime/current_domain.txt"
 
+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:-}"
+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_opts=()
+if [[ -n "$http_token" ]]; then
+  auth_b64="$(printf '%s:%s' "$http_user" "$http_token" | base64 | tr -d '\n')"
+  git_auth_opts=(-c "http.extraHeader=Authorization: Basic ${auth_b64}")
+fi
+
+git_auth() {
+  local dir="$1"
+  shift
+  if [[ -n "$credential_helper" ]]; then
+    git -C "$dir" -c credential.helper="$credential_helper" "${git_auth_opts[@]}" "$@"
+  else
+    git -C "$dir" "${git_auth_opts[@]}" "$@"
+  fi
+}
+
 before=""
 if [[ -f "$DOMAIN_FILE" ]]; then
   before="$(tr -d '\r\n' < "$DOMAIN_FILE")"
@@ -33,16 +68,16 @@ if ! command -v git >/dev/null 2>&1; then
 fi
 
 if ! git -C "$APP_DIR" rev-parse --is-inside-work-tree >/dev/null 2>&1; then
-  echo "[vmess-domain-rotator] not a git repo, skip git commit"
+  echo "[vmess-domain-rotator] not a git repo at ${APP_DIR}, skip git commit"
+  echo "[vmess-domain-rotator] hint: reinstall service from a git clone path"
   exit 0
 fi
 
-commit_name="${GIT_COMMIT_NAME:-vmess-domain-rotator}"
-commit_email="${GIT_COMMIT_EMAIL:-vmess-domain-rotator@localhost}"
-runtime_branch="${GIT_RUNTIME_BRANCH:-runtime-state}"
-ts="$(date -u +"%Y-%m-%dT%H:%M:%SZ")"
+if ! git -C "$APP_DIR" rev-parse --verify HEAD >/dev/null 2>&1; then
+  echo "[vmess-domain-rotator] repo has no commits yet, skip git commit"
+  exit 0
+fi
 
-push_remote="${GIT_PUSH_REMOTE:-origin}"
 if ! git -C "$APP_DIR" remote get-url "$push_remote" >/dev/null 2>&1; then
   push_remote=""
   while IFS= read -r r; do
@@ -64,8 +99,8 @@ else
   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
-    if [[ -n "$push_remote" ]] && git -C "$APP_DIR" ls-remote --exit-code --heads "$push_remote" "$runtime_branch" >/dev/null 2>&1; then
-      git -C "$APP_DIR" fetch "$push_remote" "$runtime_branch:$runtime_branch"
+    if [[ -n "$push_remote" ]] && git_auth "$APP_DIR" ls-remote --exit-code --heads "$push_remote" "$runtime_branch" >/dev/null 2>&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"
@@ -83,6 +118,12 @@ if [[ "$cleanup_worktree" == "1" ]]; then
   trap cleanup EXIT
 fi
 
+work_branch="$(git -C "$work_dir" symbolic-ref --quiet --short HEAD 2>/dev/null || true)"
+if [[ "$work_branch" != "$runtime_branch" ]]; then
+  echo "[vmess-domain-rotator] safety check failed: worktree branch is '${work_branch:-detached}', expected '${runtime_branch}'"
+  exit 1
+fi
+
 mkdir -p "$work_dir/runtime"
 for file in current_domain.txt current_domain.json state.json substore_vars.json; do
   src="$APP_DIR/runtime/$file"
@@ -106,20 +147,33 @@ git -C "$work_dir" \
   -c user.email="$commit_email" \
   commit -m "chore: rotate preferred domain to ${after} (${ts})"
 
-if [[ -z "$push_remote" ]]; then
+if [[ "$push_enabled" != "1" ]]; then
+  echo "[vmess-domain-rotator] git push disabled by GIT_PUSH_ENABLED=${push_enabled}"
+elif [[ -z "$push_remote" ]]; then
+  if [[ "$push_required" == "1" ]]; then
+    echo "[vmess-domain-rotator] no remote found but push is required"
+    exit 1
+  fi
   echo "[vmess-domain-rotator] no remote found, skip git push"
 else
+  push_ok="0"
   if git -C "$work_dir" rev-parse --abbrev-ref --symbolic-full-name "@{u}" >/dev/null 2>&1; then
-    if git -C "$work_dir" push "$push_remote" "$runtime_branch:$runtime_branch"; then
+    if git_auth "$work_dir" push "$push_remote" "$runtime_branch:$runtime_branch"; then
+      push_ok="1"
       echo "[vmess-domain-rotator] pushed to ${push_remote}/${runtime_branch}"
-    else
-      echo "[vmess-domain-rotator] git push failed"
     fi
   else
-    if git -C "$work_dir" push -u "$push_remote" "$runtime_branch:$runtime_branch"; then
+    if git_auth "$work_dir" push -u "$push_remote" "$runtime_branch:$runtime_branch"; then
+      push_ok="1"
       echo "[vmess-domain-rotator] pushed to ${push_remote}/${runtime_branch} (set upstream)"
-    else
-      echo "[vmess-domain-rotator] git push failed"
+    fi
+  fi
+
+  if [[ "$push_ok" != "1" ]]; then
+    echo "[vmess-domain-rotator] git push failed"
+    echo "[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

+ 0 - 6
scripts/runtime/state.json

@@ -1,6 +0,0 @@
-{
-  "updated_at": "2026-04-13T16:18:30Z",
-  "status": "error",
-  "error": "'api'",
-  "last_good_domain": ""
-}

+ 24 - 3
scripts/uninstall_debian.sh

@@ -2,6 +2,7 @@
 set -euo pipefail
 
 SERVICE_NAME="vmess-domain-rotator"
+REMOVE_AUTH_FILES="1"
 
 usage() {
 	cat <<'EOF'
@@ -10,14 +11,17 @@ Usage: sudo bash scripts/uninstall_debian.sh
 This script will:
 - Stop and disable the systemd timer and service
 - Remove systemd unit files
+- Remove environment/auth files created by installer (default)
 - Keep your git repository and all files intact
 
 Options:
  --service-name <name> Service base name (default: vmess-domain-rotator)
+ --keep-auth-files     Keep /etc/<service>.env and /var/lib/<service>
  -h, --help            Show help
 
 Examples:
  sudo bash scripts/uninstall_debian.sh
+ sudo bash scripts/uninstall_debian.sh --keep-auth-files
 EOF
 }
 
@@ -27,6 +31,10 @@ while [[ $# -gt 0 ]]; do
 			SERVICE_NAME="$2"
 			shift 2
 			;;
+		--keep-auth-files)
+			REMOVE_AUTH_FILES="0"
+			shift
+			;;
 		-h|--help)
 			usage
 			exit 0
@@ -44,6 +52,9 @@ if [[ "$(id -u)" -ne 0 ]]; then
 	exit 1
 fi
 
+ENV_FILE="/etc/${SERVICE_NAME}.env"
+STATE_DIR="/var/lib/${SERVICE_NAME}"
+
 # Stop and disable timer
 if systemctl list-unit-files | grep -q "^${SERVICE_NAME}.timer"; then
 	echo "Stopping and disabling ${SERVICE_NAME}.timer..."
@@ -61,12 +72,22 @@ echo "Removing systemd unit files..."
 rm -f "/etc/systemd/system/${SERVICE_NAME}.service"
 rm -f "/etc/systemd/system/${SERVICE_NAME}.timer"
 
+if [[ "$REMOVE_AUTH_FILES" == "1" ]]; then
+	echo "Removing auth/env files..."
+	rm -f "$ENV_FILE"
+	rm -rf "$STATE_DIR"
+fi
+
 systemctl daemon-reload
 systemctl reset-failed
 
 echo ""
 echo "✓ Uninstall complete!"
 echo ""
-echo "Note: Your git repository and all files have been preserved."
-echo "      To completely remove, manually delete the repository directory if needed."
-echo ""
+if [[ "$REMOVE_AUTH_FILES" == "1" ]]; then
+	echo "Note: Service env/auth files have been removed."
+else
+	echo "Note: Service env/auth files were kept."
+fi
+echo "      Your git repository and project files have been preserved."
+echo ""