chore(scripts): 推送提权验证脚本与温胜夜生产验证报告
This commit is contained in:
@@ -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
|
||||
```
|
||||
@@ -124,6 +124,10 @@ async def run_patch(args: argparse.Namespace) -> list[dict[str, Any]]:
|
||||
|
||||
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"))
|
||||
fail = len(results) - ok
|
||||
print(f"\nDone: {ok} ok / {fail} fail / {len(results)} total")
|
||||
|
||||
@@ -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())
|
||||
Reference in New Issue
Block a user