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])
|
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")
|
||||||
|
|||||||
@@ -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