#!/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())