chore(scripts): 推送提权验证脚本与温胜夜生产验证报告
Nexus CI/CD / test (push) Waiting to run
Nexus CI/CD / e2e (push) Blocked by required conditions
Nexus CI/CD / deploy (push) Blocked by required conditions
Nexus Pre-commit Checks / quick-check (push) Waiting to run

This commit is contained in:
Nexus Agent
2026-06-11 23:28:59 +08:00
parent 84b388fae2
commit f11ad35708
3 changed files with 221 additions and 0 deletions
@@ -0,0 +1,35 @@
# 温胜夜推送提权 — 生产验证报告
**日期**: 2026-06-11
**部署版本**: `84b388fa` fix(sync): rsync 推送非 root 自动 sudo 提权
## 背景
推送失败:`touch ... m.wenshengye.com ... Permission denied`SSH 用户 `ubuntu`,非 root)。
## 处理
1. 部署 rsync `--rsync-path=sudo -n rsync`(非 root + 默认 `auto_sudo`
2. 批量 sudoers 探测:两台 `sudo -n rsync` 已为 `already_ok`
3. 生产探针推送(`scripts/verify_push_elevation.py --name 温胜夜 --push`
## 结果
| ID | 名称 | SSH | 目标路径 | rsync sudo | 写入(sudo) | dry-run | 实推 |
|----|------|-----|----------|------------|------------|---------|------|
| 313 | 武汉市温胜夜科技有限公司 | ubuntu | `/www/wwwroot/wenshengye.com` | ✓ | ✓ | exit 0 | exit 0, elevated |
| 314 | 子-武汉市温胜夜科技有限公司 | ubuntu | `/www/wwwroot/m.wenshengye.com` | ✓ | ✓ | exit 0 | exit 0, elevated |
**结论**: 2/2 通过。通道已恢复。
## 用户操作
原批次源目录 `/tmp/nexus_upload_dbc7758062ff` 为临时上传目录,可能已被清理,请在推送页**重新上传 ZIP/文件**后再推一次。
## 复验命令(生产容器内)
```bash
cd /opt/nexus
sudo docker cp scripts/verify_push_elevation.py nexus-prod-nexus-1:/app/scripts/
sudo docker exec -w /app nexus-prod-nexus-1 python scripts/verify_push_elevation.py --name 温胜夜 --push
```
+4
View File
@@ -124,6 +124,10 @@ async def run_patch(args: argparse.Namespace) -> list[dict[str, Any]]:
await asyncio.gather(*[_one(s) for s in servers]) await asyncio.gather(*[_one(s) for s in servers])
from server.infrastructure.redis.client import close_redis
await close_redis()
ok = sum(1 for r in results if r.get("ok")) ok = sum(1 for r in results if r.get("ok"))
fail = len(results) - ok fail = len(results) - ok
print(f"\nDone: {ok} ok / {fail} fail / {len(results)} total") print(f"\nDone: {ok} ok / {fail} fail / {len(results)} total")
+182
View File
@@ -0,0 +1,182 @@
#!/usr/bin/env python3
"""Verify rsync push elevation for selected servers (diagnose + dry-run + optional probe push).
Usage:
python scripts/verify_push_elevation.py --ids 313,314
python scripts/verify_push_elevation.py --name 温胜夜 --push
"""
from __future__ import annotations
import argparse
import asyncio
import os
import secrets
import sys
import tempfile
from pathlib import Path
from typing import Any
ROOT = Path(__file__).resolve().parents[1]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
def _load_dotenv() -> None:
env_path = ROOT / ".env"
if not env_path.is_file():
return
for line in env_path.read_text(encoding="utf-8", errors="replace").splitlines():
line = line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
key, _, value = line.partition("=")
key, value = key.strip(), value.strip().strip("\"'")
if key.startswith("NEXUS_") and key[6:] not in os.environ:
os.environ[key[6:]] = value
elif key and key not in os.environ:
os.environ[key] = value
async def _fetch_servers(session: Any, *, ids: list[int] | None, name_like: str | None) -> list[Any]:
from sqlalchemy import or_, select
from server.domain.models import Server
q = select(Server).order_by(Server.id)
if ids:
q = q.where(Server.id.in_(ids))
if name_like:
q = q.where(or_(Server.name.like(f"%{name_like}%"), Server.domain.like(f"%{name_like}%")))
result = await session.execute(q)
return list(result.scalars().all())
async def _diagnose_write(server: Any, dest: str) -> dict[str, Any]:
import shlex
from server.infrastructure.ssh.remote_shell import exec_ssh_command_with_fallback
from server.utils.posix_paths import remote_join
test_file = remote_join(dest, ".nexus_verify_probe")
cmd = f"touch {shlex.quote(test_file)} && rm -f {shlex.quote(test_file)}"
r = await exec_ssh_command_with_fallback(server, cmd, timeout=15)
return {
"path_writable": r.get("exit_code") == 0,
"elevated": bool(r.get("elevated")),
"stderr": (r.get("stderr") or "")[:200],
}
async def run_verify(args: argparse.Namespace) -> list[dict[str, Any]]:
from server.application.services.files_sudoers_service import probe_rsync_sudo
from server.application.services.sync_engine_v2 import _rsync_push
from server.config import settings
from server.infrastructure.database.session import AsyncSessionLocal, init_db
from server.infrastructure.redis.client import close_redis, init_redis
from server.utils.posix_paths import UPLOAD_STAGING_PREFIX, normalize_remote_abs_path
await init_db()
await init_redis(settings.REDIS_URL)
ids: list[int] | None = None
if args.ids:
ids = [int(x.strip()) for x in args.ids.split(",") if x.strip()]
async with AsyncSessionLocal() as session:
servers = await _fetch_servers(session, ids=ids, name_like=args.name)
if not servers:
print("No servers matched.", file=sys.stderr)
return []
staging = f"{UPLOAD_STAGING_PREFIX}{secrets.token_hex(6)}"
os.makedirs(staging, exist_ok=True)
probe_name = ".nexus_push_probe.txt"
probe_path = os.path.join(staging, probe_name)
with open(probe_path, "w", encoding="utf-8") as fh:
fh.write("nexus push elevation verify\n")
results: list[dict[str, Any]] = []
try:
for server in servers:
dest = normalize_remote_abs_path(server.target_path or "/tmp/sync")
row: dict[str, Any] = {
"server_id": server.id,
"server_name": server.name,
"ssh_user": server.username,
"target_path": dest,
}
row["rsync_sudo"] = await probe_rsync_sudo(server)
write = await _diagnose_write(server, dest)
row.update(write_check=write)
preview = await _rsync_push(
server, staging, dest, sync_mode="incremental", dry_run=True
)
row["dry_run_exit"] = preview.get("exit_code")
row["dry_run_ok"] = preview.get("exit_code") in (0, 23)
if not row["dry_run_ok"]:
row["dry_run_stderr"] = (preview.get("stderr") or "")[:300]
if args.push and row["dry_run_ok"]:
push = await _rsync_push(server, staging, dest, sync_mode="incremental")
row["push_exit"] = push.get("exit_code")
row["push_ok"] = push.get("exit_code") == 0
row["push_elevated"] = bool(push.get("elevated"))
if not row["push_ok"]:
row["push_stderr"] = (push.get("stderr") or "")[:300]
ok = (
row.get("rsync_sudo")
and write.get("path_writable")
and row.get("dry_run_ok")
and (not args.push or row.get("push_ok"))
)
row["ok"] = ok
results.append(row)
status = "OK" if ok else "FAIL"
print(
f"[{status}] #{server.id} {server.name} user={server.username} dest={dest}\n"
f" rsync_sudo={row['rsync_sudo']} write={write.get('path_writable')} "
f"elevated={write.get('elevated')} dry_run={row.get('dry_run_exit')}"
+ (
f" push={row.get('push_exit')} push_elevated={row.get('push_elevated')}"
if args.push
else ""
),
flush=True,
)
if row.get("dry_run_stderr"):
print(f" dry_run_err: {row['dry_run_stderr']}", flush=True)
if row.get("push_stderr"):
print(f" push_err: {row['push_stderr']}", flush=True)
finally:
import shutil
shutil.rmtree(staging, ignore_errors=True)
await close_redis()
ok_n = sum(1 for r in results if r.get("ok"))
print(f"\nVerify: {ok_n}/{len(results)} passed")
return results
def main() -> int:
_load_dotenv()
parser = argparse.ArgumentParser(description="Verify push elevation on servers")
parser.add_argument("--ids", type=str, help="Comma-separated server ids")
parser.add_argument("--name", type=str, help="Filter name/domain")
parser.add_argument("--push", action="store_true", help="Run real probe file push after dry-run")
args = parser.parse_args()
if not args.ids and not args.name:
parser.error("Specify --ids or --name")
results = asyncio.run(run_verify(args))
return 0 if results and all(r.get("ok") for r in results) else 1
if __name__ == "__main__":
raise SystemExit(main())